Merge branch '5.3' into 5.4
authorTim Düsterhus <duesterhus@woltlab.com>
Wed, 9 Mar 2022 12:55:24 +0000 (13:55 +0100)
committerTim Düsterhus <duesterhus@woltlab.com>
Wed, 9 Mar 2022 12:55:24 +0000 (13:55 +0100)
1  2 
wcfsetup/install/files/lib/data/like/LikeAction.class.php
wcfsetup/install/files/lib/data/media/MediaAction.class.php
wcfsetup/install/files/lib/data/user/follow/UserFollowAction.class.php
wcfsetup/install/files/lib/data/user/follow/UserFollowingAction.class.php
wcfsetup/install/files/lib/data/user/profile/visitor/UserProfileVisitorAction.class.php
wcfsetup/install/files/lib/data/user/trophy/UserTrophyAction.class.php
wcfsetup/install/files/lib/system/database/Database.class.php

index 25532dbf02be8ae7add8d65d8b582a1fc50e5592,51e5597543ac369873d881f0ff66ad2dfdfaa03d..e0c67e9243f72c2cb5341fd77843eb8b7a4cbf71
@@@ -17,356 -14,342 +17,360 @@@ use wcf\system\WCF
  
  /**
   * Executes like-related actions.
 - * 
 - * @author    Alexander Ebert
 - * @copyright 2001-2019 WoltLab GmbH
 - * @license   GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
 - * @package   WoltLabSuite\Core\Data\Like
 - * @deprecated        since 5.2, use \wcf\data\reaction\ReactionAction instead
 - * 
 - * @method    Like            create()
 - * @method    LikeEditor[]    getObjects()
 - * @method    LikeEditor      getSingleObject()
 + *
 + * @author  Alexander Ebert
 + * @copyright   2001-2019 WoltLab GmbH
 + * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
 + * @package WoltLabSuite\Core\Data\Like
 + * @deprecated  since 5.2, use \wcf\data\reaction\ReactionAction instead
 + *
 + * @method  Like        create()
 + * @method  LikeEditor[]    getObjects()
 + * @method  LikeEditor  getSingleObject()
   */
 -class LikeAction extends AbstractDatabaseObjectAction implements IGroupedUserListAction {
 -      /**
 -       * @inheritDoc
 -       */
 -      protected $allowGuestAccess = ['getGroupedUserList', 'getLikeDetails', 'load'];
 -      
 -      /**
 -       * @inheritDoc
 -       */
 -      protected $className = LikeEditor::class;
 -      
 -      /**
 -       * likeable object
 -       * @var \wcf\data\like\object\ILikeObject
 -       */
 -      public $likeableObject = null;
 -      
 -      /**
 -       * object type object
 -       * @var \wcf\data\object\type\ObjectType
 -       */
 -      public $objectType = null;
 -      
 -      /**
 -       * like object type provider object
 -       * @var ILikeObjectTypeProvider
 -       */
 -      public $objectTypeProvider = null;
 -      
 -      /**
 -       * Validates parameters to fetch like details.
 -       */
 -      public function validateGetLikeDetails() {
 -              $this->validateObjectParameters();
 -      }
 -      
 -      /**
 -       * Returns like details.
 -       * 
 -       * @return      string[]
 -       */
 -      public function getLikeDetails() {
 -              $sql = "SELECT          userID, likeValue
 -                      FROM            wcf".WCF_N."_like
 -                      WHERE           objectID = ?
 -                                      AND objectTypeID = ?
 -                      ORDER BY        time DESC";
 -              $statement = WCF::getDB()->prepareStatement($sql);
 -              $statement->execute([
 -                      $this->parameters['data']['objectID'],
 -                      $this->objectType->objectTypeID
 -              ]);
 -              $data = [
 -                      Like::LIKE => [],
 -                      Like::DISLIKE => []
 -              ];
 -              while ($row = $statement->fetchArray()) {
 -                      $data[$row['likeValue']][] = $row['userID'];
 -              }
 -              
 -              $values = [];
 -              if (!empty($data[Like::LIKE])) {
 -                      $values[Like::LIKE] = new GroupedUserList(WCF::getLanguage()->get('wcf.like.details.like'));
 -                      /** @noinspection PhpUndefinedMethodInspection */
 -                      $values[Like::LIKE]->addUserIDs($data[Like::LIKE]);
 -              }
 -              if (!empty($data[Like::DISLIKE])) {
 -                      $values[Like::DISLIKE] = new GroupedUserList(WCF::getLanguage()->get('wcf.like.details.dislike'));
 -                      /** @noinspection PhpUndefinedMethodInspection */
 -                      $values[Like::DISLIKE]->addUserIDs($data[Like::DISLIKE]);
 -              }
 -              
 -              // load user profiles
 -              GroupedUserList::loadUsers();
 -              
 -              WCF::getTPL()->assign([
 -                      'groupedUsers' => $values
 -              ]);
 -              
 -              return [
 -                      'containerID' => $this->parameters['data']['containerID'],
 -                      'template' => WCF::getTPL()->fetch('groupedUserList')
 -              ];
 -      }
 -      
 -      /**
 -       * Validates parameters for like-related actions.
 -       */
 -      public function validateLike() {
 -              $this->validateObjectParameters();
 -              
 -              // check permissions
 -              if (!WCF::getUser()->userID || !WCF::getSession()->getPermission('user.like.canLike')) {
 -                      throw new PermissionDeniedException();
 -              }
 -              
 -              // check if liking own content but forbidden by configuration
 -              $this->likeableObject = $this->objectTypeProvider->getObjectByID($this->parameters['data']['objectID']);
 -              $this->likeableObject->setObjectType($this->objectType);
 -              if ($this->likeableObject->getUserID() == WCF::getUser()->userID) {
 -                      throw new PermissionDeniedException();
 -              }
 -      }
 -      
 -      /**
 -       * @inheritDoc
 -       */
 -      public function like() {
 -              return $this->updateLike(Like::LIKE);
 -      }
 -      
 -      /**
 -       * @inheritDoc
 -       */
 -      public function validateDislike() {
 -              // No longer supported since 5.2.
 -              throw new PermissionDeniedException();
 -      }
 -      
 -      /**
 -       * @inheritDoc
 -       */
 -      public function dislike() {
 -              return $this->updateLike(Like::DISLIKE);
 -      }
 -      
 -      /**
 -       * Sets like/dislike for an object, executing this method again with the same parameters
 -       * will revert the status (removing like/dislike).
 -       * 
 -       * @param       integer         $likeValue
 -       * @return      array
 -       */
 -      protected function updateLike($likeValue) {
 -              $likeData = LikeHandler::getInstance()->like($this->likeableObject, WCF::getUser(), $likeValue);
 -              
 -              // handle activity event
 -              if (UserActivityEventHandler::getInstance()->getObjectTypeID($this->objectType->objectType.'.recentActivityEvent')) {
 -                      if ($likeData['data']['liked'] == 1) {
 -                              UserActivityEventHandler::getInstance()->fireEvent($this->objectType->objectType.'.recentActivityEvent', $this->parameters['data']['objectID'], $this->likeableObject->getLanguageID());
 -                      }
 -                      else {
 -                              UserActivityEventHandler::getInstance()->removeEvent($this->objectType->objectType.'.recentActivityEvent', $this->parameters['data']['objectID']);
 -                      }
 -              }
 -              
 -              // get stats
 -              return [
 -                      'likes' => ($likeData['data']['likes'] === null) ? 0 : $likeData['data']['likes'],
 -                      'dislikes' => ($likeData['data']['dislikes'] === null) ? 0 : $likeData['data']['dislikes'],
 -                      'cumulativeLikes' => ($likeData['data']['cumulativeLikes'] === null) ? 0 : $likeData['data']['cumulativeLikes'],
 -                      'isLiked' => ($likeData['data']['liked'] == 1) ? 1 : 0,
 -                      'isDisliked' => ($likeData['data']['liked'] == -1) ? 1 : 0,
 -                      'containerID' => $this->parameters['data']['containerID'],
 -                      'newValue' => $likeData['newValue'],
 -                      'oldValue' => $likeData['oldValue'],
 -                      'users' => $likeData['users']
 -              ];
 -      }
 -      
 -      /**
 -       * Validates permissions for given object.
 -       */
 -      protected function validateObjectParameters() {
 -              if (!MODULE_LIKE) {
 -                      throw new PermissionDeniedException();
 -              }
 -              
 -              $this->readString('containerID', false, 'data');
 -              $this->readInteger('objectID', false, 'data');
 -              $this->readString('objectType', false, 'data');
 -              
 -              $this->objectType = LikeHandler::getInstance()->getObjectType($this->parameters['data']['objectType']);
 -              if ($this->objectType === null) {
 -                      throw new UserInputException('objectType');
 -              }
 -              
 -              $this->objectTypeProvider = $this->objectType->getProcessor();
 -              $this->likeableObject = $this->objectTypeProvider->getObjectByID($this->parameters['data']['objectID']);
 -              $this->likeableObject->setObjectType($this->objectType);
 -              if ($this->objectTypeProvider instanceof IRestrictedLikeObjectTypeProvider) {
 -                      if (!$this->objectTypeProvider->canViewLikes($this->likeableObject)) {
 -                              throw new PermissionDeniedException();
 -                      }
 -              }
 -              else if (!$this->objectTypeProvider->checkPermissions($this->likeableObject)) {
 -                      throw new PermissionDeniedException();
 -              }
 -      }
 -      
 -      /**
 -       * @inheritDoc
 -       */
 -      public function validateGetGroupedUserList() {
 -              $this->validateObjectParameters();
 -              
 -              $this->readInteger('pageNo');
 -
 -              if ($this->parameters['pageNo'] < 1) {
 -                      throw new UserInputException('pageNo');
 -              }
 -      }
 -      
 -      /**
 -       * @inheritDoc
 -       */
 -      public function getGroupedUserList() {
 -              // fetch number of pages
 -              $sql = "SELECT  COUNT(*)
 -                      FROM    wcf".WCF_N."_like
 -                      WHERE   objectID = ?
 -                              AND objectTypeID = ?";
 -              $statement = WCF::getDB()->prepareStatement($sql);
 -              $statement->execute([
 -                      $this->parameters['data']['objectID'],
 -                      $this->objectType->objectTypeID
 -              ]);
 -              $pageCount = ceil($statement->fetchSingleColumn() / 20);
 -              
 -              $sql = "SELECT          userID, likeValue
 -                      FROM            wcf".WCF_N."_like
 -                      WHERE           objectID = ?
 -                                      AND objectTypeID = ?
 -                      ORDER BY        likeValue DESC, time DESC";
 -              $statement = WCF::getDB()->prepareStatement($sql, 20, ($this->parameters['pageNo'] - 1) * 20);
 -              $statement->execute([
 -                      $this->parameters['data']['objectID'],
 -                      $this->objectType->objectTypeID
 -              ]);
 -              $data = [
 -                      Like::LIKE => [],
 -                      Like::DISLIKE => []
 -              ];
 -              while ($row = $statement->fetchArray()) {
 -                      $data[$row['likeValue']][] = $row['userID'];
 -              }
 -              
 -              $values = [];
 -              if (!empty($data[Like::LIKE])) {
 -                      $values[Like::LIKE] = new GroupedUserList(WCF::getLanguage()->get('wcf.like.details.like'));
 -                      /** @noinspection PhpUndefinedMethodInspection */
 -                      $values[Like::LIKE]->addUserIDs($data[Like::LIKE]);
 -              }
 -              if (!empty($data[Like::DISLIKE])) {
 -                      $values[Like::DISLIKE] = new GroupedUserList(WCF::getLanguage()->get('wcf.like.details.dislike'));
 -                      /** @noinspection PhpUndefinedMethodInspection */
 -                      $values[Like::DISLIKE]->addUserIDs($data[Like::DISLIKE]);
 -              }
 -              
 -              // load user profiles
 -              GroupedUserList::loadUsers();
 -              
 -              WCF::getTPL()->assign([
 -                      'groupedUsers' => $values
 -              ]);
 -              
 -              return [
 -                      'containerID' => $this->parameters['data']['containerID'],
 -                      'pageCount' => $pageCount,
 -                      'template' => WCF::getTPL()->fetch('groupedUserList')
 -              ];
 -      }
 -      
 -      /**
 -       * Validates parameters to load likes.
 -       */
 -      public function validateLoad() {
 -              if (!MODULE_LIKE) {
 -                      throw new IllegalLinkException();
 -              }
 -              
 -              $this->readInteger('lastLikeTime', true);
 -              $this->readInteger('userID');
 -              $this->readInteger('likeValue');
 -              $this->readString('likeType');
 -              
 -              $user = UserProfileRuntimeCache::getInstance()->getObject($this->parameters['userID']);
 -              
 -              if ($user === null) {
 -                      throw new IllegalLinkException();
 -              }
 -              
 -              if ($user->isProtected()) {
 -                      throw new PermissionDeniedException();
 -              }
 -      }
 -      
 -      /**
 -       * Loads a list of likes.
 -       * 
 -       * @return      array
 -       */
 -      public function load() {
 -              $likeList = new ViewableLikeList();
 -              if ($this->parameters['lastLikeTime']) {
 -                      $likeList->getConditionBuilder()->add("like_table.time < ?", [$this->parameters['lastLikeTime']]);
 -              }
 -              if ($this->parameters['likeType'] == 'received') {
 -                      $likeList->getConditionBuilder()->add("like_table.objectUserID = ?", [$this->parameters['userID']]);
 -              }
 -              else {
 -                      $likeList->getConditionBuilder()->add("like_table.userID = ?", [$this->parameters['userID']]);
 -              }
 -              $likeList->getConditionBuilder()->add("like_table.likeValue = ?", [$this->parameters['likeValue']]);
 -              $likeList->readObjects();
 -              if (!count($likeList)) {
 -                      return [];
 -              }
 -              
 -              // parse template
 -              WCF::getTPL()->assign([
 -                      'likeList' => $likeList
 -              ]);
 -              
 -              return [
 -                      'lastLikeTime' => $likeList->getLastLikeTime(),
 -                      'template' => WCF::getTPL()->fetch('userProfileLikeItem')
 -              ];
 -      }
 -      
 -      /**
 -       * Copies likes from one object id to another.
 -       */
 -      public function copy() {
 -              $reactionAction = new ReactionAction([], 'copy', $this->getParameters());
 -              $reactionAction->executeAction();
 -      }
 +class LikeAction extends AbstractDatabaseObjectAction implements IGroupedUserListAction
 +{
 +    /**
 +     * @inheritDoc
 +     */
 +    protected $allowGuestAccess = ['getGroupedUserList', 'getLikeDetails', 'load'];
 +
 +    /**
 +     * @inheritDoc
 +     */
 +    protected $className = LikeEditor::class;
 +
 +    /**
 +     * likeable object
 +     * @var \wcf\data\like\object\ILikeObject
 +     */
 +    public $likeableObject;
 +
 +    /**
 +     * object type object
 +     * @var \wcf\data\object\type\ObjectType
 +     */
 +    public $objectType;
 +
 +    /**
 +     * like object type provider object
 +     * @var ILikeObjectTypeProvider
 +     */
 +    public $objectTypeProvider;
 +
 +    /**
 +     * Validates parameters to fetch like details.
 +     */
 +    public function validateGetLikeDetails()
 +    {
 +        $this->validateObjectParameters();
 +    }
 +
 +    /**
 +     * Returns like details.
 +     *
 +     * @return  string[]
 +     */
 +    public function getLikeDetails()
 +    {
 +        $sql = "SELECT      userID, likeValue
 +                FROM        wcf" . WCF_N . "_like
 +                WHERE       objectID = ?
 +                        AND objectTypeID = ?
 +                ORDER BY    time DESC";
 +        $statement = WCF::getDB()->prepareStatement($sql);
 +        $statement->execute([
 +            $this->parameters['data']['objectID'],
 +            $this->objectType->objectTypeID,
 +        ]);
 +        $data = [
 +            Like::LIKE => [],
 +            Like::DISLIKE => [],
 +        ];
 +        while ($row = $statement->fetchArray()) {
 +            $data[$row['likeValue']][] = $row['userID'];
 +        }
 +
 +        $values = [];
 +        if (!empty($data[Like::LIKE])) {
 +            $values[Like::LIKE] = new GroupedUserList(WCF::getLanguage()->get('wcf.like.details.like'));
 +            /** @noinspection PhpUndefinedMethodInspection */
 +            $values[Like::LIKE]->addUserIDs($data[Like::LIKE]);
 +        }
 +        if (!empty($data[Like::DISLIKE])) {
 +            $values[Like::DISLIKE] = new GroupedUserList(WCF::getLanguage()->get('wcf.like.details.dislike'));
 +            /** @noinspection PhpUndefinedMethodInspection */
 +            $values[Like::DISLIKE]->addUserIDs($data[Like::DISLIKE]);
 +        }
 +
 +        // load user profiles
 +        GroupedUserList::loadUsers();
 +
 +        WCF::getTPL()->assign([
 +            'groupedUsers' => $values,
 +        ]);
 +
 +        return [
 +            'containerID' => $this->parameters['data']['containerID'],
 +            'template' => WCF::getTPL()->fetch('groupedUserList'),
 +        ];
 +    }
 +
 +    /**
 +     * Validates parameters for like-related actions.
 +     */
 +    public function validateLike()
 +    {
 +        $this->validateObjectParameters();
 +
 +        // check permissions
 +        if (!WCF::getUser()->userID || !WCF::getSession()->getPermission('user.like.canLike')) {
 +            throw new PermissionDeniedException();
 +        }
 +
 +        // check if liking own content but forbidden by configuration
 +        $this->likeableObject = $this->objectTypeProvider->getObjectByID($this->parameters['data']['objectID']);
 +        $this->likeableObject->setObjectType($this->objectType);
 +        if ($this->likeableObject->getUserID() == WCF::getUser()->userID) {
 +            throw new PermissionDeniedException();
 +        }
 +    }
 +
 +    /**
 +     * @inheritDoc
 +     */
 +    public function like()
 +    {
 +        return $this->updateLike(Like::LIKE);
 +    }
 +
 +    /**
 +     * @inheritDoc
 +     */
 +    public function validateDislike()
 +    {
 +        // No longer supported since 5.2.
 +        throw new PermissionDeniedException();
 +    }
 +
 +    /**
 +     * @inheritDoc
 +     */
 +    public function dislike()
 +    {
 +        return $this->updateLike(Like::DISLIKE);
 +    }
 +
 +    /**
 +     * Sets like/dislike for an object, executing this method again with the same parameters
 +     * will revert the status (removing like/dislike).
 +     *
 +     * @param int $likeValue
 +     * @return  array
 +     */
 +    protected function updateLike($likeValue)
 +    {
 +        $likeData = LikeHandler::getInstance()->like($this->likeableObject, WCF::getUser(), $likeValue);
 +
 +        // handle activity event
 +        if (UserActivityEventHandler::getInstance()->getObjectTypeID($this->objectType->objectType . '.recentActivityEvent')) {
 +            if ($likeData['data']['liked'] == 1) {
 +                UserActivityEventHandler::getInstance()->fireEvent(
 +                    $this->objectType->objectType . '.recentActivityEvent',
 +                    $this->parameters['data']['objectID'],
 +                    $this->likeableObject->getLanguageID()
 +                );
 +            } else {
 +                UserActivityEventHandler::getInstance()->removeEvent(
 +                    $this->objectType->objectType . '.recentActivityEvent',
 +                    $this->parameters['data']['objectID']
 +                );
 +            }
 +        }
 +
 +        // get stats
 +        return [
 +            'likes' => ($likeData['data']['likes'] === null) ? 0 : $likeData['data']['likes'],
 +            'dislikes' => ($likeData['data']['dislikes'] === null) ? 0 : $likeData['data']['dislikes'],
 +            'cumulativeLikes' => ($likeData['data']['cumulativeLikes'] === null) ? 0 : $likeData['data']['cumulativeLikes'],
 +            'isLiked' => ($likeData['data']['liked'] == 1) ? 1 : 0,
 +            'isDisliked' => ($likeData['data']['liked'] == -1) ? 1 : 0,
 +            'containerID' => $this->parameters['data']['containerID'],
 +            'newValue' => $likeData['newValue'],
 +            'oldValue' => $likeData['oldValue'],
 +            'users' => $likeData['users'],
 +        ];
 +    }
 +
 +    /**
 +     * Validates permissions for given object.
 +     */
 +    protected function validateObjectParameters()
 +    {
 +        if (!MODULE_LIKE) {
 +            throw new PermissionDeniedException();
 +        }
 +
 +        $this->readString('containerID', false, 'data');
 +        $this->readInteger('objectID', false, 'data');
 +        $this->readString('objectType', false, 'data');
 +
 +        $this->objectType = ReactionHandler::getInstance()->getObjectType($this->parameters['data']['objectType']);
 +        if ($this->objectType === null) {
 +            throw new UserInputException('objectType');
 +        }
 +
 +        $this->objectTypeProvider = $this->objectType->getProcessor();
 +        $this->likeableObject = $this->objectTypeProvider->getObjectByID($this->parameters['data']['objectID']);
 +        $this->likeableObject->setObjectType($this->objectType);
 +        if ($this->objectTypeProvider instanceof IRestrictedLikeObjectTypeProvider) {
 +            if (!$this->objectTypeProvider->canViewLikes($this->likeableObject)) {
 +                throw new PermissionDeniedException();
 +            }
 +        } elseif (!$this->objectTypeProvider->checkPermissions($this->likeableObject)) {
 +            throw new PermissionDeniedException();
 +        }
 +    }
 +
 +    /**
 +     * @inheritDoc
 +     */
 +    public function validateGetGroupedUserList()
 +    {
 +        $this->validateObjectParameters();
 +
 +        $this->readInteger('pageNo');
++
++        if ($this->parameters['pageNo'] < 1) {
++           throw new UserInputException('pageNo');
++        }
 +    }
 +
 +    /**
 +     * @inheritDoc
 +     */
 +    public function getGroupedUserList()
 +    {
 +        // fetch number of pages
 +        $sql = "SELECT  COUNT(*)
 +                FROM    wcf" . WCF_N . "_like
 +                WHERE   objectID = ?
 +                    AND objectTypeID = ?";
 +        $statement = WCF::getDB()->prepareStatement($sql);
 +        $statement->execute([
 +            $this->parameters['data']['objectID'],
 +            $this->objectType->objectTypeID,
 +        ]);
 +        $pageCount = \ceil($statement->fetchSingleColumn() / 20);
 +
 +        $sql = "SELECT      userID, likeValue
 +                FROM        wcf" . WCF_N . "_like
 +                WHERE       objectID = ?
 +                        AND objectTypeID = ?
 +                ORDER BY    likeValue DESC, time DESC";
 +        $statement = WCF::getDB()->prepareStatement($sql, 20, ($this->parameters['pageNo'] - 1) * 20);
 +        $statement->execute([
 +            $this->parameters['data']['objectID'],
 +            $this->objectType->objectTypeID,
 +        ]);
 +        $data = [
 +            Like::LIKE => [],
 +            Like::DISLIKE => [],
 +        ];
 +        while ($row = $statement->fetchArray()) {
 +            $data[$row['likeValue']][] = $row['userID'];
 +        }
 +
 +        $values = [];
 +        if (!empty($data[Like::LIKE])) {
 +            $values[Like::LIKE] = new GroupedUserList(WCF::getLanguage()->get('wcf.like.details.like'));
 +            /** @noinspection PhpUndefinedMethodInspection */
 +            $values[Like::LIKE]->addUserIDs($data[Like::LIKE]);
 +        }
 +        if (!empty($data[Like::DISLIKE])) {
 +            $values[Like::DISLIKE] = new GroupedUserList(WCF::getLanguage()->get('wcf.like.details.dislike'));
 +            /** @noinspection PhpUndefinedMethodInspection */
 +            $values[Like::DISLIKE]->addUserIDs($data[Like::DISLIKE]);
 +        }
 +
 +        // load user profiles
 +        GroupedUserList::loadUsers();
 +
 +        WCF::getTPL()->assign([
 +            'groupedUsers' => $values,
 +        ]);
 +
 +        return [
 +            'containerID' => $this->parameters['data']['containerID'],
 +            'pageCount' => $pageCount,
 +            'template' => WCF::getTPL()->fetch('groupedUserList'),
 +        ];
 +    }
 +
 +    /**
 +     * Validates parameters to load likes.
 +     */
 +    public function validateLoad()
 +    {
 +        if (!MODULE_LIKE) {
 +            throw new IllegalLinkException();
 +        }
 +
 +        $this->readInteger('lastLikeTime', true);
 +        $this->readInteger('userID');
 +        $this->readInteger('likeValue');
 +        $this->readString('likeType');
 +
 +        $user = UserProfileRuntimeCache::getInstance()->getObject($this->parameters['userID']);
 +
 +        if ($user === null) {
 +            throw new IllegalLinkException();
 +        }
 +
 +        if ($user->isProtected()) {
 +            throw new PermissionDeniedException();
 +        }
 +    }
 +
 +    /**
 +     * Loads a list of likes.
 +     *
 +     * @return  array
 +     */
 +    public function load()
 +    {
 +        $likeList = new ViewableLikeList();
 +        if ($this->parameters['lastLikeTime']) {
 +            $likeList->getConditionBuilder()->add("like_table.time < ?", [$this->parameters['lastLikeTime']]);
 +        }
 +        if ($this->parameters['likeType'] == 'received') {
 +            $likeList->getConditionBuilder()->add("like_table.objectUserID = ?", [$this->parameters['userID']]);
 +        } else {
 +            $likeList->getConditionBuilder()->add("like_table.userID = ?", [$this->parameters['userID']]);
 +        }
 +        $likeList->getConditionBuilder()->add("like_table.likeValue = ?", [$this->parameters['likeValue']]);
 +        $likeList->readObjects();
 +        if (!\count($likeList)) {
 +            return [];
 +        }
 +
 +        // parse template
 +        WCF::getTPL()->assign([
 +            'likeList' => $likeList,
 +        ]);
 +
 +        return [
 +            'lastLikeTime' => $likeList->getLastLikeTime(),
 +            'template' => WCF::getTPL()->fetch('userProfileLikeItem'),
 +        ];
 +    }
 +
 +    /**
 +     * Copies likes from one object id to another.
 +     */
 +    public function copy()
 +    {
 +        $reactionAction = new ReactionAction([], 'copy', $this->getParameters());
 +        $reactionAction->executeAction();
 +    }
  }
index dc2956d092d62699e49076c926df5972d4cdc805,a255d4e1ddf48cda6c6dd5971d938738ef02ec5e..56f932e18cd6f1f121127a3aa04a009dad3c5cfc
@@@ -25,816 -23,760 +25,819 @@@ use wcf\util\FileUtil
  
  /**
   * Executes media file-related actions.
 - * 
 - * @author    Matthias Schmidt
 - * @copyright 2001-2019 WoltLab GmbH
 - * @license   GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
 - * @package   WoltLabSuite\Core\Data\Media
 - * @since     3.0
 - * 
 - * @method    Media           create()
 - * @method    MediaEditor[]   getObjects()
 - * @method    MediaEditor     getSingleObject()
 + *
 + * @author  Matthias Schmidt
 + * @copyright   2001-2019 WoltLab GmbH
 + * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
 + * @package WoltLabSuite\Core\Data\Media
 + * @since   3.0
 + *
 + * @method  Media       create()
 + * @method  MediaEditor[]   getObjects()
 + * @method  MediaEditor getSingleObject()
   */
 -class MediaAction extends AbstractDatabaseObjectAction implements ISearchAction, IUploadAction {
 -      /**
 -       * number of media files per media manager dialog page
 -       */
 -      const ITEMS_PER_MANAGER_DIALOG_PAGE = 50;
 -      
 -      /**
 -       * @inheritDoc
 -       */
 -      public function validateUpload() {
 -              WCF::getSession()->checkPermissions(['admin.content.cms.canManageMedia']);
 -              
 -              $this->readBoolean('imagesOnly', true);
 -              $this->readInteger('categoryID', true);
 -              
 -              /** @noinspection PhpUndefinedMethodInspection */
 -              $this->parameters['__files']->validateFiles(new MediaUploadFileValidationStrategy($this->parameters['imagesOnly']));
 -              
 -              if ($this->parameters['categoryID']) {
 -                      $category = CategoryHandler::getInstance()->getCategory($this->parameters['categoryID']);
 -                      if ($category === null || $category->getObjectType()->objectType !== 'com.woltlab.wcf.media.category') {
 -                              throw new UserInputException('categoryID');
 -                      }
 -              }
 -      }
 -      
 -      /**
 -       * @inheritDoc
 -       */
 -      public function upload() {
 -              $additionalData = ['username' => WCF::getUser()->username];
 -              if ($this->parameters['categoryID']) {
 -                      $additionalData['categoryID'] = $this->parameters['categoryID'];
 -              }
 -              
 -              // save files
 -              $saveStrategy = new DefaultUploadFileSaveStrategy(self::class, [
 -                      'generateThumbnails' => true,
 -                      'rotateImages' => true
 -              ], $additionalData);
 -              
 -              /** @noinspection PhpUndefinedMethodInspection */
 -              $this->parameters['__files']->saveFiles($saveStrategy);
 -              
 -              /** @var Media[] $mediaFiles */
 -              $mediaFiles = $saveStrategy->getObjects();
 -              
 -              $result = [
 -                      'errors' => [],
 -                      'media' => []
 -              ];
 -              
 -              if (!empty($mediaFiles)) {
 -                      $mediaIDs = $mediaToFileID = [];
 -                      foreach ($mediaFiles as $internalFileID => $media) {
 -                              $mediaIDs[] = $media->mediaID;
 -                              $mediaToFileID[$media->mediaID] = $internalFileID;
 -                      }
 -                      
 -                      // fetch media objects from database
 -                      $mediaList = new ViewableMediaList();
 -                      $mediaList->setObjectIDs($mediaIDs);
 -                      $mediaList->readObjects();
 -                      
 -                      foreach ($mediaList as $media) {
 -                              $result['media'][$mediaToFileID[$media->mediaID]] = $this->getMediaData($media);
 -                      }
 -              }
 -              
 -              /** @var UploadFile[] $files */
 -              /** @noinspection PhpUndefinedMethodInspection */
 -              $files = $this->parameters['__files']->getFiles();
 -              foreach ($files as $file) {
 -                      if ($file->getValidationErrorType()) {
 -                              $result['errors'][$file->getInternalFileID()] = [
 -                                      'filename' => $file->getFilename(),
 -                                      'filesize' => $file->getFilesize(),
 -                                      'errorType' => $file->getValidationErrorType()
 -                              ];
 -                      }
 -              }
 -              
 -              return $result;
 -      }
 -      
 -      /**
 -       * Generates thumbnails.
 -       */
 -      public function generateThumbnails() {
 -              if (empty($this->objects)) {
 -                      $this->readObjects();
 -              }
 -              
 -              $saveStrategy = new DefaultUploadFileSaveStrategy(self::class);
 -              
 -              foreach ($this->getObjects() as $mediaEditor) {
 -                      if ($mediaEditor->getDecoratedObject()->isImage) {
 -                              $saveStrategy->generateThumbnails($mediaEditor->getDecoratedObject());
 -                      }
 -              }
 -      }
 -      
 -      /**
 -       * Returns the data of the media file to be returned by AJAX requests.
 -       * 
 -       * @param       Media|ViewableMedia     $media          media files whose data will be returned
 -       * @return      string[]
 -       */
 -      protected function getMediaData($media) {
 -              return [
 -                      'altText' => $media instanceof ViewableMedia ? $media->altText : [],
 -                      'caption' => $media instanceof ViewableMedia ? $media->caption : [],
 -                      'captionEnableHtml' => $media->captionEnableHtml,
 -                      'categoryID' => $media->categoryID,
 -                      'elementTag' => $media instanceof ViewableMedia ? $media->getElementTag($this->parameters['elementTagSize'] ?? 144) : '',
 -                      'elementTag48' => $media instanceof ViewableMedia ? $media->getElementTag(48) : '',
 -                      'fileHash' => $media->fileHash,
 -                      'filename' => $media->filename,
 -                      'filesize' => $media->filesize,
 -                      'formattedFilesize' => FileUtil::formatFilesize($media->filesize),
 -                      'fileType' => $media->fileType,
 -                      'height' => $media->height,
 -                      'languageID' => $media->languageID,
 -                      'imageDimensions' => $media->isImage ? WCF::getLanguage()->getDynamicVariable('wcf.media.imageDimensions.value', [
 -                              'media' => $media,
 -                      ]) : '',
 -                      'isImage' => $media->isImage,
 -                      'isMultilingual' => $media->isMultilingual,
 -                      'largeThumbnailHeight' => $media->largeThumbnailHeight,
 -                      'largeThumbnailLink' => $media->largeThumbnailType ? $media->getThumbnailLink('large') : '',
 -                      'largeThumbnailType' => $media->largeThumbnailType,
 -                      'largeThumbnailWidth' => $media->largeThumbnailWidth,
 -                      'link' => $media->getLink(),
 -                      'mediaID' => $media->mediaID,
 -                      'mediumThumbnailHeight' => $media->mediumThumbnailHeight,
 -                      'mediumThumbnailLink' => $media->mediumThumbnailType ? $media->getThumbnailLink('medium') : '',
 -                      'mediumThumbnailType' => $media->mediumThumbnailType,
 -                      'mediumThumbnailWidth' => $media->mediumThumbnailWidth,
 -                      'smallThumbnailHeight' => $media->smallThumbnailHeight,
 -                      'smallThumbnailLink' => $media->smallThumbnailType ? $media->getThumbnailLink('small') : '',
 -                      'smallThumbnailTag' => $media->smallThumbnailType ? $media->getThumbnailTag('small') : '',
 -                      'smallThumbnailType' => $media->smallThumbnailType,
 -                      'smallThumbnailWidth' => $media->smallThumbnailWidth,
 -                      'tinyThumbnailHeight' => $media->tinyThumbnailHeight,
 -                      'tinyThumbnailLink' => $media->tinyThumbnailType ? $media->getThumbnailLink('tiny') : '',
 -                      'tinyThumbnailType' => $media->tinyThumbnailType,
 -                      'tinyThumbnailWidth' => $media->tinyThumbnailWidth,
 -                      'title' => $media instanceof ViewableMedia ? $media->title : [],
 -                      'uploadTime' => $media->uploadTime,
 -                      'userID' => $media->userID,
 -                      'userLink' => $media->userID ? LinkHandler::getInstance()->getLink('User', [
 -                              'id' => $media->userID,
 -                              'title' => $media->username
 -                      ]) : '',
 -                      'userLinkElement' => $media instanceof ViewableMedia ? WCF::getTPL()->fetchString(
 -                              WCF::getTPL()->getCompiler()->compileString('userLink', '{user object=$userProfile}')['template'],
 -                              ['userProfile' => $media->getUserProfile()]
 -                      ) : '',
 -                      'username' => $media->username,
 -                      'width' => $media->width
 -              ];
 -      }
 -      
 -      /**
 -       * Validates the 'getManagementDialog' action.
 -       */
 -      public function validateGetManagementDialog() {
 -              if (!WCF::getSession()->getPermission('admin.content.cms.canManageMedia') && !WCF::getSession()->getPermission('admin.content.cms.canUseMedia')) {
 -                      throw new PermissionDeniedException();
 -              }
 -              
 -              $this->readBoolean('imagesOnly', true);
 -              
 -              $this->readString('mode');
 -              if ($this->parameters['mode'] != 'editor' && $this->parameters['mode'] != 'select') {
 -                      throw new UserInputException('mode');
 -              }
 -      }
 -      
 -      /**
 -       * Returns the dialog to manage media.
 -       * 
 -       * @return      string[]
 -       */
 -      public function getManagementDialog() {
 -              $mediaList = new ViewableMediaList();
 -              if (WCF::getSession()->getPermission('admin.content.cms.canOnlyAccessOwnMedia')) {
 -                      $mediaList->getConditionBuilder()->add('media.userID = ?', [WCF::getUser()->userID]);
 -              }
 -              if ($this->parameters['imagesOnly']) {
 -                      $mediaList->getConditionBuilder()->add('media.isImage = ?', [1]);
 -              }
 -              $mediaList->sqlOrderBy = 'media.uploadTime DESC, media.mediaID DESC';
 -              $mediaList->sqlLimit = static::ITEMS_PER_MANAGER_DIALOG_PAGE;
 -              $mediaList->readObjects();
 -              
 -              $categoryList = (new CategoryNodeTree('com.woltlab.wcf.media.category'))->getIterator();
 -              $categoryList->setMaxDepth(0);
 -              
 -              return [
 -                      'hasMarkedItems' => ClipboardHandler::getInstance()->hasMarkedItems(ClipboardHandler::getInstance()->getObjectTypeID('com.woltlab.wcf.media')),
 -                      'media' => $this->getI18nMediaData($mediaList),
 -                      'pageCount' => ceil($mediaList->countObjects() / static::ITEMS_PER_MANAGER_DIALOG_PAGE),
 -                      'template' => WCF::getTPL()->fetch('mediaManager', 'wcf', [
 -                              'categoryList' => $categoryList,
 -                              'mediaList' => $mediaList,
 -                              'mode' => $this->parameters['mode']
 -                      ])
 -              ];
 -      }
 -      
 -      /**
 -       * Returns the complete i18n data of the media files in the given list.
 -       * 
 -       * @param       MediaList       $mediaList
 -       * @return      array
 -       */
 -      protected function getI18nMediaData(MediaList $mediaList) {
 -              if (!count($mediaList)) return [];
 -              
 -              $conditionBuilder = new PreparedStatementConditionBuilder();
 -              $conditionBuilder->add('mediaID IN (?)', [$mediaList->getObjectIDs()]);
 -              
 -              $sql = "SELECT  *
 -                      FROM    wcf".WCF_N."_media_content
 -                      ".$conditionBuilder;
 -              $statement = WCF::getDB()->prepareStatement($sql);
 -              $statement->execute($conditionBuilder->getParameters());
 -              
 -              $mediaData = [];
 -              while ($row = $statement->fetchArray()) {
 -                      if (!isset($mediaData[$row['mediaID']])) {
 -                              $mediaData[$row['mediaID']] = [
 -                                      'altText' => [],
 -                                      'caption' => [],
 -                                      'title' => []
 -                              ];
 -                      }
 -                      
 -                      $mediaData[$row['mediaID']]['altText'][intval($row['languageID'])] = $row['altText'];
 -                      $mediaData[$row['mediaID']]['caption'][intval($row['languageID'])] = $row['caption'];
 -                      $mediaData[$row['mediaID']]['title'][intval($row['languageID'])] = $row['title'];
 -              }
 -              
 -              $i18nMediaData = [];
 -              foreach ($mediaList as $media) {
 -                      if (!isset($mediaData[$media->mediaID])) {
 -                              $mediaData[$media->mediaID] = [];
 -                      }
 -                      
 -                      $i18nMediaData[$media->mediaID] = array_merge($this->getMediaData($media), $mediaData[$media->mediaID]);
 -              }
 -              
 -              return $i18nMediaData;
 -      }
 -      
 -      /**
 -       * Validates the 'getEditorDialog' action.
 -       */
 -      public function validateGetEditorDialog() {
 -              WCF::getSession()->checkPermissions(['admin.content.cms.canManageMedia']);
 -              
 -              $this->getSingleObject();
 -              
 -              if (!$this->getSingleObject()->canManage()) {
 -                      throw new PermissionDeniedException();
 -              }
 -      }
 -      
 -      /**
 -       * Returns the template for the media editor.
 -       * 
 -       * @return      string[]
 -       */
 -      public function getEditorDialog() {
 -              $mediaList = new ViewableMediaList();
 -              $mediaList->setObjectIDs([$this->getSingleObject()->mediaID]);
 -              $mediaList->readObjects();
 -              $media = $mediaList->search($this->getSingleObject()->mediaID);
 -              
 -              I18nHandler::getInstance()->register('title_' . $media->mediaID);
 -              I18nHandler::getInstance()->register('caption_' . $media->mediaID);
 -              I18nHandler::getInstance()->register('altText_' . $media->mediaID);
 -              I18nHandler::getInstance()->assignVariables();
 -              
 -              $categoryList = (new CategoryNodeTree('com.woltlab.wcf.media.category'))->getIterator();
 -              $categoryList->setMaxDepth(0);
 -              
 -              return [
 -                      'availableLanguageCount' => count(LanguageFactory::getInstance()->getLanguages()),
 -                      'categoryIDs' => array_keys(CategoryHandler::getInstance()->getCategories('com.woltlab.wcf.media.category')),
 -                      'mediaData' => $this->getI18nMediaData($mediaList)[$this->getSingleObject()->mediaID],
 -                      'template' => WCF::getTPL()->fetch('mediaEditor', 'wcf', [
 -                              '__aclSimplePrefix' => 'mediaEditor_' . $media->mediaID . '_',
 -                              '__aclInputName' => 'mediaEditor_' . $media->mediaID . '_aclValues',
 -                              '__languageChooserPrefix' => 'mediaEditor_' . $media->mediaID . '_',
 -                              'aclValues' => SimpleAclHandler::getInstance()->getValues('com.woltlab.wcf.media', $media->mediaID),
 -                              'availableLanguages' => LanguageFactory::getInstance()->getLanguages(),
 -                              'categoryList' => $categoryList,
 -                              'languageID' => WCF::getUser()->languageID,
 -                              'languages' => LanguageFactory::getInstance()->getLanguages(),
 -                              'media' => $media
 -                      ])
 -              ];
 -      }
 -      
 -      /**
 -       * @inheritDoc
 -       */
 -      public function validateUpdate() {
 -              WCF::getSession()->checkPermissions(['admin.content.cms.canManageMedia']);
 -              
 -              if (empty($this->objects)) {
 -                      $this->readObjects();
 -                      
 -                      if (empty($this->objects)) {
 -                              throw new UserInputException('objectIDs');
 -                      }
 -              }
 -              
 -              if (WCF::getSession()->getPermission('admin.content.cms.canOnlyAccessOwnMedia')) {
 -                      foreach ($this->getObjects() as $media) {
 -                              if ($media->userID != WCF::getUser()->userID) {
 -                                      throw new PermissionDeniedException();
 -                              }
 -                      }
 -              }
 -              
 -              $this->readInteger('categoryID', true, 'data');
 -              $this->readInteger('languageID', true, 'data');
 -              $this->readBoolean('isMultilingual', true, 'data');
 -              $this->readInteger('captionEnableHtml', true, 'data');
 -              
 -              if (count(LanguageFactory::getInstance()->getLanguages()) > 1) {
 -                      // languageID: convert zero to null
 -                      if (!$this->parameters['data']['languageID']) $this->parameters['data']['languageID'] = null;
 -                      
 -                      // isMultilingual: convert boolean to integer
 -                      $this->parameters['data']['isMultilingual'] = intval($this->parameters['data']['isMultilingual']);
 -              }
 -              else {
 -                      $this->parameters['data']['isMultilingual'] = 0;
 -                      $this->parameters['data']['languageID'] = WCF::getLanguage()->languageID;
 -              }
 -              
 -              // if data is not multilingual, a language id has to be given
 -              if (!$this->parameters['data']['isMultilingual'] && !$this->parameters['data']['languageID']) {
 -                      throw new UserInputException('languageID');
 -              }
 -              
 -              // check language id
 -              if ($this->parameters['data']['languageID'] && !LanguageFactory::getInstance()->getLanguage($this->parameters['data']['languageID'])) {
 -                      throw new UserInputException('languageID');
 -              }
 -              
 -              // check category id
 -              if ($this->parameters['data']['categoryID']) {
 -                      $category = CategoryHandler::getInstance()->getCategory($this->parameters['data']['categoryID']);
 -                      if ($category === null || $category->getObjectType()->objectType !== 'com.woltlab.wcf.media.category') {
 -                              throw new UserInputException('categoryID');
 -                      }
 -              }
 -      }
 -      
 -      /**
 -       * @inheritDoc
 -       */
 -      public function update() {
 -              if (isset($this->parameters['data']['categoryID']) && $this->parameters['data']['categoryID'] === 0) {
 -                      $this->parameters['data']['categoryID'] = null;
 -              }
 -              
 -              if (empty($this->objects)) {
 -                      $this->readObjects();
 -              }
 -              
 -              parent::update();
 -              
 -              if (count($this->objects) == 1 && (isset($this->parameters['title']) || isset($this->parameters['caption']) || isset($this->parameters['altText']))) {
 -                      $media = reset($this->objects);
 -                      
 -                      $isMultilingual = $media->isMultilingual;
 -                      if (isset($this->parameters['data']['isMultilingual'])) {
 -                              $isMultilingual = $this->parameters['data']['isMultilingual'];
 -                      }
 -                      
 -                      $sql = "DELETE FROM     wcf".WCF_N."_media_content
 -                              WHERE           mediaID = ?";
 -                      $statement = WCF::getDB()->prepareStatement($sql);
 -                      $statement->execute([$media->mediaID]);
 -                      
 -                      $sql = "INSERT INTO     wcf".WCF_N."_media_content
 -                                              (mediaID, languageID, title, caption, altText)
 -                              VALUES          (?, ?, ?, ?, ?)";
 -                      $statement = WCF::getDB()->prepareStatement($sql);
 -                      
 -                      if (!$isMultilingual) {
 -                              $languageID = $media->languageID;
 -                              if (isset($this->parameters['data']['languageID'])) {
 -                                      $languageID = $this->parameters['data']['languageID'];
 -                              }
 -                              $statement->execute([
 -                                      $media->mediaID,
 -                                      $languageID,
 -                                      isset($this->parameters['title'][$languageID]) ? mb_substr($this->parameters['title'][$languageID], 0, 255) : '',
 -                                      isset($this->parameters['caption'][$languageID]) ? $this->parameters['caption'][$languageID] : '',
 -                                      isset($this->parameters['altText'][$languageID]) ? mb_substr($this->parameters['altText'][$languageID], 0, 255) : ''
 -                              ]);
 -                      }
 -                      else {
 -                              $languages = LanguageFactory::getInstance()->getLanguages();
 -                              foreach ($languages as $language) {
 -                                      $title = $caption = $altText = '';
 -                                      foreach (['title', 'caption', 'altText'] as $type) {
 -                                              if (isset($this->parameters[$type])) {
 -                                                      if (is_array($this->parameters[$type])) {
 -                                                              if (isset($this->parameters[$type][$language->languageID])) {
 -                                                                      /** @noinspection PhpVariableVariableInspection */
 -                                                                      $$type = $this->parameters[$type][$language->languageID];
 -                                                              }
 -                                                      }
 -                                                      else {
 -                                                              /** @noinspection PhpVariableVariableInspection */
 -                                                              $$type = $this->parameters[$type];
 -                                                      }
 -                                              }
 -                                      }
 -                                      
 -                                      $statement->execute([
 -                                              $media->mediaID,
 -                                              $language->languageID,
 -                                              mb_substr($title, 0, 255),
 -                                              $caption,
 -                                              mb_substr($altText, 0, 255)
 -                                      ]);
 -                              }
 -                      }
 -                      
 -                      if (!empty($this->parameters['aclValues'])) {
 -                              SimpleAclHandler::getInstance()->setValues('com.woltlab.wcf.media', $media->mediaID, $this->parameters['aclValues']);
 -                      }
 -              }
 -      }
 -      
 -      /**
 -       * @inheritDoc
 -       */
 -      public function validateGetSearchResultList() {
 -              if (!WCF::getSession()->getPermission('admin.content.cms.canManageMedia') && !WCF::getSession()->getPermission('admin.content.cms.canUseMedia')) {
 -                      throw new PermissionDeniedException();
 -              }
 -              
 -              $this->readString('searchString', true);
 -              $this->readInteger('categoryID', true);
 -              
 -              $this->readBoolean('imagesOnly', true);
 -              
 -              $this->readString('mode');
 -              if ($this->parameters['mode'] != 'editor' && $this->parameters['mode'] != 'select') {
 -                      throw new UserInputException('mode');
 -              }
 -              
 -              $this->readInteger('pageNo', true);
 -              if (!$this->parameters['pageNo']) {
 -                      $this->parameters['pageNo'] = 1;
 -              }
 -              if ($this->parameters['pageNo'] < 1) {
 -                      throw new UserInputException('pageNo');
 -              }
 -      }
 -      
 -      /**
 -       * @inheritDoc
 -       */
 -      public function getSearchResultList() {
 -              $mediaList = new MediaList();
 -              $mediaList->addSearchConditions($this->parameters['searchString']);
 -              if (WCF::getSession()->getPermission('admin.content.cms.canOnlyAccessOwnMedia')) {
 -                      $mediaList->getConditionBuilder()->add('media.userID = ?', [WCF::getUser()->userID]);
 -              }
 -              if ($this->parameters['imagesOnly']) {
 -                      $mediaList->getConditionBuilder()->add('media.isImage = ?', [1]);
 -              }
 -              if ($this->parameters['categoryID']) {
 -                      $mediaList->getConditionBuilder()->add('media.categoryID = ?', [$this->parameters['categoryID']]);
 -              }
 -              $mediaList->sqlOrderBy = 'media.uploadTime DESC, media.mediaID DESC';
 -              $mediaList->sqlLimit = static::ITEMS_PER_MANAGER_DIALOG_PAGE;
 -              $mediaList->sqlOffset = ($this->parameters['pageNo'] - 1) * static::ITEMS_PER_MANAGER_DIALOG_PAGE;
 -              $mediaList->readObjectIDs();
 -              
 -              if (empty($mediaList->getObjectIDs())) {
 -                      // check if page is requested that might have existed but does not exist anymore due to deleted
 -                      // media files
 -                      if ($this->parameters['pageNo'] > 1 && $this->parameters['searchString'] === '' && !$this->parameters['categoryID']) {
 -                              // request media dialog page with highest page number 
 -                              $parameters = $this->parameters;
 -                              $parameters['pageNo'] = ceil($mediaList->countObjects() / static::ITEMS_PER_MANAGER_DIALOG_PAGE);
 -                              
 -                              return (new MediaAction($this->objects, 'getSearchResultList', $parameters))->executeAction()['returnValues'];
 -                      }
 -                      
 -                      return [
 -                              'template' => WCF::getLanguage()->getDynamicVariable('wcf.media.search.noResults')
 -                      ];
 -              }
 -              
 -              $viewableMediaList = new ViewableMediaList();
 -              $viewableMediaList->setObjectIDs($mediaList->getObjectIDs());
 -              $viewableMediaList->readObjects();
 -              
 -              return [
 -                      'media' => $this->getI18nMediaData($viewableMediaList),
 -                      'pageCount' => ceil($mediaList->countObjects() / static::ITEMS_PER_MANAGER_DIALOG_PAGE),
 -                      'pageNo' => $this->parameters['pageNo'],
 -                      'template' => WCF::getTPL()->fetch('mediaListItems', 'wcf', [
 -                              'mediaList' => $viewableMediaList,
 -                              'mode' => $this->parameters['mode']
 -                      ])
 -              ];
 -      }
 -      
 -      /**
 -       * @inheritDoc
 -       */
 -      public function validateDelete() {
 -              WCF::getSession()->checkPermissions(['admin.content.cms.canManageMedia']);
 -              
 -              if (empty($this->objects)) {
 -                      $this->readObjects();
 -                      
 -                      if (empty($this->objects)) {
 -                              throw new UserInputException('objectIDs');
 -                      }
 -              }
 -              
 -              if (WCF::getSession()->getPermission('admin.content.cms.canOnlyAccessOwnMedia')) {
 -                      foreach ($this->getObjects() as $media) {
 -                              if ($media->userID != WCF::getUser()->userID) {
 -                                      throw new PermissionDeniedException();
 -                              }
 -                      }
 -              }
 -      }
 -      
 -      /**
 -       * @inheritDoc
 -       */
 -      public function delete() {
 -              if (empty($this->objects)) {
 -                      $this->readObjects();
 -              }
 -              
 -              foreach ($this->getObjects() as $mediaEditor) {
 -                      $mediaEditor->deleteFiles();
 -              }
 -              
 -              parent::delete();
 -              
 -              $this->unmarkItems();
 -      }
 -      
 -      /**
 -       * Unmarks the media files with the given ids. If no media ids are given,
 -       * all media files currently loaded are unmarked.
 -       * 
 -       * @param       integer[]       $mediaIDs       ids of the media files to be unmarked
 -       */
 -      protected function unmarkItems(array $mediaIDs = []) {
 -              if (empty($mediaIDs)) {
 -                      foreach ($this->getObjects() as $media) {
 -                              $mediaIDs[] = $media->mediaID;
 -                      }
 -              }
 -              
 -              if (!empty($mediaIDs)) {
 -                      ClipboardHandler::getInstance()->unmark($mediaIDs, ClipboardHandler::getInstance()->getObjectTypeID('com.woltlab.wcf.media'));
 -              }
 -      }
 -      
 -      /**
 -       * Validates the `getSetCategoryDialog` action.
 -       * 
 -       * @throws      PermissionDeniedException       if user is not allowed to set category of media files
 -       * @throws      IllegalLinkException            if no media file categories exist
 -       */
 -      public function validateGetSetCategoryDialog() {
 -              if (!WCF::getSession()->getPermission('admin.content.cms.canManageMedia')) {
 -                      throw new PermissionDeniedException();
 -              }
 -              
 -              if (empty(CategoryHandler::getInstance()->getCategories('com.woltlab.wcf.media.category'))) {
 -                      throw new IllegalLinkException();
 -              }
 -      }
 -      
 -      /**
 -       * Returns the dialog to set the category of multiple media files.
 -       * 
 -       * @return      string[]
 -       */
 -      public function getSetCategoryDialog() {
 -              $categoryList = (new CategoryNodeTree('com.woltlab.wcf.media.category'))->getIterator();
 -              $categoryList->setMaxDepth(0);
 -              
 -              return [
 -                      'template' => WCF::getTPL()->fetch('__mediaSetCategoryDialog', 'wcf', [
 -                              'categoryList' => $categoryList
 -                      ])
 -              ];
 -      }
 -      
 -      /**
 -       * Validates the `setCategory` action.
 -       * 
 -       * @throws      PermissionDeniedException       if user is not allowed to edit a requested media file
 -       * @throws      UserInputException              if no object ids are given
 -       */
 -      public function validateSetCategory() {
 -              $this->validateGetSetCategoryDialog();
 -              
 -              if (empty($this->objects)) {
 -                      $this->readObjects();
 -                      
 -                      if (empty($this->objects)) {
 -                              throw new UserInputException('objectIDs');
 -                      }
 -              }
 -              
 -              if (WCF::getSession()->getPermission('admin.content.cms.canOnlyAccessOwnMedia')) {
 -                      foreach ($this->getObjects() as $media) {
 -                              if ($media->userID != WCF::getUser()->userID) {
 -                                      throw new PermissionDeniedException();
 -                              }
 -                      }
 -              }
 -              
 -              $this->readInteger('categoryID', true);
 -      }
 -      
 -      /**
 -       * Sets the category of multiple media files.
 -       */
 -      public function setCategory() {
 -              $conditionBuilder = new PreparedStatementConditionBuilder();
 -              $conditionBuilder->add('mediaID IN (?)', [$this->objectIDs]);
 -              
 -              $sql = "UPDATE  wcf" . WCF_N . "_media
 -                      SET     categoryID = ?
 -                      " . $conditionBuilder;
 -              $statement = WCF::getDB()->prepareStatement($sql);
 -              $statement->execute(array_merge(
 -                      [$this->parameters['categoryID'] ?: null],
 -                      $conditionBuilder->getParameters()
 -              ));
 -              
 -              $this->unmarkItems();
 -      }
 -      
 -      /**
 -       * Validates the `replaceFile` action.
 -       * 
 -       * @since       5.3
 -       */
 -      public function validateReplaceFile() {
 -              WCF::getSession()->checkPermissions(['admin.content.cms.canManageMedia']);
 -              
 -              $this->getSingleObject();
 -              
 -              /** @noinspection PhpUndefinedMethodInspection */
 -              $this->parameters['__files']->validateFiles(
 -                      new MediaReplaceUploadFileValidationStrategy($this->getSingleObject()->getDecoratedObject())
 -              );
 -      }
 -      
 -      /**
 -       * Replaces the actual file of a media file.
 -       * 
 -       * @return      array
 -       * @since       5.3
 -       */
 -      public function replaceFile() {
 -              $saveStrategy = new DefaultUploadFileSaveStrategy(static::class, [
 -                      'action' => 'update',
 -                      'generateThumbnails' => true,
 -                      'object' => $this->getSingleObject()->getDecoratedObject(),
 -                      'rotateImages' => true,
 -              ], [
 -                      'fileUpdateTime' => TIME_NOW,
 -                      'userID' => $this->getSingleObject()->userID,
 -                      'username' => $this->getSingleObject()->username,
 -              ]);
 -              
 -              /** @noinspection PhpUndefinedMethodInspection */
 -              $this->parameters['__files']->saveFiles($saveStrategy);
 -              
 -              /** @var Media[] $mediaFiles */
 -              $mediaFiles = $saveStrategy->getObjects();
 -              
 -              $result = [
 -                      'errors' => [],
 -                      'media' => []
 -              ];
 -              
 -              if (!empty($mediaFiles)) {
 -                      $mediaIDs = $mediaToFileID = [];
 -                      foreach ($mediaFiles as $internalFileID => $media) {
 -                              $mediaIDs[] = $media->mediaID;
 -                              $mediaToFileID[$media->mediaID] = $internalFileID;
 -                      }
 -                      
 -                      // fetch media objects from database
 -                      $mediaList = new ViewableMediaList();
 -                      $mediaList->setObjectIDs($mediaIDs);
 -                      $mediaList->readObjects();
 -                      
 -                      foreach ($mediaList as $media) {
 -                              $result['media'][$mediaToFileID[$media->mediaID]] = $this->getMediaData($media);
 -                      }
 -              }
 -              
 -              /** @var UploadFile[] $files */
 -              /** @noinspection PhpUndefinedMethodInspection */
 -              $files = $this->parameters['__files']->getFiles();
 -              foreach ($files as $file) {
 -                      if ($file->getValidationErrorType()) {
 -                              $result['errors'][$file->getInternalFileID()] = [
 -                                      'filename' => $file->getFilename(),
 -                                      'filesize' => $file->getFilesize(),
 -                                      'errorType' => $file->getValidationErrorType()
 -                              ];
 -                      }
 -              }
 -              
 -              $outdatedMediaFile = $this->getSingleObject();
 -              $updatedMediaFile = new Media($this->getSingleObject()->mediaID);
 -              
 -              // Delete *old* files using the non-updated local media editor object if the new file is
 -              // stored in a different location.
 -              if (empty($result['errors']) && $updatedMediaFile->getLocation() !== $outdatedMediaFile->getLocation()) {
 -                      $outdatedMediaFile->deleteFiles();
 -              }
 -              
 -              return $result;
 -      }
 +class MediaAction extends AbstractDatabaseObjectAction implements ISearchAction, IUploadAction
 +{
 +    /**
 +     * number of media files per media manager dialog page
 +     */
 +    const ITEMS_PER_MANAGER_DIALOG_PAGE = 50;
 +
 +    /**
 +     * @inheritDoc
 +     */
 +    public function validateUpload()
 +    {
 +        WCF::getSession()->checkPermissions(['admin.content.cms.canManageMedia']);
 +
 +        $this->readBoolean('imagesOnly', true);
 +        $this->readInteger('categoryID', true);
 +
 +        /** @noinspection PhpUndefinedMethodInspection */
 +        $this->parameters['__files']->validateFiles(new MediaUploadFileValidationStrategy($this->parameters['imagesOnly']));
 +
 +        if ($this->parameters['categoryID']) {
 +            $category = CategoryHandler::getInstance()->getCategory($this->parameters['categoryID']);
 +            if ($category === null || $category->getObjectType()->objectType !== 'com.woltlab.wcf.media.category') {
 +                throw new UserInputException('categoryID');
 +            }
 +        }
 +    }
 +
 +    /**
 +     * @inheritDoc
 +     */
 +    public function upload()
 +    {
 +        $additionalData = ['username' => WCF::getUser()->username];
 +        if ($this->parameters['categoryID']) {
 +            $additionalData['categoryID'] = $this->parameters['categoryID'];
 +        }
 +
 +        // save files
 +        $saveStrategy = new DefaultUploadFileSaveStrategy(self::class, [
 +            'generateThumbnails' => true,
 +            'rotateImages' => true,
 +        ], $additionalData);
 +
 +        /** @noinspection PhpUndefinedMethodInspection */
 +        $this->parameters['__files']->saveFiles($saveStrategy);
 +
 +        /** @var Media[] $mediaFiles */
 +        $mediaFiles = $saveStrategy->getObjects();
 +
 +        $result = [
 +            'errors' => [],
 +            'media' => [],
 +        ];
 +
 +        if (!empty($mediaFiles)) {
 +            $mediaIDs = $mediaToFileID = [];
 +            foreach ($mediaFiles as $internalFileID => $media) {
 +                $mediaIDs[] = $media->mediaID;
 +                $mediaToFileID[$media->mediaID] = $internalFileID;
 +            }
 +
 +            // fetch media objects from database
 +            $mediaList = new ViewableMediaList();
 +            $mediaList->setObjectIDs($mediaIDs);
 +            $mediaList->readObjects();
 +
 +            foreach ($mediaList as $media) {
 +                $result['media'][$mediaToFileID[$media->mediaID]] = $this->getMediaData($media);
 +            }
 +        }
 +
 +        /** @var UploadFile[] $files */
 +        /** @noinspection PhpUndefinedMethodInspection */
 +        $files = $this->parameters['__files']->getFiles();
 +        foreach ($files as $file) {
 +            if ($file->getValidationErrorType()) {
 +                $result['errors'][$file->getInternalFileID()] = [
 +                    'filename' => $file->getFilename(),
 +                    'filesize' => $file->getFilesize(),
 +                    'errorType' => $file->getValidationErrorType(),
 +                ];
 +            }
 +        }
 +
 +        return $result;
 +    }
 +
 +    /**
 +     * Generates thumbnails.
 +     */
 +    public function generateThumbnails()
 +    {
 +        if (empty($this->objects)) {
 +            $this->readObjects();
 +        }
 +
 +        $saveStrategy = new DefaultUploadFileSaveStrategy(self::class);
 +
 +        foreach ($this->getObjects() as $mediaEditor) {
 +            if ($mediaEditor->getDecoratedObject()->isImage) {
 +                $saveStrategy->generateThumbnails($mediaEditor->getDecoratedObject());
 +            }
 +        }
 +    }
 +
 +    /**
 +     * Returns the data of the media file to be returned by AJAX requests.
 +     *
 +     * @param Media|ViewableMedia $media media files whose data will be returned
 +     * @return  string[]
 +     */
 +    protected function getMediaData($media)
 +    {
 +        return [
 +            'altText' => $media instanceof ViewableMedia ? $media->altText : [],
 +            'caption' => $media instanceof ViewableMedia ? $media->caption : [],
 +            'captionEnableHtml' => $media->captionEnableHtml,
 +            'categoryID' => $media->categoryID,
 +            'elementTag' => $media instanceof ViewableMedia ? $media->getElementTag($this->parameters['elementTagSize'] ?? 144) : '',
 +            'elementTag48' => $media instanceof ViewableMedia ? $media->getElementTag(48) : '',
 +            'fileHash' => $media->fileHash,
 +            'filename' => $media->filename,
 +            'filesize' => $media->filesize,
 +            'formattedFilesize' => FileUtil::formatFilesize($media->filesize),
 +            'fileType' => $media->fileType,
 +            'height' => $media->height,
 +            'languageID' => $media->languageID,
 +            'imageDimensions' => $media->isImage ? WCF::getLanguage()->getDynamicVariable(
 +                'wcf.media.imageDimensions.value',
 +                [
 +                    'media' => $media,
 +                ]
 +            ) : '',
 +            'isImage' => $media->isImage,
 +            'isMultilingual' => $media->isMultilingual,
 +            'largeThumbnailHeight' => $media->largeThumbnailHeight,
 +            'largeThumbnailLink' => $media->largeThumbnailType ? $media->getThumbnailLink('large') : '',
 +            'largeThumbnailType' => $media->largeThumbnailType,
 +            'largeThumbnailWidth' => $media->largeThumbnailWidth,
 +            'link' => $media->getLink(),
 +            'mediaID' => $media->mediaID,
 +            'mediumThumbnailHeight' => $media->mediumThumbnailHeight,
 +            'mediumThumbnailLink' => $media->mediumThumbnailType ? $media->getThumbnailLink('medium') : '',
 +            'mediumThumbnailType' => $media->mediumThumbnailType,
 +            'mediumThumbnailWidth' => $media->mediumThumbnailWidth,
 +            'smallThumbnailHeight' => $media->smallThumbnailHeight,
 +            'smallThumbnailLink' => $media->smallThumbnailType ? $media->getThumbnailLink('small') : '',
 +            'smallThumbnailTag' => $media->smallThumbnailType ? $media->getThumbnailTag('small') : '',
 +            'smallThumbnailType' => $media->smallThumbnailType,
 +            'smallThumbnailWidth' => $media->smallThumbnailWidth,
 +            'tinyThumbnailHeight' => $media->tinyThumbnailHeight,
 +            'tinyThumbnailLink' => $media->tinyThumbnailType ? $media->getThumbnailLink('tiny') : '',
 +            'tinyThumbnailType' => $media->tinyThumbnailType,
 +            'tinyThumbnailWidth' => $media->tinyThumbnailWidth,
 +            'title' => $media instanceof ViewableMedia ? $media->title : [],
 +            'uploadTime' => $media->uploadTime,
 +            'userID' => $media->userID,
 +            'userLink' => $media->userID ? LinkHandler::getInstance()->getLink('User', [
 +                'id' => $media->userID,
 +                'title' => $media->username,
 +            ]) : '',
 +            'userLinkElement' => $media instanceof ViewableMedia ? WCF::getTPL()->fetchString(
 +                WCF::getTPL()->getCompiler()->compileString('userLink', '{user object=$userProfile}')['template'],
 +                ['userProfile' => $media->getUserProfile()]
 +            ) : '',
 +            'username' => $media->username,
 +            'width' => $media->width,
 +        ];
 +    }
 +
 +    /**
 +     * Validates the 'getManagementDialog' action.
 +     */
 +    public function validateGetManagementDialog()
 +    {
 +        if (!WCF::getSession()->getPermission('admin.content.cms.canManageMedia') && !WCF::getSession()->getPermission('admin.content.cms.canUseMedia')) {
 +            throw new PermissionDeniedException();
 +        }
 +
 +        $this->readBoolean('imagesOnly', true);
 +
 +        $this->readString('mode');
 +        if ($this->parameters['mode'] != 'editor' && $this->parameters['mode'] != 'select') {
 +            throw new UserInputException('mode');
 +        }
 +    }
 +
 +    /**
 +     * Returns the dialog to manage media.
 +     *
 +     * @return  string[]
 +     */
 +    public function getManagementDialog()
 +    {
 +        $mediaList = new ViewableMediaList();
 +        if (WCF::getSession()->getPermission('admin.content.cms.canOnlyAccessOwnMedia')) {
 +            $mediaList->getConditionBuilder()->add('media.userID = ?', [WCF::getUser()->userID]);
 +        }
 +        if ($this->parameters['imagesOnly']) {
 +            $mediaList->getConditionBuilder()->add('media.isImage = ?', [1]);
 +        }
 +        $mediaList->sqlOrderBy = 'media.uploadTime DESC, media.mediaID DESC';
 +        $mediaList->sqlLimit = static::ITEMS_PER_MANAGER_DIALOG_PAGE;
 +        $mediaList->readObjects();
 +
 +        $categoryList = (new CategoryNodeTree('com.woltlab.wcf.media.category'))->getIterator();
 +        $categoryList->setMaxDepth(0);
 +
 +        return [
 +            'hasMarkedItems' => ClipboardHandler::getInstance()->hasMarkedItems(ClipboardHandler::getInstance()->getObjectTypeID('com.woltlab.wcf.media')),
 +            'media' => $this->getI18nMediaData($mediaList),
 +            'pageCount' => \ceil($mediaList->countObjects() / static::ITEMS_PER_MANAGER_DIALOG_PAGE),
 +            'template' => WCF::getTPL()->fetch('mediaManager', 'wcf', [
 +                'categoryList' => $categoryList,
 +                'mediaList' => $mediaList,
 +                'mode' => $this->parameters['mode'],
 +            ]),
 +        ];
 +    }
 +
 +    /**
 +     * Returns the complete i18n data of the media files in the given list.
 +     *
 +     * @param MediaList $mediaList
 +     * @return  array
 +     */
 +    protected function getI18nMediaData(MediaList $mediaList)
 +    {
 +        if (!\count($mediaList)) {
 +            return [];
 +        }
 +
 +        $conditionBuilder = new PreparedStatementConditionBuilder();
 +        $conditionBuilder->add('mediaID IN (?)', [$mediaList->getObjectIDs()]);
 +
 +        $sql = "SELECT  *
 +                FROM    wcf" . WCF_N . "_media_content
 +                " . $conditionBuilder;
 +        $statement = WCF::getDB()->prepareStatement($sql);
 +        $statement->execute($conditionBuilder->getParameters());
 +
 +        $mediaData = [];
 +        while ($row = $statement->fetchArray()) {
 +            if (!isset($mediaData[$row['mediaID']])) {
 +                $mediaData[$row['mediaID']] = [
 +                    'altText' => [],
 +                    'caption' => [],
 +                    'title' => [],
 +                ];
 +            }
 +
 +            $mediaData[$row['mediaID']]['altText'][\intval($row['languageID'])] = $row['altText'];
 +            $mediaData[$row['mediaID']]['caption'][\intval($row['languageID'])] = $row['caption'];
 +            $mediaData[$row['mediaID']]['title'][\intval($row['languageID'])] = $row['title'];
 +        }
 +
 +        $i18nMediaData = [];
 +        foreach ($mediaList as $media) {
 +            if (!isset($mediaData[$media->mediaID])) {
 +                $mediaData[$media->mediaID] = [];
 +            }
 +
 +            $i18nMediaData[$media->mediaID] = \array_merge($this->getMediaData($media), $mediaData[$media->mediaID]);
 +        }
 +
 +        return $i18nMediaData;
 +    }
 +
 +    /**
 +     * Validates the 'getEditorDialog' action.
 +     */
 +    public function validateGetEditorDialog()
 +    {
 +        WCF::getSession()->checkPermissions(['admin.content.cms.canManageMedia']);
 +
 +        $this->getSingleObject();
 +
 +        if (!$this->getSingleObject()->canManage()) {
 +            throw new PermissionDeniedException();
 +        }
 +    }
 +
 +    /**
 +     * Returns the template for the media editor.
 +     *
 +     * @return  string[]
 +     */
 +    public function getEditorDialog()
 +    {
 +        $mediaList = new ViewableMediaList();
 +        $mediaList->setObjectIDs([$this->getSingleObject()->mediaID]);
 +        $mediaList->readObjects();
 +        $media = $mediaList->search($this->getSingleObject()->mediaID);
 +
 +        I18nHandler::getInstance()->register('title_' . $media->mediaID);
 +        I18nHandler::getInstance()->register('caption_' . $media->mediaID);
 +        I18nHandler::getInstance()->register('altText_' . $media->mediaID);
 +        I18nHandler::getInstance()->assignVariables();
 +
 +        $categoryList = (new CategoryNodeTree('com.woltlab.wcf.media.category'))->getIterator();
 +        $categoryList->setMaxDepth(0);
 +
 +        return [
 +            'availableLanguageCount' => \count(LanguageFactory::getInstance()->getLanguages()),
 +            'categoryIDs' => \array_keys(CategoryHandler::getInstance()->getCategories('com.woltlab.wcf.media.category')),
 +            'mediaData' => $this->getI18nMediaData($mediaList)[$this->getSingleObject()->mediaID],
 +            'template' => WCF::getTPL()->fetch('mediaEditor', 'wcf', [
 +                '__aclSimplePrefix' => 'mediaEditor_' . $media->mediaID . '_',
 +                '__aclInputName' => 'mediaEditor_' . $media->mediaID . '_aclValues',
 +                '__languageChooserPrefix' => 'mediaEditor_' . $media->mediaID . '_',
 +                'aclValues' => SimpleAclHandler::getInstance()->getValues('com.woltlab.wcf.media', $media->mediaID),
 +                'availableLanguages' => LanguageFactory::getInstance()->getLanguages(),
 +                'categoryList' => $categoryList,
 +                'languageID' => WCF::getUser()->languageID,
 +                'languages' => LanguageFactory::getInstance()->getLanguages(),
 +                'media' => $media,
 +            ]),
 +        ];
 +    }
 +
 +    /**
 +     * @inheritDoc
 +     */
 +    public function validateUpdate()
 +    {
 +        WCF::getSession()->checkPermissions(['admin.content.cms.canManageMedia']);
 +
 +        if (empty($this->objects)) {
 +            $this->readObjects();
 +
 +            if (empty($this->objects)) {
 +                throw new UserInputException('objectIDs');
 +            }
 +        }
 +
 +        if (WCF::getSession()->getPermission('admin.content.cms.canOnlyAccessOwnMedia')) {
 +            foreach ($this->getObjects() as $media) {
 +                if ($media->userID != WCF::getUser()->userID) {
 +                    throw new PermissionDeniedException();
 +                }
 +            }
 +        }
 +
 +        $this->readInteger('categoryID', true, 'data');
 +        $this->readInteger('languageID', true, 'data');
 +        $this->readBoolean('isMultilingual', true, 'data');
 +        $this->readInteger('captionEnableHtml', true, 'data');
 +
 +        if (\count(LanguageFactory::getInstance()->getLanguages()) > 1) {
 +            // languageID: convert zero to null
 +            if (!$this->parameters['data']['languageID']) {
 +                $this->parameters['data']['languageID'] = null;
 +            }
 +
 +            // isMultilingual: convert boolean to integer
 +            $this->parameters['data']['isMultilingual'] = \intval($this->parameters['data']['isMultilingual']);
 +        } else {
 +            $this->parameters['data']['isMultilingual'] = 0;
 +            $this->parameters['data']['languageID'] = WCF::getLanguage()->languageID;
 +        }
 +
 +        // if data is not multilingual, a language id has to be given
 +        if (!$this->parameters['data']['isMultilingual'] && !$this->parameters['data']['languageID']) {
 +            throw new UserInputException('languageID');
 +        }
 +
 +        // check language id
 +        if ($this->parameters['data']['languageID'] && !LanguageFactory::getInstance()->getLanguage($this->parameters['data']['languageID'])) {
 +            throw new UserInputException('languageID');
 +        }
 +
 +        // check category id
 +        if ($this->parameters['data']['categoryID']) {
 +            $category = CategoryHandler::getInstance()->getCategory($this->parameters['data']['categoryID']);
 +            if ($category === null || $category->getObjectType()->objectType !== 'com.woltlab.wcf.media.category') {
 +                throw new UserInputException('categoryID');
 +            }
 +        }
 +    }
 +
 +    /**
 +     * @inheritDoc
 +     */
 +    public function update()
 +    {
 +        if (isset($this->parameters['data']['categoryID']) && $this->parameters['data']['categoryID'] === 0) {
 +            $this->parameters['data']['categoryID'] = null;
 +        }
 +
 +        if (empty($this->objects)) {
 +            $this->readObjects();
 +        }
 +
 +        parent::update();
 +
 +        if (\count($this->objects) == 1 && (isset($this->parameters['title']) || isset($this->parameters['caption']) || isset($this->parameters['altText']))) {
 +            $media = \reset($this->objects);
 +
 +            $isMultilingual = $media->isMultilingual;
 +            if (isset($this->parameters['data']['isMultilingual'])) {
 +                $isMultilingual = $this->parameters['data']['isMultilingual'];
 +            }
 +
 +            $sql = "DELETE FROM wcf" . WCF_N . "_media_content
 +                    WHERE       mediaID = ?";
 +            $statement = WCF::getDB()->prepareStatement($sql);
 +            $statement->execute([$media->mediaID]);
 +
 +            $sql = "INSERT INTO wcf" . WCF_N . "_media_content
 +                                (mediaID, languageID, title, caption, altText)
 +                    VALUES      (?, ?, ?, ?, ?)";
 +            $statement = WCF::getDB()->prepareStatement($sql);
 +
 +            if (!$isMultilingual) {
 +                $languageID = $media->languageID;
 +                if (isset($this->parameters['data']['languageID'])) {
 +                    $languageID = $this->parameters['data']['languageID'];
 +                }
 +                $statement->execute([
 +                    $media->mediaID,
 +                    $languageID,
 +                    isset($this->parameters['title'][$languageID]) ? \mb_substr(
 +                        $this->parameters['title'][$languageID],
 +                        0,
 +                        255
 +                    ) : '',
 +                    $this->parameters['caption'][$languageID] ?? '',
 +                    isset($this->parameters['altText'][$languageID]) ? \mb_substr(
 +                        $this->parameters['altText'][$languageID],
 +                        0,
 +                        255
 +                    ) : '',
 +                ]);
 +            } else {
 +                $languages = LanguageFactory::getInstance()->getLanguages();
 +                foreach ($languages as $language) {
 +                    $title = $caption = $altText = '';
 +                    foreach (['title', 'caption', 'altText'] as $type) {
 +                        if (isset($this->parameters[$type])) {
 +                            if (\is_array($this->parameters[$type])) {
 +                                if (isset($this->parameters[$type][$language->languageID])) {
 +                                    /** @noinspection PhpVariableVariableInspection */
 +                                    ${$type} = $this->parameters[$type][$language->languageID];
 +                                }
 +                            } else {
 +                                /** @noinspection PhpVariableVariableInspection */
 +                                ${$type} = $this->parameters[$type];
 +                            }
 +                        }
 +                    }
 +
 +                    $statement->execute([
 +                        $media->mediaID,
 +                        $language->languageID,
 +                        \mb_substr($title, 0, 255),
 +                        $caption,
 +                        \mb_substr($altText, 0, 255),
 +                    ]);
 +                }
 +            }
 +
 +            if (!empty($this->parameters['aclValues'])) {
 +                SimpleAclHandler::getInstance()->setValues(
 +                    'com.woltlab.wcf.media',
 +                    $media->mediaID,
 +                    $this->parameters['aclValues']
 +                );
 +            }
 +        }
 +    }
 +
 +    /**
 +     * @inheritDoc
 +     */
 +    public function validateGetSearchResultList()
 +    {
 +        if (!WCF::getSession()->getPermission('admin.content.cms.canManageMedia') && !WCF::getSession()->getPermission('admin.content.cms.canUseMedia')) {
 +            throw new PermissionDeniedException();
 +        }
 +
 +        $this->readString('searchString', true);
 +        $this->readInteger('categoryID', true);
 +
 +        $this->readBoolean('imagesOnly', true);
 +
 +        $this->readString('mode');
 +        if ($this->parameters['mode'] != 'editor' && $this->parameters['mode'] != 'select') {
 +            throw new UserInputException('mode');
 +        }
 +
 +        $this->readInteger('pageNo', true);
 +        if (!$this->parameters['pageNo']) {
 +            $this->parameters['pageNo'] = 1;
 +        }
++        if ($this->parameters['pageNo'] < 1) {
++            throw new UserInputException('pageNo');
++        }
 +    }
 +
 +    /**
 +     * @inheritDoc
 +     */
 +    public function getSearchResultList()
 +    {
 +        $mediaList = new MediaList();
 +        $mediaList->addSearchConditions($this->parameters['searchString']);
 +        if (WCF::getSession()->getPermission('admin.content.cms.canOnlyAccessOwnMedia')) {
 +            $mediaList->getConditionBuilder()->add('media.userID = ?', [WCF::getUser()->userID]);
 +        }
 +        if ($this->parameters['imagesOnly']) {
 +            $mediaList->getConditionBuilder()->add('media.isImage = ?', [1]);
 +        }
 +        if ($this->parameters['categoryID']) {
 +            $mediaList->getConditionBuilder()->add('media.categoryID = ?', [$this->parameters['categoryID']]);
 +        }
 +        $mediaList->sqlOrderBy = 'media.uploadTime DESC, media.mediaID DESC';
 +        $mediaList->sqlLimit = static::ITEMS_PER_MANAGER_DIALOG_PAGE;
 +        $mediaList->sqlOffset = ($this->parameters['pageNo'] - 1) * static::ITEMS_PER_MANAGER_DIALOG_PAGE;
 +        $mediaList->readObjectIDs();
 +
 +        if (empty($mediaList->getObjectIDs())) {
 +            // check if page is requested that might have existed but does not exist anymore due to deleted
 +            // media files
 +            if ($this->parameters['pageNo'] > 1 && $this->parameters['searchString'] === '' && !$this->parameters['categoryID']) {
 +                // request media dialog page with highest page number
 +                $parameters = $this->parameters;
 +                $parameters['pageNo'] = \ceil($mediaList->countObjects() / static::ITEMS_PER_MANAGER_DIALOG_PAGE);
 +
 +                return (new self($this->objects, 'getSearchResultList', $parameters))->executeAction()['returnValues'];
 +            }
 +
 +            return [
 +                'template' => WCF::getLanguage()->getDynamicVariable('wcf.media.search.noResults'),
 +            ];
 +        }
 +
 +        $viewableMediaList = new ViewableMediaList();
 +        $viewableMediaList->setObjectIDs($mediaList->getObjectIDs());
 +        $viewableMediaList->readObjects();
 +
 +        return [
 +            'media' => $this->getI18nMediaData($viewableMediaList),
 +            'pageCount' => \ceil($mediaList->countObjects() / static::ITEMS_PER_MANAGER_DIALOG_PAGE),
 +            'pageNo' => $this->parameters['pageNo'],
 +            'template' => WCF::getTPL()->fetch('mediaListItems', 'wcf', [
 +                'mediaList' => $viewableMediaList,
 +                'mode' => $this->parameters['mode'],
 +            ]),
 +        ];
 +    }
 +
 +    /**
 +     * @inheritDoc
 +     */
 +    public function validateDelete()
 +    {
 +        WCF::getSession()->checkPermissions(['admin.content.cms.canManageMedia']);
 +
 +        if (empty($this->objects)) {
 +            $this->readObjects();
 +
 +            if (empty($this->objects)) {
 +                throw new UserInputException('objectIDs');
 +            }
 +        }
 +
 +        if (WCF::getSession()->getPermission('admin.content.cms.canOnlyAccessOwnMedia')) {
 +            foreach ($this->getObjects() as $media) {
 +                if ($media->userID != WCF::getUser()->userID) {
 +                    throw new PermissionDeniedException();
 +                }
 +            }
 +        }
 +    }
 +
 +    /**
 +     * @inheritDoc
 +     */
 +    public function delete()
 +    {
 +        if (empty($this->objects)) {
 +            $this->readObjects();
 +        }
 +
 +        foreach ($this->getObjects() as $mediaEditor) {
 +            $mediaEditor->deleteFiles();
 +        }
 +
 +        parent::delete();
 +
 +        $this->unmarkItems();
 +    }
 +
 +    /**
 +     * Unmarks the media files with the given ids. If no media ids are given,
 +     * all media files currently loaded are unmarked.
 +     *
 +     * @param int[] $mediaIDs ids of the media files to be unmarked
 +     */
 +    protected function unmarkItems(array $mediaIDs = [])
 +    {
 +        if (empty($mediaIDs)) {
 +            foreach ($this->getObjects() as $media) {
 +                $mediaIDs[] = $media->mediaID;
 +            }
 +        }
 +
 +        if (!empty($mediaIDs)) {
 +            ClipboardHandler::getInstance()->unmark(
 +                $mediaIDs,
 +                ClipboardHandler::getInstance()->getObjectTypeID('com.woltlab.wcf.media')
 +            );
 +        }
 +    }
 +
 +    /**
 +     * Validates the `getSetCategoryDialog` action.
 +     *
 +     * @throws  PermissionDeniedException   if user is not allowed to set category of media files
 +     * @throws  IllegalLinkException        if no media file categories exist
 +     */
 +    public function validateGetSetCategoryDialog()
 +    {
 +        if (!WCF::getSession()->getPermission('admin.content.cms.canManageMedia')) {
 +            throw new PermissionDeniedException();
 +        }
 +
 +        if (empty(CategoryHandler::getInstance()->getCategories('com.woltlab.wcf.media.category'))) {
 +            throw new IllegalLinkException();
 +        }
 +    }
 +
 +    /**
 +     * Returns the dialog to set the category of multiple media files.
 +     *
 +     * @return  string[]
 +     */
 +    public function getSetCategoryDialog()
 +    {
 +        $categoryList = (new CategoryNodeTree('com.woltlab.wcf.media.category'))->getIterator();
 +        $categoryList->setMaxDepth(0);
 +
 +        return [
 +            'template' => WCF::getTPL()->fetch('__mediaSetCategoryDialog', 'wcf', [
 +                'categoryList' => $categoryList,
 +            ]),
 +        ];
 +    }
 +
 +    /**
 +     * Validates the `setCategory` action.
 +     *
 +     * @throws  PermissionDeniedException   if user is not allowed to edit a requested media file
 +     * @throws  UserInputException      if no object ids are given
 +     */
 +    public function validateSetCategory()
 +    {
 +        $this->validateGetSetCategoryDialog();
 +
 +        if (empty($this->objects)) {
 +            $this->readObjects();
 +
 +            if (empty($this->objects)) {
 +                throw new UserInputException('objectIDs');
 +            }
 +        }
 +
 +        if (WCF::getSession()->getPermission('admin.content.cms.canOnlyAccessOwnMedia')) {
 +            foreach ($this->getObjects() as $media) {
 +                if ($media->userID != WCF::getUser()->userID) {
 +                    throw new PermissionDeniedException();
 +                }
 +            }
 +        }
 +
 +        $this->readInteger('categoryID', true);
 +    }
 +
 +    /**
 +     * Sets the category of multiple media files.
 +     */
 +    public function setCategory()
 +    {
 +        $conditionBuilder = new PreparedStatementConditionBuilder();
 +        $conditionBuilder->add('mediaID IN (?)', [$this->objectIDs]);
 +
 +        $sql = "UPDATE  wcf" . WCF_N . "_media
 +                SET     categoryID = ?
 +                " . $conditionBuilder;
 +        $statement = WCF::getDB()->prepareStatement($sql);
 +        $statement->execute(\array_merge(
 +            [$this->parameters['categoryID'] ?: null],
 +            $conditionBuilder->getParameters()
 +        ));
 +
 +        $this->unmarkItems();
 +    }
 +
 +    /**
 +     * Validates the `replaceFile` action.
 +     *
 +     * @since       5.3
 +     */
 +    public function validateReplaceFile()
 +    {
 +        WCF::getSession()->checkPermissions(['admin.content.cms.canManageMedia']);
 +
 +        $this->getSingleObject();
 +
 +        /** @noinspection PhpUndefinedMethodInspection */
 +        $this->parameters['__files']->validateFiles(
 +            new MediaReplaceUploadFileValidationStrategy($this->getSingleObject()->getDecoratedObject())
 +        );
 +    }
 +
 +    /**
 +     * Replaces the actual file of a media file.
 +     *
 +     * @return      array
 +     * @since       5.3
 +     */
 +    public function replaceFile()
 +    {
 +        $saveStrategy = new DefaultUploadFileSaveStrategy(static::class, [
 +            'action' => 'update',
 +            'generateThumbnails' => true,
 +            'object' => $this->getSingleObject()->getDecoratedObject(),
 +            'rotateImages' => true,
 +        ], [
 +            'fileUpdateTime' => TIME_NOW,
 +            'userID' => $this->getSingleObject()->userID,
 +            'username' => $this->getSingleObject()->username,
 +            // Reset thumbnail data in case the new file has no thumbnails.
 +            'tinyThumbnailType' => '',
 +            'tinyThumbnailSize' => 0,
 +            'tinyThumbnailWidth' => 0,
 +            'tinyThumbnailHeight' => 0,
 +            'smallThumbnailType' => '',
 +            'smallThumbnailSize' => 0,
 +            'smallThumbnailWidth' => 0,
 +            'smallThumbnailHeight' => 0,
 +            'mediumThumbnailType' => '',
 +            'mediumThumbnailSize' => 0,
 +            'mediumThumbnailWidth' => 0,
 +            'mediumThumbnailHeight' => 0,
 +            'largeThumbnailType' => '',
 +            'largeThumbnailSize' => 0,
 +            'largeThumbnailWidth' => 0,
 +            'largeThumbnailHeight' => 0,
 +        ]);
 +
 +        /** @noinspection PhpUndefinedMethodInspection */
 +        $this->parameters['__files']->saveFiles($saveStrategy);
 +
 +        /** @var Media[] $mediaFiles */
 +        $mediaFiles = $saveStrategy->getObjects();
 +
 +        $result = [
 +            'errors' => [],
 +            'media' => [],
 +        ];
 +
 +        if (!empty($mediaFiles)) {
 +            $mediaIDs = $mediaToFileID = [];
 +            foreach ($mediaFiles as $internalFileID => $media) {
 +                $mediaIDs[] = $media->mediaID;
 +                $mediaToFileID[$media->mediaID] = $internalFileID;
 +            }
 +
 +            // fetch media objects from database
 +            $mediaList = new ViewableMediaList();
 +            $mediaList->setObjectIDs($mediaIDs);
 +            $mediaList->readObjects();
 +
 +            foreach ($mediaList as $media) {
 +                $result['media'][$mediaToFileID[$media->mediaID]] = $this->getMediaData($media);
 +            }
 +        }
 +
 +        /** @var UploadFile[] $files */
 +        /** @noinspection PhpUndefinedMethodInspection */
 +        $files = $this->parameters['__files']->getFiles();
 +        foreach ($files as $file) {
 +            if ($file->getValidationErrorType()) {
 +                $result['errors'][$file->getInternalFileID()] = [
 +                    'filename' => $file->getFilename(),
 +                    'filesize' => $file->getFilesize(),
 +                    'errorType' => $file->getValidationErrorType(),
 +                ];
 +            }
 +        }
 +
 +        $outdatedMediaFile = $this->getSingleObject();
 +        $updatedMediaFile = new Media($this->getSingleObject()->mediaID);
 +
 +        // Delete *old* files using the non-updated local media editor object if the new file is
 +        // stored in a different location.
 +        if (empty($result['errors']) && $updatedMediaFile->getLocation() !== $outdatedMediaFile->getLocation()) {
 +            $outdatedMediaFile->deleteFiles();
 +        }
 +
 +        return $result;
 +    }
  }
index 8cc209e2d98274a312af8f3bc6553db81cee6009,e5401e663a8892122c84315ad6a51c189081cfc8..44cf7b24a1c35913151b3aaeb57679185a346315
@@@ -17,228 -15,217 +17,235 @@@ use wcf\system\WCF
  
  /**
   * Executes follower-related actions.
 - * 
 - * @author    Alexander Ebert
 - * @copyright 2001-2019 WoltLab GmbH
 - * @license   GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
 - * @package   WoltLabSuite\Core\Data\User\Follow
 - * 
 - * @method    UserFollow              create()
 - * @method    UserFollowEditor[]      getObjects()
 - * @method    UserFollowEditor        getSingleObject()
 + *
 + * @author  Alexander Ebert
 + * @copyright   2001-2019 WoltLab GmbH
 + * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
 + * @package WoltLabSuite\Core\Data\User\Follow
 + *
 + * @method  UserFollow      create()
 + * @method  UserFollowEditor[]  getObjects()
 + * @method  UserFollowEditor    getSingleObject()
   */
 -class UserFollowAction extends AbstractDatabaseObjectAction implements IGroupedUserListAction {
 -      /**
 -       * @inheritDoc
 -       */
 -      protected $allowGuestAccess = ['getGroupedUserList'];
 -      
 -      /**
 -       * user profile object
 -       * @var UserProfile;
 -       */
 -      public $userProfile = null;
 -      
 -      /**
 -       * Validates given parameters.
 -       */
 -      public function validateFollow() {
 -              $this->readInteger('userID', false, 'data');
 -              
 -              if ($this->parameters['data']['userID'] == WCF::getUser()->userID) {
 -                      throw new PermissionDeniedException();
 -              }
 -              
 -              // check if current user is ignored by target user
 -              $sql = "SELECT  ignoreID
 -                      FROM    wcf".WCF_N."_user_ignore
 -                      WHERE   userID = ?
 -                              AND ignoreUserID = ?";
 -              $statement = WCF::getDB()->prepareStatement($sql);
 -              $statement->execute([
 -                      $this->parameters['data']['userID'],
 -                      WCF::getUser()->userID
 -              ]);
 -              
 -              $ignoreID = $statement->fetchSingleColumn();
 -              if ($ignoreID !== false) {
 -                      throw new PermissionDeniedException();
 -              }
 -      }
 -      
 -      /**
 -       * Follows a user.
 -       * 
 -       * @return      array
 -       */
 -      public function follow() {
 -              /** @var UserFollow $follow */
 -              $follow = UserFollowEditor::createOrIgnore([
 -                      'userID' => WCF::getUser()->userID,
 -                      'followUserID' => $this->parameters['data']['userID'],
 -                      'time' => TIME_NOW,
 -              ]);
 -              
 -              if ($follow !== null) {
 -                      // send notification
 -                      UserNotificationHandler::getInstance()->fireEvent(
 -                              'following',
 -                              'com.woltlab.wcf.user.follow',
 -                              new UserFollowUserNotificationObject($follow),
 -                              [$follow->followUserID]
 -                      );
 -                      
 -                      // fire activity event
 -                      UserActivityEventHandler::getInstance()->fireEvent('com.woltlab.wcf.user.recentActivityEvent.follow', $this->parameters['data']['userID']);
 -                      
 -                      // reset storage
 -                      UserStorageHandler::getInstance()->reset([$this->parameters['data']['userID']], 'followerUserIDs');
 -                      UserStorageHandler::getInstance()->reset([WCF::getUser()->userID], 'followingUserIDs');
 -              }
 -              
 -              return [
 -                      'following' => 1
 -              ];
 -      }
 -      
 -      /**
 -       * @inheritDoc
 -       */
 -      public function validateUnfollow() {
 -              $this->validateFollow();
 -      }
 -      
 -      /**
 -       * Stops following a user.
 -       * 
 -       * @return      array
 -       */
 -      public function unfollow() {
 -              $follow = UserFollow::getFollow(WCF::getUser()->userID, $this->parameters['data']['userID']);
 -              
 -              if ($follow->followID) {
 -                      $followEditor = new UserFollowEditor($follow);
 -                      $followEditor->delete();
 -                      
 -                      // remove activity event
 -                      UserActivityEventHandler::getInstance()->removeEvent('com.woltlab.wcf.user.recentActivityEvent.follow', $this->parameters['data']['userID']);
 -              }
 -              
 -              // reset storage
 -              UserStorageHandler::getInstance()->reset([$this->parameters['data']['userID']], 'followerUserIDs');
 -              UserStorageHandler::getInstance()->reset([WCF::getUser()->userID], 'followingUserIDs');
 -              
 -              return [
 -                      'following' => 0
 -              ];
 -      }
 -      
 -      /**
 -       * @inheritDoc
 -       */
 -      public function validateDelete() {
 -              // read objects
 -              if (empty($this->objects)) {
 -                      $this->readObjects();
 -                      
 -                      if (empty($this->objects)) {
 -                              throw new UserInputException('objectIDs');
 -                      }
 -              }
 -              
 -              // validate ownership
 -              foreach ($this->getObjects() as $follow) {
 -                      if ($follow->userID != WCF::getUser()->userID) {
 -                              throw new PermissionDeniedException();
 -                      }
 -              }
 -      }
 -      
 -      /**
 -       * @inheritDoc
 -       */
 -      public function delete() {
 -              $returnValues = parent::delete();
 -              
 -              $followUserIDs = [];
 -              foreach ($this->getObjects() as $follow) {
 -                      $followUserIDs[] = $follow->followUserID;
 -                      // remove activity event
 -                      UserActivityEventHandler::getInstance()->removeEvents('com.woltlab.wcf.user.recentActivityEvent.follow', [$follow->followUserID]);
 -              }
 -              
 -              // reset storage
 -              UserStorageHandler::getInstance()->reset($followUserIDs, 'followerUserIDs');
 -              UserStorageHandler::getInstance()->reset([WCF::getUser()->userID], 'followingUserIDs');
 -              
 -              return $returnValues;
 -      }
 -      
 -      /**
 -       * @inheritDoc
 -       */
 -      public function validateGetGroupedUserList() {
 -              $this->readInteger('pageNo');
 -              $this->readInteger('userID');
 -              
 -              $this->userProfile = UserProfileRuntimeCache::getInstance()->getObject($this->parameters['userID']);
 -              if (!$this->userProfile) {
 -                      throw new UserInputException('userID');
 -              }
 -              if ($this->userProfile->isProtected()) {
 -                      throw new PermissionDeniedException();
 -              }
 -
 -              if ($this->parameters['pageNo'] < 1) {
 -                      throw new UserInputException('pageNo');
 -              }
 -      }
 -      
 -      /**
 -       * @inheritDoc
 -       */
 -      public function getGroupedUserList() {
 -              // resolve page count
 -              $sql = "SELECT  COUNT(*)
 -                      FROM    wcf".WCF_N."_user_follow
 -                      WHERE   followUserID = ?";
 -              $statement = WCF::getDB()->prepareStatement($sql);
 -              $statement->execute([$this->parameters['userID']]);
 -              $pageCount = ceil($statement->fetchSingleColumn() / 20);
 -              
 -              // get user ids
 -              $sql = "SELECT  userID
 -                      FROM    wcf".WCF_N."_user_follow
 -                      WHERE   followUserID = ?";
 -              $statement = WCF::getDB()->prepareStatement($sql, 20, ($this->parameters['pageNo'] - 1) * 20);
 -              $statement->execute([$this->parameters['userID']]);
 -              $userIDs = $statement->fetchAll(\PDO::FETCH_COLUMN);
 -              
 -              // create group
 -              $group = new GroupedUserList();
 -              $group->addUserIDs($userIDs);
 -              
 -              // load user profiles
 -              GroupedUserList::loadUsers();
 -              
 -              WCF::getTPL()->assign([
 -                      'groupedUsers' => [$group]
 -              ]);
 -              
 -              return [
 -                      'pageCount' => $pageCount,
 -                      'template' => WCF::getTPL()->fetch('groupedUserList')
 -              ];
 -      }
 +class UserFollowAction extends AbstractDatabaseObjectAction implements IGroupedUserListAction
 +{
 +    /**
 +     * @inheritDoc
 +     */
 +    protected $allowGuestAccess = ['getGroupedUserList'];
 +
 +    /**
 +     * user profile object
 +     * @var UserProfile;
 +     */
 +    public $userProfile;
 +
 +    /**
 +     * Validates given parameters.
 +     */
 +    public function validateFollow()
 +    {
 +        $this->readInteger('userID', false, 'data');
 +
 +        if ($this->parameters['data']['userID'] == WCF::getUser()->userID) {
 +            throw new PermissionDeniedException();
 +        }
 +
 +        // check if current user is ignored by target user
 +        $sql = "SELECT  ignoreID
 +                FROM    wcf" . WCF_N . "_user_ignore
 +                WHERE   userID = ?
 +                    AND ignoreUserID = ?";
 +        $statement = WCF::getDB()->prepareStatement($sql);
 +        $statement->execute([
 +            $this->parameters['data']['userID'],
 +            WCF::getUser()->userID,
 +        ]);
 +
 +        $ignoreID = $statement->fetchSingleColumn();
 +        if ($ignoreID !== false) {
 +            throw new PermissionDeniedException();
 +        }
 +    }
 +
 +    /**
 +     * Follows a user.
 +     *
 +     * @return  array
 +     */
 +    public function follow()
 +    {
 +        /** @var UserFollow $follow */
 +        $follow = UserFollowEditor::createOrIgnore([
 +            'userID' => WCF::getUser()->userID,
 +            'followUserID' => $this->parameters['data']['userID'],
 +            'time' => TIME_NOW,
 +        ]);
 +
 +        if ($follow !== null) {
 +            // send notification
 +            UserNotificationHandler::getInstance()->fireEvent(
 +                'following',
 +                'com.woltlab.wcf.user.follow',
 +                new UserFollowUserNotificationObject($follow),
 +                [$follow->followUserID]
 +            );
 +
 +            // fire activity event
 +            UserActivityEventHandler::getInstance()->fireEvent(
 +                'com.woltlab.wcf.user.recentActivityEvent.follow',
 +                $this->parameters['data']['userID']
 +            );
 +
 +            // reset storage
 +            UserStorageHandler::getInstance()->reset([$this->parameters['data']['userID']], 'followerUserIDs');
 +            UserStorageHandler::getInstance()->reset([WCF::getUser()->userID], 'followingUserIDs');
 +        }
 +
 +        return [
 +            'following' => 1,
 +        ];
 +    }
 +
 +    /**
 +     * @inheritDoc
 +     */
 +    public function validateUnfollow()
 +    {
 +        $this->validateFollow();
 +    }
 +
 +    /**
 +     * Stops following a user.
 +     *
 +     * @return  array
 +     */
 +    public function unfollow()
 +    {
 +        $follow = UserFollow::getFollow(WCF::getUser()->userID, $this->parameters['data']['userID']);
 +
 +        if ($follow->followID) {
 +            $followEditor = new UserFollowEditor($follow);
 +            $followEditor->delete();
 +
 +            // remove activity event
 +            UserActivityEventHandler::getInstance()->removeEvent(
 +                'com.woltlab.wcf.user.recentActivityEvent.follow',
 +                $this->parameters['data']['userID']
 +            );
 +        }
 +
 +        // reset storage
 +        UserStorageHandler::getInstance()->reset([$this->parameters['data']['userID']], 'followerUserIDs');
 +        UserStorageHandler::getInstance()->reset([WCF::getUser()->userID], 'followingUserIDs');
 +
 +        return [
 +            'following' => 0,
 +        ];
 +    }
 +
 +    /**
 +     * @inheritDoc
 +     */
 +    public function validateDelete()
 +    {
 +        // read objects
 +        if (empty($this->objects)) {
 +            $this->readObjects();
 +
 +            if (empty($this->objects)) {
 +                throw new UserInputException('objectIDs');
 +            }
 +        }
 +
 +        // validate ownership
 +        foreach ($this->getObjects() as $follow) {
 +            if ($follow->userID != WCF::getUser()->userID) {
 +                throw new PermissionDeniedException();
 +            }
 +        }
 +    }
 +
 +    /**
 +     * @inheritDoc
 +     */
 +    public function delete()
 +    {
 +        $returnValues = parent::delete();
 +
 +        $followUserIDs = [];
 +        foreach ($this->getObjects() as $follow) {
 +            $followUserIDs[] = $follow->followUserID;
 +            // remove activity event
 +            UserActivityEventHandler::getInstance()->removeEvents(
 +                'com.woltlab.wcf.user.recentActivityEvent.follow',
 +                [$follow->followUserID]
 +            );
 +        }
 +
 +        // reset storage
 +        UserStorageHandler::getInstance()->reset($followUserIDs, 'followerUserIDs');
 +        UserStorageHandler::getInstance()->reset([WCF::getUser()->userID], 'followingUserIDs');
 +
 +        return $returnValues;
 +    }
 +
 +    /**
 +     * @inheritDoc
 +     */
 +    public function validateGetGroupedUserList()
 +    {
 +        $this->readInteger('pageNo');
 +        $this->readInteger('userID');
 +
 +        $this->userProfile = UserProfileRuntimeCache::getInstance()->getObject($this->parameters['userID']);
++        if (!$this->userProfile) {
++            throw new UserInputException('userID');
++        }
 +        if ($this->userProfile->isProtected()) {
 +            throw new PermissionDeniedException();
 +        }
++
++        if ($this->parameters['pageNo'] < 1) {
++            throw new UserInputException('pageNo');
++        }
 +    }
 +
 +    /**
 +     * @inheritDoc
 +     */
 +    public function getGroupedUserList()
 +    {
 +        // resolve page count
 +        $sql = "SELECT  COUNT(*)
 +                FROM    wcf" . WCF_N . "_user_follow
 +                WHERE   followUserID = ?";
 +        $statement = WCF::getDB()->prepareStatement($sql);
 +        $statement->execute([$this->parameters['userID']]);
 +        $pageCount = \ceil($statement->fetchSingleColumn() / 20);
 +
 +        // get user ids
 +        $sql = "SELECT  userID
 +                FROM    wcf" . WCF_N . "_user_follow
 +                WHERE   followUserID = ?";
 +        $statement = WCF::getDB()->prepareStatement($sql, 20, ($this->parameters['pageNo'] - 1) * 20);
 +        $statement->execute([$this->parameters['userID']]);
 +        $userIDs = $statement->fetchAll(\PDO::FETCH_COLUMN);
 +
 +        // create group
 +        $group = new GroupedUserList();
 +        $group->addUserIDs($userIDs);
 +
 +        // load user profiles
 +        GroupedUserList::loadUsers();
 +
 +        WCF::getTPL()->assign([
 +            'groupedUsers' => [$group],
 +        ]);
 +
 +        return [
 +            'pageCount' => $pageCount,
 +            'template' => WCF::getTPL()->fetch('groupedUserList'),
 +        ];
 +    }
  }
index 7e1d24c956eb402c0c1998225c3818bae73de482,6b942d5bab3dbcb2e6ace7bfd178208393edcaa7..a80c68083197ac9e51089a109c33e7817dc11ac7
@@@ -1,76 -1,79 +1,84 @@@
  <?php
 +
  namespace wcf\data\user\follow;
 +
  use wcf\system\cache\runtime\UserProfileRuntimeCache;
  use wcf\system\exception\PermissionDeniedException;
+ use wcf\system\exception\UserInputException;
  use wcf\system\user\GroupedUserList;
  use wcf\system\WCF;
  
  /**
   * Executes following-related actions.
 - * 
 - * @author    Alexander Ebert
 - * @copyright 2001-2019 WoltLab GmbH
 - * @license   GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
 - * @package   WoltLabSuite\Core\Data\User\Follow
 + *
 + * @author  Alexander Ebert
 + * @copyright   2001-2019 WoltLab GmbH
 + * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
 + * @package WoltLabSuite\Core\Data\User\Follow
   */
 -class UserFollowingAction extends UserFollowAction {
 -      /**
 -       * @inheritDoc
 -       */
 -      protected $className = UserFollowEditor::class;
 -      
 -      /**
 -       * @inheritDoc
 -       */
 -      public function validateGetGroupedUserList() {
 -              $this->readInteger('pageNo');
 -              $this->readInteger('userID');
 -              
 -              $this->userProfile = UserProfileRuntimeCache::getInstance()->getObject($this->parameters['userID']);
 -              if (!$this->userProfile) {
 -                      throw new UserInputException('userID');
 -              }
 -              if ($this->userProfile->isProtected()) {
 -                      throw new PermissionDeniedException();
 -              }
 +class UserFollowingAction extends UserFollowAction
 +{
 +    /**
 +     * @inheritDoc
 +     */
 +    protected $className = UserFollowEditor::class;
 +
 +    /**
 +     * @inheritDoc
 +     */
 +    public function validateGetGroupedUserList()
 +    {
 +        $this->readInteger('pageNo');
 +        $this->readInteger('userID');
 +
 +        $this->userProfile = UserProfileRuntimeCache::getInstance()->getObject($this->parameters['userID']);
++        if (!$this->userProfile) {
++            throw new UserInputException('userID');
++        }
 +        if ($this->userProfile->isProtected()) {
 +            throw new PermissionDeniedException();
 +        }
++
++        if ($this->parameters['pageNo'] < 1) {
++            throw new UserInputException('pageNo');
++        }
 +    }
 +
 +    /**
 +     * @inheritDoc
 +     */
 +    public function getGroupedUserList()
 +    {
 +        // resolve page count
 +        $sql = "SELECT  COUNT(*)
 +                FROM    wcf" . WCF_N . "_user_follow
 +                WHERE   userID = ?";
 +        $statement = WCF::getDB()->prepareStatement($sql);
 +        $statement->execute([$this->parameters['userID']]);
 +        $pageCount = \ceil($statement->fetchSingleColumn() / 20);
 +
 +        // get user ids
 +        $sql = "SELECT  followUserID
 +                FROM    wcf" . WCF_N . "_user_follow
 +                WHERE   userID = ?";
 +        $statement = WCF::getDB()->prepareStatement($sql, 20, ($this->parameters['pageNo'] - 1) * 20);
 +        $statement->execute([$this->parameters['userID']]);
 +        $userIDs = $statement->fetchAll(\PDO::FETCH_COLUMN);
 +
 +        // create group
 +        $group = new GroupedUserList();
 +        $group->addUserIDs($userIDs);
 +
 +        // load user profiles
 +        GroupedUserList::loadUsers();
 +
 +        WCF::getTPL()->assign([
 +            'groupedUsers' => [$group],
 +        ]);
  
 -              if ($this->parameters['pageNo'] < 1) {
 -                      throw new UserInputException('pageNo');
 -              }
 -      }
 -      
 -      /**
 -       * @inheritDoc
 -       */
 -      public function getGroupedUserList() {
 -              // resolve page count
 -              $sql = "SELECT  COUNT(*)
 -                      FROM    wcf".WCF_N."_user_follow
 -                      WHERE   userID = ?";
 -              $statement = WCF::getDB()->prepareStatement($sql);
 -              $statement->execute([$this->parameters['userID']]);
 -              $pageCount = ceil($statement->fetchSingleColumn() / 20);
 -              
 -              // get user ids
 -              $sql = "SELECT  followUserID
 -                      FROM    wcf".WCF_N."_user_follow
 -                      WHERE   userID = ?";
 -              $statement = WCF::getDB()->prepareStatement($sql, 20, ($this->parameters['pageNo'] - 1) * 20);
 -              $statement->execute([$this->parameters['userID']]);
 -              $userIDs = $statement->fetchAll(\PDO::FETCH_COLUMN);
 -              
 -              // create group
 -              $group = new GroupedUserList();
 -              $group->addUserIDs($userIDs);
 -              
 -              // load user profiles
 -              GroupedUserList::loadUsers();
 -              
 -              WCF::getTPL()->assign([
 -                      'groupedUsers' => [$group]
 -              ]);
 -              
 -              return [
 -                      'pageCount' => $pageCount,
 -                      'template' => WCF::getTPL()->fetch('groupedUserList')
 -              ];
 -      }
 +        return [
 +            'pageCount' => $pageCount,
 +            'template' => WCF::getTPL()->fetch('groupedUserList'),
 +        ];
 +    }
  }
index 2c41204f6a9c12e40177a97c0bc05c619b19e259,92ba491c040b6fd8eddea50015d539bd36331547..45d4884f186d033f659c401aa003fbc3442320a5
@@@ -13,100 -11,100 +13,104 @@@ use wcf\system\WCF
  
  /**
   * Executes profile visitor-related actions.
 - * 
 - * @author    Alexander Ebert
 - * @copyright 2001-2019 WoltLab GmbH
 - * @license   GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
 - * @package   WoltLabSuite\Core\Data\User\Profile\Visitor
 - * 
 - * @method    UserProfileVisitor              create()
 - * @method    UserProfileVisitorEditor[]      getObjects()
 - * @method    UserProfileVisitorEditor        getSingleObject()
 + *
 + * @author  Alexander Ebert
 + * @copyright   2001-2019 WoltLab GmbH
 + * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
 + * @package WoltLabSuite\Core\Data\User\Profile\Visitor
 + *
 + * @method  UserProfileVisitor      create()
 + * @method  UserProfileVisitorEditor[]  getObjects()
 + * @method  UserProfileVisitorEditor    getSingleObject()
   */
 -class UserProfileVisitorAction extends AbstractDatabaseObjectAction implements IGroupedUserListAction {
 -      /**
 -       * @inheritDoc
 -       */
 -      protected $allowGuestAccess = ['getGroupedUserList'];
 -      
 -      /**
 -       * user profile object
 -       * @var UserProfile;
 -       */
 -      public $userProfile = null;
 -      
 -      /**
 -       * @inheritDoc
 -       */
 -      public function validateGetGroupedUserList() {
 -              $this->readInteger('pageNo');
 -              $this->readInteger('userID');
 -              
 -              $this->userProfile = UserProfileRuntimeCache::getInstance()->getObject($this->parameters['userID']);
 -              if (!$this->userProfile) {
 -                      throw new UserInputException('userID');
 -              }
 -              if ($this->userProfile->isProtected()) {
 -                      throw new PermissionDeniedException();
 -              }
 +class UserProfileVisitorAction extends AbstractDatabaseObjectAction implements IGroupedUserListAction
 +{
 +    /**
 +     * @inheritDoc
 +     */
 +    protected $allowGuestAccess = ['getGroupedUserList'];
 +
 +    /**
 +     * user profile object
 +     * @var UserProfile;
 +     */
 +    public $userProfile;
 +
 +    /**
 +     * @inheritDoc
 +     */
 +    public function validateGetGroupedUserList()
 +    {
 +        $this->readInteger('pageNo');
 +        $this->readInteger('userID');
 +
 +        $this->userProfile = UserProfileRuntimeCache::getInstance()->getObject($this->parameters['userID']);
 +        if (!$this->userProfile) {
 +            throw new UserInputException('userID');
 +        }
 +        if ($this->userProfile->isProtected()) {
 +            throw new PermissionDeniedException();
 +        }
++
++        if ($this->parameters['pageNo'] < 1) {
++            throw new UserInputException('pageNo');
++        }
 +    }
 +
 +    /**
 +     * @inheritDoc
 +     */
 +    public function getGroupedUserList()
 +    {
 +        // resolve page count
 +        $sql = "SELECT  COUNT(*)
 +                FROM    wcf" . WCF_N . "_user_profile_visitor
 +                WHERE   ownerID = ?";
 +        $statement = WCF::getDB()->prepareStatement($sql);
 +        $statement->execute([$this->parameters['userID']]);
 +        $pageCount = \ceil($statement->fetchSingleColumn() / 20);
 +
 +        // get user ids
 +        $sql = "SELECT      userID
 +                FROM        wcf" . WCF_N . "_user_profile_visitor
 +                WHERE       ownerID = ?
 +                ORDER BY    time DESC";
 +        $statement = WCF::getDB()->prepareStatement($sql, 20, ($this->parameters['pageNo'] - 1) * 20);
 +        $statement->execute([$this->parameters['userID']]);
 +        $userIDs = $statement->fetchAll(\PDO::FETCH_COLUMN);
 +
 +        // create group
 +        $group = new GroupedUserList();
 +        $group->addUserIDs($userIDs);
 +
 +        // load user profiles
 +        GroupedUserList::loadUsers();
 +
 +        WCF::getTPL()->assign([
 +            'groupedUsers' => [$group],
 +        ]);
 +
 +        return [
 +            'pageCount' => $pageCount,
 +            'template' => WCF::getTPL()->fetch('groupedUserList'),
 +        ];
 +    }
  
 -              if ($this->parameters['pageNo'] < 1) {
 -                      throw new UserInputException('pageNo');
 -              }
 -      }
 -      
 -      /**
 -       * @inheritDoc
 -       */
 -      public function getGroupedUserList() {
 -              // resolve page count
 -              $sql = "SELECT  COUNT(*)
 -                      FROM    wcf".WCF_N."_user_profile_visitor
 -                      WHERE   ownerID = ?";
 -              $statement = WCF::getDB()->prepareStatement($sql);
 -              $statement->execute([$this->parameters['userID']]);
 -              $pageCount = ceil($statement->fetchSingleColumn() / 20);
 -              
 -              // get user ids
 -              $sql = "SELECT          userID
 -                      FROM            wcf".WCF_N."_user_profile_visitor
 -                      WHERE           ownerID = ?
 -                      ORDER BY        time DESC";
 -              $statement = WCF::getDB()->prepareStatement($sql, 20, ($this->parameters['pageNo'] - 1) * 20);
 -              $statement->execute([$this->parameters['userID']]);
 -              $userIDs = $statement->fetchAll(\PDO::FETCH_COLUMN);
 -              
 -              // create group
 -              $group = new GroupedUserList();
 -              $group->addUserIDs($userIDs);
 -              
 -              // load user profiles
 -              GroupedUserList::loadUsers();
 -              
 -              WCF::getTPL()->assign([
 -                      'groupedUsers' => [$group]
 -              ]);
 -              
 -              return [
 -                      'pageCount' => $pageCount,
 -                      'template' => WCF::getTPL()->fetch('groupedUserList')
 -              ];
 -      }
 -      
 -      /**
 -       * Inserts a new visitor if it does not already exist, or updates it if it does.
 -       * @since       5.2
 -       */
 -      public function registerVisitor() {
 -              $sql = "INSERT INTO             wcf".WCF_N."_user_profile_visitor
 -                                              (ownerID, userID, time)
 -                      VALUES                  (?, ?, ?)
 -                      ON DUPLICATE KEY UPDATE time = VALUES(time)";
 -              $statement = WCF::getDB()->prepareStatement($sql);
 -              $statement->execute([
 -                      $this->parameters['data']['ownerID'],
 -                      $this->parameters['data']['userID'],
 -                      $this->parameters['data']['time'] ?? TIME_NOW,
 -              ]);
 -      }
 +    /**
 +     * Inserts a new visitor if it does not already exist, or updates it if it does.
 +     * @since       5.2
 +     */
 +    public function registerVisitor()
 +    {
 +        $sql = "INSERT INTO             wcf" . WCF_N . "_user_profile_visitor
 +                                        (ownerID, userID, time)
 +                VALUES                  (?, ?, ?)
 +                ON DUPLICATE KEY UPDATE time = VALUES(time)";
 +        $statement = WCF::getDB()->prepareStatement($sql);
 +        $statement->execute([
 +            $this->parameters['data']['ownerID'],
 +            $this->parameters['data']['userID'],
 +            $this->parameters['data']['time'] ?? TIME_NOW,
 +        ]);
 +    }
  }
index c2177ec6d17b2be2cecfcefaec5f5b9fdf16b11f,8e84d34a089d49fc0dc94de49d0a6ec469741888..d4d89e13bbd33b6d37b745c905f83ff6ba133a08
@@@ -18,241 -16,205 +18,245 @@@ use wcf\system\user\storage\UserStorage
  use wcf\system\WCF;
  
  /**
 - * Provides user trophy actions. 
 + * Provides user trophy actions.
   *
 - * @author    Joshua Ruesweg
 - * @copyright 2001-2019 WoltLab GmbH
 - * @license   GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
 - * @package   WoltLabSuite\Core\Data\User\Trophy
 - * @since     3.1
 + * @author  Joshua Ruesweg
 + * @copyright   2001-2019 WoltLab GmbH
 + * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
 + * @package WoltLabSuite\Core\Data\User\Trophy
 + * @since   3.1
   *
 - * @method    UserTrophyEditor[]              getObjects()
 - * @method    UserTrophyEditor                getSingleObject()
 + * @method  UserTrophyEditor[]      getObjects()
 + * @method  UserTrophyEditor        getSingleObject()
   */
 -class UserTrophyAction extends AbstractDatabaseObjectAction {
 -      /**
 -       * @inheritDoc
 -       */
 -      protected $permissionsDelete = ['admin.trophy.canAwardTrophy'];
 -      
 -      /**
 -       * @inheritDoc
 -       */
 -      protected $allowGuestAccess = ['getGroupedUserTrophyList'];
 -      
 -      /**
 -       * @var UserProfile
 -       */
 -      public $userProfile;
 -      
 -      /**
 -       * @inheritDoc
 -       */
 -      public function create() {
 -              /** @var UserTrophy $userTrophy */
 -              $userTrophy = parent::create();
 -              
 -              if (!$userTrophy->getTrophy()->isDisabled()) {
 -                      $userAction = new UserAction([$userTrophy->userID], 'update', [
 -                              'counters' => [
 -                                      'trophyPoints' => 1
 -                              ]
 -                      ]); 
 -                      $userAction->executeAction(); 
 -                      
 -                      // checks if the user still has space to add special trophies
 -                      if (count($userTrophy->getUserProfile()->getSpecialTrophies()) < $userTrophy->getUserProfile()->getPermission('user.profile.trophy.maxUserSpecialTrophies')) {
 -                              $hasTrophy = false;
 -                              foreach (UserTrophyList::getUserTrophies([$userTrophy->getUserProfile()->userID])[$userTrophy->getUserProfile()->userID] as $trophy) {
 -                                      if ($trophy->trophyID == $userTrophy->trophyID && $trophy->userTrophyID !== $userTrophy->userTrophyID) {
 -                                              $hasTrophy = true; 
 -                                              break; 
 -                                      }
 -                              }
 -                              
 -                              if (!$hasTrophy) {
 -                                      $userProfileAction = new UserProfileAction([$userTrophy->getUserProfile()->getDecoratedObject()], 'updateSpecialTrophies', [
 -                                              'trophyIDs' => array_unique(array_merge(array_map(function($trophy) {
 -                                                      return $trophy->trophyID;
 -                                              }, $userTrophy->getUserProfile()->getSpecialTrophies()), [$userTrophy->trophyID]))
 -                                      ]);
 -                                      $userProfileAction->executeAction();
 -                              }
 -                      }
 -              }
 -              
 -              UserActivityEventHandler::getInstance()->fireEvent('com.woltlab.wcf.userTrophy.recentActivityEvent.trophyReceived', $userTrophy->getObjectID(), null, $userTrophy->userID);
 -              
 -              UserNotificationHandler::getInstance()->fireEvent('received', 'com.woltlab.wcf.userTrophy.notification', new UserTrophyNotificationObject($userTrophy), [
 -                      $userTrophy->userID
 -              ]);
 -              
 -              return $userTrophy; 
 -      }
 -      
 -      /**
 -       * @inheritDoc
 -       */
 -      public function validateDelete() {
 -              parent::validateDelete();
 -              
 -              /** @var UserTrophy $object */
 -              foreach ($this->objects as $object) {
 -                      if ($object->getTrophy()->awardAutomatically) {
 -                              throw new PermissionDeniedException(); 
 -                      }
 -              }
 -      }
 -      
 -      /**
 -       * @inheritDoc
 -       */
 -      public function delete() {
 -              if (empty($this->objects)) {
 -                      $this->readObjects();
 -              }
 -              
 -              $trophyIDs = $userIDs = []; 
 -              foreach ($this->getObjects() as $object) {
 -                      $trophyIDs[] = $object->trophyID; 
 -                      $userIDs[] = $object->userID; 
 -              }
 -              
 -              $returnValues = parent::delete();
 -              
 -              if (!empty($this->objects)) {
 -                      // update user special trophies trophies
 -                      $userTrophies = UserTrophyList::getUserTrophies($userIDs);
 -                      
 -                      foreach ($userTrophies as $userID => $trophies) {
 -                              $userTrophyIDs = [];
 -                              foreach ($trophies as $trophy) {
 -                                      $userTrophyIDs[] = $trophy->trophyID;
 -                              }
 -                              
 -                              $conditionBuilder = new PreparedStatementConditionBuilder();
 -                              if (!empty($userTrophyIDs)) $conditionBuilder->add('trophyID NOT IN (?)', [array_unique($userTrophyIDs)]);
 -                              $conditionBuilder->add('userID = ?', [$userID]);
 -                              
 -                              $sql = "DELETE FROM wcf". WCF_N ."_user_special_trophy ". $conditionBuilder;
 -                              $statement = WCF::getDB()->prepareStatement($sql);
 -                              $statement->execute($conditionBuilder->getParameters());
 -                              
 -                              UserStorageHandler::getInstance()->reset([$userID], 'specialTrophies');
 -                      }
 -                      
 -                      $updateUserTrophies = [];
 -                      foreach ($this->getObjects() as $object) {
 -                              if (!$object->getTrophy()->isDisabled()) {
 -                                      if (!isset($updateUserTrophies[$object->userID])) $updateUserTrophies[$object->userID] = 0;
 -                                      $updateUserTrophies[$object->userID]--;
 -                              }
 -                      }
 -                      
 -                      foreach ($updateUserTrophies as $userID => $count) {
 -                              $userAction = new UserAction([$userID], 'update', [
 -                                      'counters' => [
 -                                              'trophyPoints' => $count
 -                                      ]
 -                              ]);
 -                              $userAction->executeAction();
 -                      }
 -              }
 -              
 -              return $returnValues;
 -      }
 -      
 -      /**
 -       * Validates the getGroupedUserTrophyList method. 
 -       */
 -      public function validateGetGroupedUserTrophyList() {
 -              if (!MODULE_TROPHY) {
 -                      throw new IllegalLinkException();
 -              }
 -              
 -              WCF::getSession()->checkPermissions(['user.profile.trophy.canSeeTrophies']);
 -              
 -              $this->readInteger('pageNo');
 -              $this->readInteger('userID');
 -              
 -              $this->userProfile = UserProfileRuntimeCache::getInstance()->getObject($this->parameters['userID']);
 -              if (!$this->userProfile) {
 -                      throw new UserInputException('userID');
 -              }
 -              if (!$this->userProfile->isAccessible('canViewTrophies') && $this->userProfile->userID != WCF::getSession()->userID) {
 -                      throw new PermissionDeniedException();
 -              }
 -
 -              if ($this->parameters['pageNo'] < 1) {
 -                      throw new UserInputException('pageNo');
 -              }
 -      }
 -      
 -      /**
 -       * Returns a viewable user trophy list for a specific user. 
 -       */
 -      public function getGroupedUserTrophyList() {
 -              $userTrophyList = new UserTrophyList();
 -              $userTrophyList->getConditionBuilder()->add('userID = ?', [$this->parameters['userID']]);
 -              if (!empty($userTrophyList->sqlJoins)) $userTrophyList->sqlJoins .= ' ';
 -              if (!empty($userTrophyList->sqlConditionJoins)) $userTrophyList->sqlConditionJoins .= ' ';
 -              $userTrophyList->sqlJoins .= 'LEFT JOIN wcf'. WCF_N . '_trophy trophy ON user_trophy.trophyID = trophy.trophyID';
 -              $userTrophyList->sqlConditionJoins .= 'LEFT JOIN wcf'. WCF_N . '_trophy trophy ON user_trophy.trophyID = trophy.trophyID';
 -              
 -              // trophy category join
 -              $userTrophyList->sqlJoins .= ' LEFT JOIN wcf'. WCF_N . '_category category ON trophy.categoryID = category.categoryID';
 -              $userTrophyList->sqlConditionJoins .= ' LEFT JOIN wcf'. WCF_N . '_category category ON trophy.categoryID = category.categoryID';
 -              
 -              $userTrophyList->getConditionBuilder()->add('trophy.isDisabled = ?', [0]);
 -              $userTrophyList->getConditionBuilder()->add('category.isDisabled = ?', [0]);
 -              $userTrophyList->sqlLimit = 10; 
 -              $userTrophyList->sqlOffset = ($this->parameters['pageNo'] - 1) * 10;
 -              $userTrophyList->sqlOrderBy = 'time DESC';
 -              $pageCount = ceil($userTrophyList->countObjects() / 10);
 -              $userTrophyList->readObjects();
 -              
 -              return [
 -                      'pageCount' => $pageCount,
 -                      'title' => WCF::getLanguage()->getDynamicVariable('wcf.user.trophy.dialogTitle', ['username' => $this->userProfile->username]),
 -                      'template' => WCF::getTPL()->fetch('groupedUserTrophyList', 'wcf', [
 -                              'userTrophyList' => $userTrophyList
 -                      ])
 -              ];
 -      }
 +class UserTrophyAction extends AbstractDatabaseObjectAction
 +{
 +    /**
 +     * @inheritDoc
 +     */
 +    protected $permissionsDelete = ['admin.trophy.canAwardTrophy'];
 +
 +    /**
 +     * @inheritDoc
 +     */
 +    protected $allowGuestAccess = ['getGroupedUserTrophyList'];
 +
 +    /**
 +     * @var UserProfile
 +     */
 +    public $userProfile;
 +
 +    /**
 +     * @inheritDoc
 +     */
 +    public function create()
 +    {
 +        /** @var UserTrophy $userTrophy */
 +        $userTrophy = parent::create();
 +
 +        if (!$userTrophy->getTrophy()->isDisabled()) {
 +            $userAction = new UserAction([$userTrophy->userID], 'update', [
 +                'counters' => [
 +                    'trophyPoints' => 1,
 +                ],
 +            ]);
 +            $userAction->executeAction();
 +
 +            // checks if the user still has space to add special trophies
 +            if (\count($userTrophy->getUserProfile()->getSpecialTrophies()) < $userTrophy->getUserProfile()->getPermission('user.profile.trophy.maxUserSpecialTrophies')) {
 +                $hasTrophy = false;
 +                foreach (UserTrophyList::getUserTrophies([$userTrophy->getUserProfile()->userID])[$userTrophy->getUserProfile()->userID] as $trophy) {
 +                    if ($trophy->trophyID == $userTrophy->trophyID && $trophy->userTrophyID !== $userTrophy->userTrophyID) {
 +                        $hasTrophy = true;
 +                        break;
 +                    }
 +                }
 +
 +                if (!$hasTrophy) {
 +                    $userProfileAction = new UserProfileAction(
 +                        [$userTrophy->getUserProfile()->getDecoratedObject()],
 +                        'updateSpecialTrophies',
 +                        [
 +                            'trophyIDs' => \array_unique(\array_merge(\array_map(static function ($trophy) {
 +                                return $trophy->trophyID;
 +                            }, $userTrophy->getUserProfile()->getSpecialTrophies()), [$userTrophy->trophyID])),
 +                        ]
 +                    );
 +                    $userProfileAction->executeAction();
 +                }
 +            }
 +        }
 +
 +        UserActivityEventHandler::getInstance()->fireEvent(
 +            'com.woltlab.wcf.userTrophy.recentActivityEvent.trophyReceived',
 +            $userTrophy->getObjectID(),
 +            null,
 +            $userTrophy->userID
 +        );
 +
 +        UserNotificationHandler::getInstance()->fireEvent(
 +            'received',
 +            'com.woltlab.wcf.userTrophy.notification',
 +            new UserTrophyNotificationObject($userTrophy),
 +            [
 +                $userTrophy->userID,
 +            ]
 +        );
 +
 +        return $userTrophy;
 +    }
 +
 +    /**
 +     * @inheritDoc
 +     */
 +    public function validateDelete()
 +    {
 +        parent::validateDelete();
 +
 +        /** @var UserTrophy $object */
 +        foreach ($this->objects as $object) {
 +            if ($object->getTrophy()->awardAutomatically) {
 +                throw new PermissionDeniedException();
 +            }
 +        }
 +    }
 +
 +    /**
 +     * @inheritDoc
 +     */
 +    public function delete()
 +    {
 +        if (empty($this->objects)) {
 +            $this->readObjects();
 +        }
 +
 +        $trophyIDs = $userIDs = [];
 +        foreach ($this->getObjects() as $object) {
 +            $trophyIDs[] = $object->trophyID;
 +            $userIDs[] = $object->userID;
 +        }
 +
 +        $returnValues = parent::delete();
 +
 +        if (!empty($this->objects)) {
 +            // update user special trophies trophies
 +            $userTrophies = UserTrophyList::getUserTrophies($userIDs);
 +
 +            foreach ($userTrophies as $userID => $trophies) {
 +                $userTrophyIDs = [];
 +                foreach ($trophies as $trophy) {
 +                    $userTrophyIDs[] = $trophy->trophyID;
 +                }
 +
 +                $conditionBuilder = new PreparedStatementConditionBuilder();
 +                if (!empty($userTrophyIDs)) {
 +                    $conditionBuilder->add('trophyID NOT IN (?)', [\array_unique($userTrophyIDs)]);
 +                }
 +                $conditionBuilder->add('userID = ?', [$userID]);
 +
 +                $sql = "DELETE FROM wcf" . WCF_N . "_user_special_trophy
 +                        " . $conditionBuilder;
 +                $statement = WCF::getDB()->prepareStatement($sql);
 +                $statement->execute($conditionBuilder->getParameters());
 +
 +                UserStorageHandler::getInstance()->reset([$userID], 'specialTrophies');
 +            }
 +
 +            $updateUserTrophies = [];
 +            foreach ($this->getObjects() as $object) {
 +                if (!$object->getTrophy()->isDisabled()) {
 +                    if (!isset($updateUserTrophies[$object->userID])) {
 +                        $updateUserTrophies[$object->userID] = 0;
 +                    }
 +                    $updateUserTrophies[$object->userID]--;
 +                }
 +            }
 +
 +            foreach ($updateUserTrophies as $userID => $count) {
 +                $userAction = new UserAction([$userID], 'update', [
 +                    'counters' => [
 +                        'trophyPoints' => $count,
 +                    ],
 +                ]);
 +                $userAction->executeAction();
 +            }
 +        }
 +
 +        return $returnValues;
 +    }
 +
 +    /**
 +     * Validates the getGroupedUserTrophyList method.
 +     */
 +    public function validateGetGroupedUserTrophyList()
 +    {
 +        if (!MODULE_TROPHY) {
 +            throw new IllegalLinkException();
 +        }
 +
 +        WCF::getSession()->checkPermissions(['user.profile.trophy.canSeeTrophies']);
 +
 +        $this->readInteger('pageNo');
 +        $this->readInteger('userID');
 +
 +        $this->userProfile = UserProfileRuntimeCache::getInstance()->getObject($this->parameters['userID']);
 +        if (!$this->userProfile) {
 +            throw new UserInputException('userID');
 +        }
 +        if (!$this->userProfile->isAccessible('canViewTrophies') && $this->userProfile->userID != WCF::getSession()->userID) {
 +            throw new PermissionDeniedException();
 +        }
++
++        if ($this->parameters['pageNo'] < 1) {
++            throw new UserInputException('pageNo');
++        }
 +    }
 +
 +    /**
 +     * Returns a viewable user trophy list for a specific user.
 +     */
 +    public function getGroupedUserTrophyList()
 +    {
 +        $userTrophyList = new UserTrophyList();
 +        $userTrophyList->getConditionBuilder()->add('userID = ?', [$this->parameters['userID']]);
 +        if (!empty($userTrophyList->sqlJoins)) {
 +            $userTrophyList->sqlJoins .= ' ';
 +        }
 +        if (!empty($userTrophyList->sqlConditionJoins)) {
 +            $userTrophyList->sqlConditionJoins .= ' ';
 +        }
 +        $userTrophyList->sqlJoins .= '
 +            LEFT JOIN   wcf' . WCF_N . '_trophy trophy
 +            ON          user_trophy.trophyID = trophy.trophyID';
 +        $userTrophyList->sqlConditionJoins .= '
 +            LEFT JOIN   wcf' . WCF_N . '_trophy trophy
 +            ON          user_trophy.trophyID = trophy.trophyID';
 +
 +        // trophy category join
 +        $userTrophyList->sqlJoins .= '
 +            LEFT JOIN   wcf' . WCF_N . '_category category
 +            ON          trophy.categoryID = category.categoryID';
 +        $userTrophyList->sqlConditionJoins .= '
 +            LEFT JOIN   wcf' . WCF_N . '_category category
 +            ON          trophy.categoryID = category.categoryID';
 +
 +        $userTrophyList->getConditionBuilder()->add('trophy.isDisabled = ?', [0]);
 +        $userTrophyList->getConditionBuilder()->add('category.isDisabled = ?', [0]);
 +        $userTrophyList->sqlLimit = 10;
 +        $userTrophyList->sqlOffset = ($this->parameters['pageNo'] - 1) * 10;
 +        $userTrophyList->sqlOrderBy = 'time DESC';
 +        $pageCount = \ceil($userTrophyList->countObjects() / 10);
 +        $userTrophyList->readObjects();
 +
 +        return [
 +            'pageCount' => $pageCount,
 +            'title' => WCF::getLanguage()->getDynamicVariable(
 +                'wcf.user.trophy.dialogTitle',
 +                ['username' => $this->userProfile->username]
 +            ),
 +            'template' => WCF::getTPL()->fetch('groupedUserTrophyList', 'wcf', [
 +                'userTrophyList' => $userTrophyList,
 +            ]),
 +        ];
 +    }
  }
index 8ab5bab8f659b543590335e0c3d1cdb25390d37d,513261f8f9b88bb40dd1e5fd127689488bc18826..34a2551ba115c3aa69f38b7b26d88fadcf09e8f9
@@@ -15,518 -11,427 +15,527 @@@ use wcf\system\WCF
  
  /**
   * Abstract implementation of a database access class using PDO.
 - * 
 - * @author    Marcel Werk
 - * @copyright 2001-2019 WoltLab GmbH
 - * @license   GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
 - * @package   WoltLabSuite\Core\System\Database
 + *
 + * @author  Marcel Werk
 + * @copyright   2001-2019 WoltLab GmbH
 + * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
 + * @package WoltLabSuite\Core\System\Database
   */
 -abstract class Database {
 -      /**
 -       * name of the class used for prepared statements
 -       * @var string
 -       */
 -      protected $preparedStatementClassName = PreparedStatement::class;
 -      
 -      /**
 -       * name of the database editor class
 -       * @var string
 -       */
 -      protected $editorClassName = DatabaseEditor::class;
 -      
 -      /**
 -       * sql server hostname
 -       * @var string
 -       */
 -      protected $host = '';
 -      
 -      /**
 -       * sql server post
 -       * @var integer
 -       */
 -      protected $port = 0;
 -      
 -      /**
 -       * sql server login name
 -       * @var string
 -       */
 -      protected $user = '';
 -      
 -      /**
 -       * sql server login password
 -       * @var string
 -       */
 -      protected $password = '';
 -      
 -      /**
 -       * database name
 -       * @var string
 -       */
 -      protected $database = '';
 -      
 -      /**
 -       * enables failsafe connection
 -       * @var boolean
 -       */
 -      protected $failsafeTest = false;
 -      
 -      /**
 -       * number of executed queries
 -       * @var integer
 -       */
 -      protected $queryCount = 0;
 -      
 -      /**
 -       * database editor object
 -       * @var DatabaseEditor
 -       */
 -      protected $editor = null;
 -      
 -      /**
 -       * pdo object
 -       * @var \PDO
 -       */
 -      protected $pdo = null;
 -      
 -      /**
 -       * amount of active transactions
 -       * @var integer
 -       */
 -      protected $activeTransactions = 0;
 -      
 -      /**
 -       * attempts to create the database after the connection has been established
 -       * @var boolean
 -       */
 -      protected $tryToCreateDatabase = false;
 -      
 -      /**
 -       * default driver options passed to the PDO constructor
 -       * @var array
 -       */
 -      protected $defaultDriverOptions = [];
 -      
 -      /**
 -       * Creates a Database Object.
 -       * 
 -       * @param       string          $host                   SQL database server host address
 -       * @param       string          $user                   SQL database server username
 -       * @param       string          $password               SQL database server password
 -       * @param       string          $database               SQL database server database name
 -       * @param       integer         $port                   SQL database server port
 -       * @param       boolean         $failsafeTest
 -       * @param       boolean         $tryToCreateDatabase
 -       * @param       array           $defaultDriverOptions
 -       */
 -      public function __construct($host, $user, $password, $database, $port, $failsafeTest = false, $tryToCreateDatabase = false, $defaultDriverOptions = []) {
 -              $this->host = $host;
 -              $this->port = $port;
 -              $this->user = $user;
 -              $this->password = $password;
 -              $this->database = $database;
 -              $this->failsafeTest = $failsafeTest;
 -              $this->tryToCreateDatabase = $tryToCreateDatabase;
 -              $this->defaultDriverOptions = $defaultDriverOptions;
 -              
 -              // connect database
 -              $this->connect();
 -      }
 -      
 -      public function enableDebugMode() {
 -              $this->preparedStatementClassName = DebugPreparedStatement::class;
 -      }
 -      
 -      /**
 -       * Connects to database server.
 -       */
 -      abstract public function connect();
 -      
 -      /**
 -       * Returns ID from last insert.
 -       * 
 -       * @param       string          $table
 -       * @param       string          $field
 -       * @return      integer
 -       * @throws      DatabaseException
 -       */
 -      public function getInsertID($table, $field) {
 -              try {
 -                      return $this->pdo->lastInsertId();
 -              }
 -              catch (\PDOException $e) {
 -                      throw new GenericDatabaseException("Cannot fetch last insert id", $e);
 -              }
 -      }
 -      
 -      /**
 -       * Initiates a transaction.
 -       * 
 -       * @return      boolean         true on success
 -       * @throws      DatabaseTransactionException
 -       */
 -      public function beginTransaction() {
 -              try {
 -                      if ($this->activeTransactions === 0) {
 -                              if (WCF::benchmarkIsEnabled()) Benchmark::getInstance()->start("BEGIN", Benchmark::TYPE_SQL_QUERY);
 -                              $result = $this->pdo->beginTransaction();
 -                      }
 -                      else {
 -                              if (WCF::benchmarkIsEnabled()) Benchmark::getInstance()->start("SAVEPOINT level".$this->activeTransactions, Benchmark::TYPE_SQL_QUERY);
 -                              $result = $this->pdo->exec("SAVEPOINT level".$this->activeTransactions) !== false;
 -                      }
 -                      if (WCF::benchmarkIsEnabled()) Benchmark::getInstance()->stop();
 -                      
 -                      $this->activeTransactions++;
 -                      
 -                      return $result;
 -              }
 -              catch (\PDOException $e) {
 -                      throw new DatabaseTransactionException("Could not begin transaction", $e);
 -              }
 -      }
 -      
 -      /**
 -       * Commits a transaction and returns true if the transaction was successful.
 -       * 
 -       * @return      boolean
 -       * @throws      DatabaseTransactionException
 -       */
 -      public function commitTransaction() {
 -              if ($this->activeTransactions === 0) return false;
 -              
 -              try {
 -                      $this->activeTransactions--;
 -                      
 -                      if ($this->activeTransactions === 0) {
 -                              if (WCF::benchmarkIsEnabled()) Benchmark::getInstance()->start("COMMIT", Benchmark::TYPE_SQL_QUERY);
 -                              $result = $this->pdo->commit();
 -                      }
 -                      else {
 -                              if (WCF::benchmarkIsEnabled()) Benchmark::getInstance()->start("RELEASE SAVEPOINT level".$this->activeTransactions, Benchmark::TYPE_SQL_QUERY);
 -                              $result = $this->pdo->exec("RELEASE SAVEPOINT level".$this->activeTransactions) !== false;
 -                      }
 -                      
 -                      if (WCF::benchmarkIsEnabled()) Benchmark::getInstance()->stop();
 -                      
 -                      return $result;
 -              }
 -              catch (\PDOException $e) {
 -                      throw new DatabaseTransactionException("Could not commit transaction", $e);
 -              }
 -      }
 -      
 -      /**
 -       * Rolls back a transaction and returns true if the rollback was successful.
 -       * 
 -       * @return      boolean
 -       * @throws      DatabaseTransactionException
 -       */
 -      public function rollBackTransaction() {
 -              if ($this->activeTransactions === 0) return false;
 -              
 -              try {
 -                      $this->activeTransactions--;
 -                      if ($this->activeTransactions === 0) {
 -                              if (WCF::benchmarkIsEnabled()) Benchmark::getInstance()->start("ROLLBACK", Benchmark::TYPE_SQL_QUERY);
 -                              $result = $this->pdo->rollBack();
 -                      }
 -                      else {
 -                              if (WCF::benchmarkIsEnabled()) Benchmark::getInstance()->start("ROLLBACK TO SAVEPOINT level".$this->activeTransactions, Benchmark::TYPE_SQL_QUERY);
 -                              $result = $this->pdo->exec("ROLLBACK TO SAVEPOINT level".$this->activeTransactions) !== false;
 -                      }
 -                      
 -                      if (WCF::benchmarkIsEnabled()) Benchmark::getInstance()->stop();
 -                      
 -                      return $result;
 -              }
 -              catch (\PDOException $e) {
 -                      throw new DatabaseTransactionException("Could not roll back transaction", $e);
 -              }
 -      }
 -      
 -      /**
 -       * Prepares a statement for execution and returns a statement object.
 -       * 
 -       * @param       string                  $statement
 -       * @param       integer                 $limit
 -       * @param       integer                 $offset
 -       * @return      PreparedStatement
 -       * @throws      DatabaseQueryException
 -       */
 -      public function prepareStatement($statement, $limit = 0, $offset = 0) {
 -              $statement = $this->handleLimitParameter($statement, $limit, $offset);
 -              
 -              try {
 -                      // Append routing information of the current request as a comment.
 -                      // This allows the system administrator to find offending requests
 -                      // in MySQL's slow query log and / or MySQL's process list.
 -                      // Note: This is meant to be run unconditionally in production to be
 -                      //       useful. Thus the code to retrieve the request information
 -                      //       must be absolutely lightweight.
 -                      static $requestInformation = null;
 -                      if ($requestInformation === null) {
 -                              $requestInformation = '';
 -                              if (defined('ENABLE_PRODUCTION_DEBUG_MODE') && ENABLE_PRODUCTION_DEBUG_MODE && isset($_SERVER['REQUEST_URI'])) {
 -                                      $requestInformation = $_SERVER['REQUEST_URI'];
 -                                      if ($requestId = \wcf\getRequestId()) {
 -                                              $requestInformation = substr($requestInformation, 0, 70);
 -                                              $requestInformation .= ' ('.$requestId.')';
 -                                      }
 -                                      if (isset($_REQUEST['className']) && isset($_REQUEST['actionName'])) {
 -                                              $requestInformation = substr($requestInformation, 0, 90);
 -                                              $requestInformation .= ' ('.$_REQUEST['className'].':'.$_REQUEST['actionName'].')';
 -                                      }
 -                                      $requestInformation = substr($requestInformation, 0, 180);
 -                              }
 -                      }
 -                      
 -                      $pdoStatement = $this->pdo->prepare($statement.($requestInformation ? " -- ".$this->pdo->quote($requestInformation) : ''));
 -                      
 -                      return new $this->preparedStatementClassName($this, $pdoStatement, $statement);
 -              }
 -              catch (\PDOException $e) {
 -                      throw new DatabaseQueryException("Could not prepare statement '".$statement."'", $e);
 -              }
 -      }
 -      
 -      /**
 -       * Handles the limit and offset parameter in SELECT queries.
 -       * This is a default implementation compatible to MySQL and PostgreSQL.
 -       * Other database implementations should override this function. 
 -       * 
 -       * @param       string          $query
 -       * @param       integer         $limit
 -       * @param       integer         $offset
 -       * @return      string
 -       */
 -      public function handleLimitParameter($query, $limit = 0, $offset = 0) {
 -              $limit = \intval($limit);
 -              $offset = \intval($offset);
 -              if ($limit < 0) {
 -                      throw new \InvalidArgumentException('The limit must not be negative.');
 -              }
 -              if ($offset < 0) {
 -                      throw new \InvalidArgumentException('The offset must not be negative.');
 -              }
 -
 -              if ($limit != 0) {
 -                      $query = preg_replace('~(\s+FOR\s+UPDATE\s*)?$~', " LIMIT " . $limit . ($offset ? " OFFSET " . $offset : '') . "\\0", $query, 1);
 -              }
 -              
 -              return $query;
 -      }
 -      
 -      /**
 -       * Returns the number of the last error.
 -       * 
 -       * @return      integer
 -       */
 -      public function getErrorNumber() {
 -              if ($this->pdo !== null) return $this->pdo->errorCode();
 -              return 0;
 -      }
 -      
 -      /**
 -       * Returns the description of the last error.
 -       * 
 -       * @return      string
 -       */
 -      public function getErrorDesc() {
 -              if ($this->pdo !== null) {
 -                      $errorInfoArray = $this->pdo->errorInfo();
 -                      if (isset($errorInfoArray[2])) return $errorInfoArray[2];
 -              }
 -              return '';
 -      }
 -      
 -      /**
 -       * Returns the current database type.
 -       * 
 -       * @return      string
 -       */
 -      public function getDBType() {
 -              return get_class($this);
 -      }
 -      
 -      /**
 -       * Escapes a string for use in sql query.
 -       * 
 -       * @param       string          $string
 -       * @return      string
 -       */
 -      public function escapeString($string) {
 -              return addslashes($string);
 -      }
 -      
 -      /**
 -       * Returns the sql version.
 -       * 
 -       * @return      string
 -       */
 -      public function getVersion() {
 -              try {
 -                      if ($this->pdo !== null) {
 -                              return $this->pdo->getAttribute(\PDO::ATTR_SERVER_VERSION);
 -                      }
 -              }
 -              catch (\PDOException $e) {}
 -              
 -              return 'unknown';
 -      }
 -      
 -      /**
 -       * Returns the database name.
 -       * 
 -       * @return      string
 -       */
 -      public function getDatabaseName() {
 -              return $this->database;
 -      }
 -      
 -      /**
 -       * Returns the name of the database user.
 -       * 
 -       * @return      string          user name
 -       */
 -      public function getUser() {
 -              return $this->user;
 -      }
 -      
 -      /**
 -       * Returns the amount of executed sql queries.
 -       * 
 -       * @return      integer
 -       */
 -      public function getQueryCount() {
 -              return $this->queryCount;
 -      }
 -      
 -      /**
 -       * Increments the query counter by one.
 -       */
 -      public function incrementQueryCount() {
 -              $this->queryCount++;
 -      }
 -      
 -      /**
 -       * Returns a database editor object.
 -       * 
 -       * @return      DatabaseEditor
 -       */
 -      public function getEditor() {
 -              if ($this->editor === null) {
 -                      $this->editor = new $this->editorClassName($this);
 -              }
 -              
 -              return $this->editor;
 -      }
 -      
 -      /**
 -       * Returns true if this database type is supported.
 -       * 
 -       * @return      boolean
 -       */
 -      public static function isSupported() {
 -              return false;
 -      }
 -      
 -      /**
 -       * Sets default connection attributes.
 -       */
 -      protected function setAttributes() {
 -              $this->pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
 -              $this->pdo->setAttribute(\PDO::ATTR_CASE, \PDO::CASE_NATURAL);
 -              $this->pdo->setAttribute(\PDO::ATTR_STRINGIFY_FETCHES, false);
 -      }
 +abstract class Database
 +{
 +    /**
 +     * name of the class used for prepared statements
 +     * @var string
 +     */
 +    protected $preparedStatementClassName = PreparedStatement::class;
 +
 +    /**
 +     * name of the database editor class
 +     * @var string
 +     */
 +    protected $editorClassName = DatabaseEditor::class;
 +
 +    /**
 +     * sql server hostname
 +     * @var string
 +     */
 +    protected $host = '';
 +
 +    /**
 +     * sql server post
 +     * @var int
 +     */
 +    protected $port = 0;
 +
 +    /**
 +     * sql server login name
 +     * @var string
 +     */
 +    protected $user = '';
 +
 +    /**
 +     * sql server login password
 +     * @var string
 +     */
 +    protected $password = '';
 +
 +    /**
 +     * database name
 +     * @var string
 +     */
 +    protected $database = '';
 +
 +    /**
 +     * enables failsafe connection
 +     * @var bool
 +     */
 +    protected $failsafeTest = false;
 +
 +    /**
 +     * number of executed queries
 +     * @var int
 +     */
 +    protected $queryCount = 0;
 +
 +    /**
 +     * database editor object
 +     * @var DatabaseEditor
 +     */
 +    protected $editor;
 +
 +    /**
 +     * pdo object
 +     * @var \PDO
 +     */
 +    protected $pdo;
 +
 +    /**
 +     * amount of active transactions
 +     * @var int
 +     */
 +    protected $activeTransactions = 0;
 +
 +    /**
 +     * attempts to create the database after the connection has been established
 +     * @var bool
 +     */
 +    protected $tryToCreateDatabase = false;
 +
 +    /**
 +     * default driver options passed to the PDO constructor
 +     * @var array
 +     */
 +    protected $defaultDriverOptions = [];
 +
 +    /**
 +     * Creates a Database Object.
 +     *
 +     * @param string $host SQL database server host address
 +     * @param string $user SQL database server username
 +     * @param string $password SQL database server password
 +     * @param string $database SQL database server database name
 +     * @param int $port SQL database server port
 +     * @param bool $failsafeTest
 +     * @param bool $tryToCreateDatabase
 +     * @param array $defaultDriverOptions
 +     */
 +    public function __construct(
 +        $host,
 +        $user,
 +        $password,
 +        $database,
 +        $port,
 +        $failsafeTest = false,
 +        $tryToCreateDatabase = false,
 +        $defaultDriverOptions = []
 +    ) {
 +        $this->host = $host;
 +        $this->port = $port;
 +        $this->user = $user;
 +        $this->password = $password;
 +        $this->database = $database;
 +        $this->failsafeTest = $failsafeTest;
 +        $this->tryToCreateDatabase = $tryToCreateDatabase;
 +        $this->defaultDriverOptions = $defaultDriverOptions;
 +
 +        // connect database
 +        $this->connect();
 +    }
 +
 +    public function enableDebugMode()
 +    {
 +        $this->preparedStatementClassName = DebugPreparedStatement::class;
 +    }
 +
 +    /**
 +     * Connects to database server.
 +     */
 +    abstract public function connect();
 +
 +    /**
 +     * Returns ID from last insert.
 +     *
 +     * @param string $table
 +     * @param string $field
 +     * @return  int
 +     * @throws  DatabaseException
 +     */
 +    public function getInsertID($table, $field)
 +    {
 +        try {
 +            return $this->pdo->lastInsertId();
 +        } catch (\PDOException $e) {
 +            throw new GenericDatabaseException("Cannot fetch last insert id", $e);
 +        }
 +    }
 +
 +    /**
 +     * Initiates a transaction.
 +     *
 +     * @return  bool        true on success
 +     * @throws  DatabaseTransactionException
 +     */
 +    public function beginTransaction()
 +    {
 +        try {
 +            if ($this->activeTransactions === 0) {
 +                if (WCF::benchmarkIsEnabled()) {
 +                    Benchmark::getInstance()->start("BEGIN", Benchmark::TYPE_SQL_QUERY);
 +                }
 +                $result = $this->pdo->beginTransaction();
 +            } else {
 +                if (WCF::benchmarkIsEnabled()) {
 +                    Benchmark::getInstance()->start(
 +                        "SAVEPOINT level" . $this->activeTransactions,
 +                        Benchmark::TYPE_SQL_QUERY
 +                    );
 +                }
 +                $result = $this->pdo->exec("SAVEPOINT level" . $this->activeTransactions) !== false;
 +            }
 +            if (WCF::benchmarkIsEnabled()) {
 +                Benchmark::getInstance()->stop();
 +            }
 +
 +            $this->activeTransactions++;
 +
 +            return $result;
 +        } catch (\PDOException $e) {
 +            throw new DatabaseTransactionException("Could not begin transaction", $e);
 +        }
 +    }
 +
 +    /**
 +     * Commits a transaction and returns true if the transaction was successful.
 +     *
 +     * @return  bool
 +     * @throws  DatabaseTransactionException
 +     */
 +    public function commitTransaction()
 +    {
 +        if ($this->activeTransactions === 0) {
 +            return false;
 +        }
 +
 +        try {
 +            $this->activeTransactions--;
 +
 +            if ($this->activeTransactions === 0) {
 +                if (WCF::benchmarkIsEnabled()) {
 +                    Benchmark::getInstance()->start("COMMIT", Benchmark::TYPE_SQL_QUERY);
 +                }
 +                $result = $this->pdo->commit();
 +            } else {
 +                if (WCF::benchmarkIsEnabled()) {
 +                    Benchmark::getInstance()->start(
 +                        "RELEASE SAVEPOINT level" . $this->activeTransactions,
 +                        Benchmark::TYPE_SQL_QUERY
 +                    );
 +                }
 +                $result = $this->pdo->exec("RELEASE SAVEPOINT level" . $this->activeTransactions) !== false;
 +            }
 +
 +            if (WCF::benchmarkIsEnabled()) {
 +                Benchmark::getInstance()->stop();
 +            }
 +
 +            return $result;
 +        } catch (\PDOException $e) {
 +            throw new DatabaseTransactionException("Could not commit transaction", $e);
 +        }
 +    }
 +
 +    /**
 +     * Rolls back a transaction and returns true if the rollback was successful.
 +     *
 +     * @return  bool
 +     * @throws  DatabaseTransactionException
 +     */
 +    public function rollBackTransaction()
 +    {
 +        if ($this->activeTransactions === 0) {
 +            return false;
 +        }
 +
 +        try {
 +            $this->activeTransactions--;
 +            if ($this->activeTransactions === 0) {
 +                if (WCF::benchmarkIsEnabled()) {
 +                    Benchmark::getInstance()->start("ROLLBACK", Benchmark::TYPE_SQL_QUERY);
 +                }
 +                $result = $this->pdo->rollBack();
 +            } else {
 +                if (WCF::benchmarkIsEnabled()) {
 +                    Benchmark::getInstance()->start(
 +                        "ROLLBACK TO SAVEPOINT level" . $this->activeTransactions,
 +                        Benchmark::TYPE_SQL_QUERY
 +                    );
 +                }
 +                $result = $this->pdo->exec("ROLLBACK TO SAVEPOINT level" . $this->activeTransactions) !== false;
 +            }
 +
 +            if (WCF::benchmarkIsEnabled()) {
 +                Benchmark::getInstance()->stop();
 +            }
 +
 +            return $result;
 +        } catch (\PDOException $e) {
 +            throw new DatabaseTransactionException("Could not roll back transaction", $e);
 +        }
 +    }
 +
 +    /**
 +     * Prepares a statement for execution and returns a statement object.
 +     *
 +     * @param string $statement
 +     * @param int $limit
 +     * @param int $offset
 +     * @return  PreparedStatement
 +     * @throws  DatabaseQueryException
 +     */
 +    public function prepareStatement($statement, $limit = 0, $offset = 0)
 +    {
 +        $statement = $this->handleLimitParameter($statement, $limit, $offset);
 +
 +        try {
 +            // Append routing information of the current request as a comment.
 +            // This allows the system administrator to find offending requests
 +            // in MySQL's slow query log and / or MySQL's process list.
 +            // Note: This is meant to be run unconditionally in production to be
 +            //       useful. Thus the code to retrieve the request information
 +            //       must be absolutely lightweight.
 +            static $requestInformation = null;
 +            if ($requestInformation === null) {
 +                $requestInformation = '';
 +                if (
 +                    \defined('ENABLE_PRODUCTION_DEBUG_MODE')
 +                    && ENABLE_PRODUCTION_DEBUG_MODE
 +                    && isset($_SERVER['REQUEST_URI'])
 +                ) {
 +                    $requestInformation = $_SERVER['REQUEST_URI'];
 +                    if ($requestId = \wcf\getRequestId()) {
 +                        $requestInformation = \substr($requestInformation, 0, 70);
 +                        $requestInformation .= ' (' . $requestId . ')';
 +                    }
 +                    if (isset($_REQUEST['className']) && isset($_REQUEST['actionName'])) {
 +                        $requestInformation = \substr($requestInformation, 0, 90);
 +                        $requestInformation .= ' (' . $_REQUEST['className'] . ':' . $_REQUEST['actionName'] . ')';
 +                    }
 +                    $requestInformation = \substr($requestInformation, 0, 180);
 +                }
 +            }
 +
 +            $pdoStatement = $this->pdo->prepare(
 +                $statement . ($requestInformation ? " -- " . $this->pdo->quote($requestInformation) : '')
 +            );
 +
 +            return new $this->preparedStatementClassName($this, $pdoStatement, $statement);
 +        } catch (\PDOException $e) {
 +            throw new DatabaseQueryException("Could not prepare statement '" . $statement . "'", $e);
 +        }
 +    }
 +
 +    /**
 +     * Prepares a statement for execution and returns a statement object.
 +     *
 +     * In contrast to `prepareStatement()`, for all installed apps, `app1_` is replaced with
 +     * `app{WCF_N}_`.
 +     *
 +     * @since   5.4
 +     */
 +    public function prepare(string $statement, int $limit = 0, int $offset = 0): PreparedStatement
 +    {
 +        static $regex = null;
 +        if ($regex === null) {
 +            $abbreviations = \implode(
 +                '|',
 +                \array_map(static function (Application $app): string {
 +                    return \preg_quote($app->getAbbreviation(), '~');
 +                }, ApplicationHandler::getInstance()->getApplications())
 +            );
 +
 +            $regex = "~(\\b(?:{$abbreviations}))1_~";
 +        }
 +
 +        $statement = \preg_replace(
 +            $regex,
 +            '${1}' . WCF_N . '_',
 +            $statement
 +        );
 +
 +        return $this->prepareStatement($statement, $limit, $offset);
 +    }
 +
 +    /**
 +     * Handles the limit and offset parameter in SELECT queries.
 +     * This is a default implementation compatible to MySQL and PostgreSQL.
 +     * Other database implementations should override this function.
 +     *
 +     * @param string $query
 +     * @param int $limit
 +     * @param int $offset
 +     * @return  string
 +     */
 +    public function handleLimitParameter($query, $limit = 0, $offset = 0)
 +    {
++        $limit = \intval($limit);
++        $offset = \intval($offset);
++        if ($limit < 0) {
++            throw new \InvalidArgumentException('The limit must not be negative.');
++        }
++        if ($offset < 0) {
++            throw new \InvalidArgumentException('The offset must not be negative.');
++        }
++
 +        if ($limit != 0) {
 +            $query = \preg_replace(
 +                '~(\s+FOR\s+UPDATE\s*)?$~',
 +                " LIMIT " . $limit . ($offset ? " OFFSET " . $offset : '') . "\\0",
 +                $query,
 +                1
 +            );
 +        }
 +
 +        return $query;
 +    }
 +
 +    /**
 +     * Returns the number of the last error.
 +     *
 +     * @return  int
 +     */
 +    public function getErrorNumber()
 +    {
 +        if ($this->pdo !== null) {
 +            return $this->pdo->errorCode();
 +        }
 +
 +        return 0;
 +    }
 +
 +    /**
 +     * Returns the description of the last error.
 +     *
 +     * @return  string
 +     */
 +    public function getErrorDesc()
 +    {
 +        if ($this->pdo !== null) {
 +            $errorInfoArray = $this->pdo->errorInfo();
 +            if (isset($errorInfoArray[2])) {
 +                return $errorInfoArray[2];
 +            }
 +        }
 +
 +        return '';
 +    }
 +
 +    /**
 +     * Returns the current database type.
 +     *
 +     * @return  string
 +     */
 +    public function getDBType()
 +    {
 +        return \get_class($this);
 +    }
 +
 +    /**
 +     * Escapes a string for use in sql query.
 +     *
 +     * @param string $string
 +     * @return  string
 +     */
 +    public function escapeString($string)
 +    {
 +        return \addslashes($string);
 +    }
 +
 +    /**
 +     * Returns the sql version.
 +     *
 +     * @return  string
 +     */
 +    public function getVersion()
 +    {
 +        try {
 +            if ($this->pdo !== null) {
 +                return $this->pdo->getAttribute(\PDO::ATTR_SERVER_VERSION);
 +            }
 +        } catch (\PDOException $e) {
 +        }
 +
 +        return 'unknown';
 +    }
 +
 +    /**
 +     * Returns the database name.
 +     *
 +     * @return  string
 +     */
 +    public function getDatabaseName()
 +    {
 +        return $this->database;
 +    }
 +
 +    /**
 +     * Returns the name of the database user.
 +     *
 +     * @return  string      user name
 +     */
 +    public function getUser()
 +    {
 +        return $this->user;
 +    }
 +
 +    /**
 +     * Returns the amount of executed sql queries.
 +     *
 +     * @return  int
 +     */
 +    public function getQueryCount()
 +    {
 +        return $this->queryCount;
 +    }
 +
 +    /**
 +     * Increments the query counter by one.
 +     */
 +    public function incrementQueryCount()
 +    {
 +        $this->queryCount++;
 +    }
 +
 +    /**
 +     * Returns a database editor object.
 +     *
 +     * @return  DatabaseEditor
 +     */
 +    public function getEditor()
 +    {
 +        if ($this->editor === null) {
 +            $this->editor = new $this->editorClassName($this);
 +        }
 +
 +        return $this->editor;
 +    }
 +
 +    /**
 +     * Returns true if this database type is supported.
 +     *
 +     * @return  bool
 +     */
 +    public static function isSupported()
 +    {
 +        return false;
 +    }
 +
 +    /**
 +     * Sets default connection attributes.
 +     */
 +    protected function setAttributes()
 +    {
 +        $this->pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
 +        $this->pdo->setAttribute(\PDO::ATTR_CASE, \PDO::CASE_NATURAL);
 +        $this->pdo->setAttribute(\PDO::ATTR_STRINGIFY_FETCHES, false);
 +    }
  }