owncloud/core

View on GitHub
lib/private/Repair/RepairUnmergedShares.php

Summary

Maintainability
B
4 hrs
Test Coverage
<?php
/**
 * @author Jörn Friedrich Dreyer <jfd@butonic.de>
 * @author Vincent Petry <pvince81@owncloud.com>
 *
 * @copyright Copyright (c) 2018, ownCloud GmbH
 * @license AGPL-3.0
 *
 * This code is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License, version 3,
 * as published by the Free Software Foundation.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
 * GNU Affero General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public License, version 3,
 * along with this program.  If not, see <http://www.gnu.org/licenses/>
 *
 */

namespace OC\Repair;

use OCP\Migration\IOutput;
use OCP\Migration\IRepairStep;
use OC\Share\Constants;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IConfig;
use OCP\IDBConnection;
use OCP\IUserManager;
use OCP\IUser;
use OCP\IGroupManager;
use OC\Share20\DefaultShareProvider;

/**
 * Repairs shares for which the received folder was not properly deduplicated.
 *
 * An unmerged share can for example happen when sharing a folder with the same
 * user through multiple ways, like several groups and also directly, additionally
 * to group shares. Since 9.0.0 these would create duplicate entries "folder (2)",
 * one for every share. This repair step rearranges them so they only appear as a single
 * folder.
 */
class RepairUnmergedShares implements IRepairStep {
    /** @var \OCP\IConfig */
    protected $config;

    /** @var \OCP\IDBConnection */
    protected $connection;

    /** @var IUserManager */
    protected $userManager;

    /** @var IGroupManager */
    protected $groupManager;

    /** @var IQueryBuilder */
    private $queryGetSharesWithUsers;

    /** @var IQueryBuilder */
    private $queryUpdateSharePermissionsAndTarget;

    /** @var IQueryBuilder */
    private $queryUpdateShareInBatch;

    /**
     * @param \OCP\IConfig $config
     * @param \OCP\IDBConnection $connection
     */
    public function __construct(
        IConfig $config,
        IDBConnection $connection,
        IUserManager $userManager,
        IGroupManager $groupManager
    ) {
        $this->connection = $connection;
        $this->config = $config;
        $this->userManager = $userManager;
        $this->groupManager = $groupManager;
    }

    public function getName() {
        return 'Repair unmerged shares';
    }

    /**
     * Builds prepared queries for reuse
     */
    private function buildPreparedQueries() {
        /**
         * Retrieve shares for a given user/group and share type
         */
        $query = $this->connection->getQueryBuilder();
        $query
            ->select('item_source', 'id', 'file_target', 'permissions', 'parent', 'share_type', 'stime')
            ->from('share')
            ->where($query->expr()->eq('share_type', $query->createParameter('shareType')))
            ->andWhere($query->expr()->in('share_with', $query->createParameter('shareWiths')))
            ->andWhere($query->expr()->in('item_type', $query->createParameter('itemTypes')))
            ->orderBy('item_source', 'ASC')
            ->addOrderBy('stime', 'ASC');

        $this->queryGetSharesWithUsers = $query;

        /**
         * Updates the file_target to the given value for all given share ids.
         *
         * This updates several shares in bulk which is faster than individually.
         */
        $query = $this->connection->getQueryBuilder();
        $query->update('share')
            ->set('file_target', $query->createParameter('file_target'))
            ->where($query->expr()->in('id', $query->createParameter('ids')));

        $this->queryUpdateShareInBatch = $query;

        /**
         * Updates the share permissions and target path of a single share.
         */
        $query = $this->connection->getQueryBuilder();
        $query->update('share')
            ->set('permissions', $query->createParameter('permissions'))
            ->set('file_target', $query->createParameter('file_target'))
            ->where($query->expr()->eq('id', $query->createParameter('shareid')));

        $this->queryUpdateSharePermissionsAndTarget = $query;
    }

    private function getSharesWithUser($shareType, $shareWiths) {
        $groupedShares = [];

        $query = $this->queryGetSharesWithUsers;
        $query->setParameter('shareWiths', $shareWiths, IQueryBuilder::PARAM_STR_ARRAY);
        $query->setParameter('shareType', $shareType);
        $query->setParameter('itemTypes', ['file', 'folder'], IQueryBuilder::PARAM_STR_ARRAY);

        $shares = $query->execute()->fetchAll();

        // group by item_source
        foreach ($shares as $share) {
            if (!isset($groupedShares[$share['item_source']])) {
                $groupedShares[$share['item_source']] = [];
            }
            $groupedShares[$share['item_source']][] = $share;
        }
        return $groupedShares;
    }

    private function isPotentialDuplicateName($name) {
        return (\preg_match('/\(\d+\)(\.[^\.]+)?$/', $name) === 1);
    }

    /**
     * Decide on the best target name based on all group shares and subshares,
     * goal is to increase the likeliness that the chosen name matches what
     * the user is expecting.
     *
     * For this, we discard the entries with parenthesis "(2)".
     * In case the user also renamed the duplicates to a legitimate name, this logic
     * will still pick the most recent one as it's the one the user is most likely to
     * remember renaming.
     *
     * If no suitable subshare is found, use the least recent group share instead.
     *
     * @param array $groupShares group share entries
     * @param array $subShares sub share entries
     *
     * @return string chosen target name
     */
    private function findBestTargetName($groupShares, $subShares) {
        $pickedShare = null;
        // sort by stime, this also properly sorts the direct user share if any
        @\usort($subShares, function ($a, $b) {
            return ((int)$a['stime'] - (int)$b['stime']);
        });

        foreach ($subShares as $subShare) {
            // skip entries that have parenthesis with numbers
            if ($this->isPotentialDuplicateName($subShare['file_target'])) {
                continue;
            }
            // pick any share found that would match, the last being the most recent
            $pickedShare = $subShare;
        }

        // no suitable subshare found
        if ($pickedShare === null) {
            // use least recent group share target instead
            $pickedShare = $groupShares[0];
        }

        return $pickedShare['file_target'];
    }

    /**
     * Fix the given received share represented by the set of group shares
     * and matching sub shares
     *
     * @param array $groupShares group share entries
     * @param array $subShares sub share entries
     *
     * @return boolean false if the share was not repaired, true if it was
     */
    private function fixThisShare($groupShares, $subShares) {
        if (empty($subShares)) {
            return false;
        }

        $groupSharesById = [];
        foreach ($groupShares as $groupShare) {
            $groupSharesById[$groupShare['id']] = $groupShare;
        }

        if ($this->isThisShareValid($groupSharesById, $subShares)) {
            return false;
        }

        $targetPath = $this->findBestTargetName($groupShares, $subShares);

        // check whether the user opted out completely of all subshares
        $optedOut = true;
        foreach ($subShares as $subShare) {
            if ((int)$subShare['permissions'] !== 0) {
                $optedOut = false;
                break;
            }
        }

        $shareIds = [];
        foreach ($subShares as $subShare) {
            // only if the user deleted some subshares but not all, adjust the permissions of that subshare
            if (!$optedOut && (int)$subShare['permissions'] === 0 && (int)$subShare['share_type'] === DefaultShareProvider::SHARE_TYPE_USERGROUP) {
                // set permissions from parent group share
                $permissions = $groupSharesById[$subShare['parent']]['permissions'];

                // fix permissions and target directly
                $query = $this->queryUpdateSharePermissionsAndTarget;
                $query->setParameter('shareid', $subShare['id']);
                $query->setParameter('file_target', $targetPath);
                $query->setParameter('permissions', $permissions);
                $query->execute();
            } else {
                // gather share ids for bulk target update
                if ($subShare['file_target'] !== $targetPath) {
                    $shareIds[] = (int)$subShare['id'];
                }
            }
        }

        if (!empty($shareIds)) {
            $query = $this->queryUpdateShareInBatch;
            $query->setParameter('ids', $shareIds, IQueryBuilder::PARAM_INT_ARRAY);
            $query->setParameter('file_target', $targetPath);
            $query->execute();
        }

        return true;
    }

    /**
     * Checks whether the number of group shares is balanced with the child subshares.
     * If all group shares have exactly one subshare, and the target of every subshare
     * is the same, then the share is valid.
     * If however there is a group share entry that has no matching subshare, it means
     * we're in the bogus situation and the whole share must be repaired
     *
     * @param array $groupSharesById
     * @param array $subShares
     *
     * @return true if the share is valid, false if it needs repair
     */
    private function isThisShareValid($groupSharesById, $subShares) {
        $foundTargets = [];

        // every group share needs to have exactly one matching subshare
        foreach ($subShares as $subShare) {
            $foundTargets[$subShare['file_target']] = true;
            if (\count($foundTargets) > 1) {
                // not all the same target path value => invalid
                return false;
            }
            if (isset($groupSharesById[$subShare['parent']])) {
                // remove it from the list as we found it
                unset($groupSharesById[$subShare['parent']]);
            }
        }

        // if we found one subshare per group entry, the set will be empty.
        // If not empty, it means that one of the group shares did not have
        // a matching subshare entry.
        return empty($groupSharesById);
    }

    /**
     * Detect unmerged received shares and merge them properly
     */
    private function fixUnmergedShares(IOutput $out, IUser $user) {
        $groups = $this->groupManager->getUserGroupIds($user);
        if (empty($groups)) {
            // user is in no groups, so can't have received group shares
            return;
        }

        // get all subshares grouped by item source
        $subSharesByItemSource = $this->getSharesWithUser(DefaultShareProvider::SHARE_TYPE_USERGROUP, [$user->getUID()]);

        // because sometimes one wants to give the user more permissions than the group share
        $userSharesByItemSource = $this->getSharesWithUser(Constants::SHARE_TYPE_USER, [$user->getUID()]);

        if (empty($subSharesByItemSource) && empty($userSharesByItemSource)) {
            // nothing to repair for this user, no need to do extra queries
            return;
        }

        $groupSharesByItemSource = $this->getSharesWithUser(Constants::SHARE_TYPE_GROUP, $groups);
        if (empty($groupSharesByItemSource) && empty($userSharesByItemSource)) {
            // nothing to repair for this user
            return;
        }

        foreach ($groupSharesByItemSource as $itemSource => $groupShares) {
            $subShares = [];
            if (isset($subSharesByItemSource[$itemSource])) {
                $subShares = $subSharesByItemSource[$itemSource];
            }

            if (isset($userSharesByItemSource[$itemSource])) {
                // add it to the subshares to get a similar treatment
                $subShares = \array_merge($subShares, $userSharesByItemSource[$itemSource]);
            }

            $this->fixThisShare($groupShares, $subShares);
        }
    }

    /**
     * Count all the users
     *
     * @return int
     */
    private function countUsers() {
        $allCount = $this->userManager->countUsers();

        $totalCount = 0;
        foreach ($allCount as $backend => $count) {
            $totalCount += $count;
        }

        return $totalCount;
    }

    public function run(IOutput $output) {
        $ocVersionFromBeforeUpdate = $this->config->getSystemValue('version', '0.0.0');
        // this situation was only possible between 9.0.0 and 9.0.3 included, and 9.1.0
        if (
            \version_compare($ocVersionFromBeforeUpdate, '9.1.0', '>=')
            && \version_compare($ocVersionFromBeforeUpdate, '9.1.0.16', '<')
        ) {
            $function = function (IUser $user) use ($output) {
                $this->fixUnmergedShares($output, $user);
                $output->advance();
            };

            $this->buildPreparedQueries();

            $output->startProgress($this->countUsers());

            $this->userManager->callForAllUsers($function);

            $output->finishProgress();
        }
    }
}