owncloud/core

View on GitHub
apps/files_sharing/lib/External/Manager.php

Summary

Maintainability
D
2 days
Test Coverage
<?php
/**
 * @author Björn Schießle <bjoern@schiessle.org>
 * @author Joas Schilling <coding@schilljs.com>
 * @author Jörn Friedrich Dreyer <jfd@butonic.de>
 * @author Lukas Reschke <lukas@statuscode.ch>
 * @author Morris Jobke <hey@morrisjobke.de>
 * @author Robin Appelman <icewind@owncloud.com>
 * @author Roeland Jago Douma <rullzer@owncloud.com>
 * @author Stefan Weil <sw@weilnetz.de>
 * @author Thomas Müller <thomas.mueller@tmit.eu>
 *
 * @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 OCA\Files_Sharing\External;

use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use GuzzleHttp\Exception\ConnectException;
use OC\Files\Filesystem;
use OC\User\NoUserException;
use OCA\Files_Sharing\Helper;
use OCP\Files;
use OCP\Http\Client\IClientService;
use OCP\Notification\IManager;
use OCP\Share\Events\AcceptShare;
use OCP\Share\Events\DeclineShare;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\EventDispatcher\GenericEvent;

class Manager {
    public const STORAGE = '\OCA\Files_Sharing\External\Storage';

    /**
     * @var string
     */
    private $uid;

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

    /**
     * @var \OC\Files\Mount\Manager
     */
    private $mountManager;

    /**
     * @var \OCP\Files\Storage\IStorageFactory
     */
    private $storageLoader;

    /**
     * @var IManager
     */
    private $notificationManager;

    /**
     * @var EventDispatcherInterface
     */
    private $eventDispatcher;

    /**
     * @param \OCP\IDBConnection $connection
     * @param \OC\Files\Mount\Manager $mountManager
     * @param \OCP\Files\Storage\IStorageFactory $storageLoader
     * @param IManager $notificationManager
     * @param EventDispatcherInterface $eventDispatcher
     * @param string $uid
     */
    public function __construct(
        \OCP\IDBConnection                 $connection,
        \OC\Files\Mount\Manager            $mountManager,
        \OCP\Files\Storage\IStorageFactory $storageLoader,
        IManager                           $notificationManager,
        EventDispatcherInterface           $eventDispatcher,
        $uid
    ) {
        $this->connection = $connection;
        $this->mountManager = $mountManager;
        $this->storageLoader = $storageLoader;
        $this->uid = $uid;
        $this->notificationManager = $notificationManager;
        $this->eventDispatcher = $eventDispatcher;
    }

    /**
     * add new server-to-server share
     *
     * @param string $remote
     * @param string $token
     * @param string $password
     * @param string $name
     * @param string $owner
     * @param boolean $accepted
     * @param string $user
     * @param string $remoteId
     * @return Mount|null
     */
    public function addShare($remote, $token, $password, $name, $owner, $accepted = false, $user = null, $remoteId = -1) {
        $user = $user ? $user : $this->uid;
        $accepted = $accepted ? 1 : 0;
        $name = Filesystem::normalizePath('/' . $name);

        if (!$accepted) {
            // To avoid conflicts with the mount point generation later,
            // we only use a temporary mount point name here. The real
            // mount point name will be generated when accepting the share,
            // using the original share item name.
            $tmpMountPointName = '{{TemporaryMountPointName#' . $name . '}}';
            $mountPoint = $tmpMountPointName;
            $hash = \md5($tmpMountPointName);
            $data = [
                'remote' => $remote,
                'share_token' => $token,
                'password' => $password,
                'name' => $name,
                'owner' => $owner,
                'user' => $user,
                'mountpoint' => $mountPoint,
                'mountpoint_hash' => $hash,
                'accepted' => $accepted,
                'remote_id' => $remoteId,
            ];

            $i = 1;
            while (!$this->connection->insertIfNotExist('*PREFIX*share_external', $data, ['user', 'mountpoint_hash'])) {
                // The external share already exists for the user
                $data['mountpoint'] = $tmpMountPointName . '-' . $i;
                $data['mountpoint_hash'] = \md5($data['mountpoint']);
                $i++;
            }
            return null;
        }

        $shareFolder = Helper::getShareFolder();
        $mountPoint = Files::buildNotExistingFileName($shareFolder, $name);
        $mountPoint = Filesystem::normalizePath($mountPoint);
        $hash = \md5($mountPoint);

        $query = $this->connection->prepare('
                INSERT INTO `*PREFIX*share_external`
                    (`remote`, `share_token`, `password`, `name`, `owner`, `user`, `mountpoint`, `mountpoint_hash`, `accepted`, `remote_id`)
                VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
            ');
        $query->execute([$remote, $token, $password, $name, $owner, $user, $mountPoint, $hash, $accepted, $remoteId]);

        $options = [
            'remote' => $remote,
            'token' => $token,
            'password' => $password,
            'mountpoint' => $mountPoint,
            'owner' => $owner
        ];
        return $this->mountShare($options);
    }

    /**
     * get share
     *
     * @param int $id share id
     * @return mixed share of false
     */
    public function getShare($id) {
        $getShare = $this->connection->prepare('
            SELECT `id`, `remote`, `remote_id`, `share_token`, `name`, `owner`, `user`, `mountpoint`, `accepted`
            FROM  `*PREFIX*share_external`
            WHERE `id` = ? AND `user` = ?');
        $result = $getShare->execute([$id, $this->uid]);

        return $result ? $getShare->fetch() : false;
    }

    /**
     * Get the file id for an accepted share. Returns null when
     * the file id cannot be determined.
     *
     * @param mixed $share
     * @param string $mountPoint
     * @return string|null
     */
    public function getShareFileId($share, $mountPoint) {
        $options = [
            'remote' => $share['remote'],
            'token' => $share['share_token'],
            'mountpoint' => $mountPoint,
            'owner' => $share['owner']
        ];

        // We need to scan the new file/folder here to get its fileId
        // which will be passed to the event for further processing.
        $mount = $this->getMount($options);
        $storage = $mount->getStorage();

        if ($storage) {
            $scanner = $storage->getScanner('');

            // No need to scan all the folder contents as we only care about the root share
            $file = $scanner->scanFile('');

            if (isset($file['fileid'])) {
                return $file['fileid'];
            }
        }

        return null;
    }

    /**
     * Get the mount point of a newly received share.
     *
     * @param mixed $share
     * @return string
     */
    public function getShareRecipientMountPoint($share) {
        \OC_Util::setupFS($share['user']);
        $shareFolder = Helper::getShareFolder();
        $mountPoint = Files::buildNotExistingFileName($shareFolder, $share['name']);
        return Filesystem::normalizePath($mountPoint);
    }

    /**
     * accept server-to-server share
     *
     * @param int $id
     * @return bool True if the share could be accepted, false otherwise
     */
    public function acceptShare($id) {
        $share = $this->getShare($id);

        if ($share) {
            $mountPoint = $this->getShareRecipientMountPoint($share);
            $hash = \md5($mountPoint);

            $acceptShare = $this->connection->prepare('
                UPDATE `*PREFIX*share_external`
                SET `accepted` = ?,
                    `mountpoint` = ?,
                    `mountpoint_hash` = ?
                WHERE `id` = ? AND `user` = ?');
            $acceptShare->execute([1, $mountPoint, $hash, $id, $this->uid]);

            $fileId = $this->getShareFileId($share, $mountPoint);

            $this->eventDispatcher->dispatch(
                new AcceptShare($share),
                AcceptShare::class
            );

            $event = new GenericEvent(
                null,
                [
                    'sharedItem' => $share['name'],
                    'shareAcceptedFrom' => $share['owner'],
                    'remoteUrl' => $share['remote'],
                    'fileId' => $fileId, // can be null in case the file was not scanned properly
                    'shareId' => $id,
                    'shareRecipient' => $this->uid,
                ]
            );
            $this->eventDispatcher->dispatch($event, 'remoteshare.accepted');
            \OC_Hook::emit('OCP\Share', 'federated_share_added', ['server' => $share['remote']]);

            $this->processNotification($id);
            return true;
        }

        return false;
    }

    /**
     * decline server-to-server share
     *
     * @param int $id
     * @return bool True if the share could be declined, false otherwise
     */
    public function declineShare($id) {
        $share = $this->getShare($id);

        if ($share) {
            $removeShare = $this->connection->prepare('
                DELETE FROM `*PREFIX*share_external` WHERE `id` = ? AND `user` = ?');
            $removeShare->execute([$id, $this->uid]);

            $this->eventDispatcher->dispatch(
                new DeclineShare($share),
                DeclineShare::class
            );

            $event = new GenericEvent(null, ['sharedItem' => $share['name'], 'shareAcceptedFrom' => $share['owner'],
                'remoteUrl' => $share['remote']]);
            $this->eventDispatcher->dispatch($event, 'remoteshare.declined');

            $this->processNotification($id);
            return true;
        }

        return false;
    }

    /**
     * @param int $remoteShare
     */
    public function processNotification($remoteShare) {
        $filter = $this->notificationManager->createNotification();
        $filter->setApp('files_sharing')
            ->setUser($this->uid)
            ->setObject('remote_share', (int)$remoteShare);
        $this->notificationManager->markProcessed($filter);
    }

    /**
     * remove '/user/files' from the path and trailing slashes
     *
     * @param string $path
     * @return string
     */
    protected function stripPath($path) {
        $prefix = "/{$this->uid}/files";
        return \rtrim(\substr($path, \strlen($prefix)), '/');
    }

    public function getMount($data) {
        $data['manager'] = $this;
        $mountPoint = '/' . $this->uid . '/files' . $data['mountpoint'];
        $data['mountpoint'] = $mountPoint;
        $data['certificateManager'] = \OC::$server->getCertificateManager($this->uid);
        return new Mount(self::STORAGE, $mountPoint, $data, $this, $this->storageLoader);
    }

    /**
     * @param array $data
     * @return Mount
     */
    protected function mountShare($data) {
        $mount = $this->getMount($data);
        $this->mountManager->addMount($mount);
        return $mount;
    }

    /**
     * @return \OC\Files\Mount\Manager
     */
    public function getMountManager() {
        return $this->mountManager;
    }

    /**
     * @param string $source
     * @param string $target
     * @return bool
     */
    public function setMountPoint($source, $target) {
        $source = $this->stripPath($source);
        $target = $this->stripPath($target);
        $sourceHash = \md5($source);
        $targetHash = \md5($target);

        $query = $this->connection->prepare('
            UPDATE `*PREFIX*share_external`
            SET `mountpoint` = ?, `mountpoint_hash` = ?
            WHERE `mountpoint_hash` = ?
            AND `user` = ?
        ');
        try {
            $result = (bool)$query->execute([$target, $targetHash, $sourceHash, $this->uid]);
        } catch (UniqueConstraintViolationException $e) {
            $result = false;
        }

        return $result;
    }

    /**
     * Explicitly set uid when the shares are managed in CLI
     *
     * @param string|null $uid
     */
    public function setUid($uid) {
        // FIXME: External manager should not depend on uid
        $this->uid = $uid;
    }

    /**
     * @param $mountPoint
     * @return bool
     *
     * @throws NoUserException
     */
    public function removeShare($mountPoint) {
        if ($this->uid === null) {
            throw new NoUserException();
        }

        $mountPointObj = $this->mountManager->find($mountPoint);
        $id = $mountPointObj->getStorage()->getCache()->getId('');

        $mountPoint = $this->stripPath($mountPoint);
        $hash = \md5($mountPoint);

        $getShare = $this->connection->prepare('
            SELECT `remote`, `share_token`, `remote_id`
            FROM  `*PREFIX*share_external`
            WHERE `mountpoint_hash` = ? AND `user` = ?');
        $result = $getShare->execute([$hash, $this->uid]);

        if ($result) {
            $share = $getShare->fetch();
            if ($share !== false) {
                $this->eventDispatcher->dispatch(
                    new DeclineShare($share),
                    DeclineShare::class
                );
            }
        }
        $getShare->closeCursor();

        $query = $this->connection->prepare('
            DELETE FROM `*PREFIX*share_external`
            WHERE `mountpoint_hash` = ?
            AND `user` = ?
        ');
        $result = (bool)$query->execute([$hash, $this->uid]);

        if ($result) {
            $this->removeReShares($id);
            $event = new GenericEvent(null, ['user' => $this->uid, 'targetmount' => $mountPoint]);
            $this->eventDispatcher->dispatch($event, '\OCA\Files_Sharing::unshareEvent');
        }

        return $result;
    }

    /**
     * remove re-shares from share table and mapping in the federated_reshares table
     *
     * @param $mountPointId
     */
    protected function removeReShares($mountPointId) {
        $selectQuery = $this->connection->getQueryBuilder();
        $query = $this->connection->getQueryBuilder();
        $selectQuery->select('id')->from('share')
            ->where($selectQuery->expr()->eq('file_source', $query->createNamedParameter($mountPointId)));
        $select = $selectQuery->getSQL();

        $query->delete('federated_reshares')
            ->where($query->expr()->in('share_id', $query->createFunction('(' . $select . ')')));
        $query->execute();

        $deleteReShares = $this->connection->getQueryBuilder();
        $deleteReShares->delete('share')
            ->where($deleteReShares->expr()->eq('file_source', $deleteReShares->createNamedParameter($mountPointId)));
        $deleteReShares->execute();
    }

    /**
     * remove all shares for user $uid if the user was deleted
     *
     * @param string $uid
     * @return bool
     */
    public function removeUserShares($uid) {
        $getShare = $this->connection->prepare('
            SELECT `remote`, `share_token`, `remote_id`
            FROM  `*PREFIX*share_external`
            WHERE `user` = ?');
        $result = $getShare->execute([$uid]);

        if ($result) {
            $shares = $getShare->fetchAll();
            foreach ($shares as $share) {
                $this->eventDispatcher->dispatch(
                    new DeclineShare($share),
                    DeclineShare::class
                );
            }
        }

        $query = $this->connection->prepare('
            DELETE FROM `*PREFIX*share_external`
            WHERE `user` = ?
        ');
        return (bool)$query->execute([$uid]);
    }

    /**
     * return a list of shares which are not yet accepted by the user
     *
     * @return array list of open server-to-server shares
     */
    public function getOpenShares() {
        return $this->getShares(false);
    }

    /**
     * return a list of shares which are accepted by the user
     *
     * @return array list of accepted server-to-server shares
     */
    public function getAcceptedShares() {
        return $this->getShares(true);
    }

    /**
     * return a list of shares for the user
     *
     * @param bool|null $accepted True for accepted only,
     *                            false for not accepted,
     *                            null for all shares of the user
     * @return array list of open server-to-server shares
     */
    private function getShares($accepted) {
        $query = 'SELECT `id`, `remote`, `remote_id`, `share_token`, `name`, `owner`, `user`, `mountpoint`, `accepted`
                  FROM `*PREFIX*share_external` 
                  WHERE `user` = ?';
        $parameters = [$this->uid];
        if ($accepted !== null) {
            $query .= ' AND `accepted` = ?';
            $parameters[] = (int)$accepted;
        }
        $query .= ' ORDER BY `id` ASC';

        $shares = $this->connection->prepare($query);
        $result = $shares->execute($parameters);

        return $result ? $shares->fetchAll() : [];
    }

    /**
     * Test whether the specified remote is accessible
     */
    protected function testUrl(IClientService $clientService, string $remote, bool $checkVersion = false, bool $throwConnectException = false): bool {
        try {
            $client = $clientService->newClient();
            $response = \json_decode($client->get(
                $remote,
                [
                    'timeout' => 3,
                    'connect_timeout' => 3,
                ]
            )->getBody());

            if ($checkVersion) {
                return !empty($response->version) && \version_compare($response->version, '7.0.0', '>=');
            }

            return \is_object($response);
        } catch (ConnectException $e) {
            if ($throwConnectException) {
                throw $e;
            }
        } catch (\Exception $e) {
        }
        return false;
    }

    public function testRemoteUrl(IClientService $clientService, string $remote) {
        $parsed_host = parse_url($remote, PHP_URL_HOST);
        $parsed_port = parse_url($remote, PHP_URL_PORT);
        if (\is_string($parsed_host)) {
            $remote = $parsed_host;
            if ($parsed_port !== null) {
                $remote .= ':' . $parsed_port;
            }
        } else {
            $string_to_parse = 'http://' . $remote;
            $parsed_host = parse_url($string_to_parse, PHP_URL_HOST);
            $parsed_port = parse_url($string_to_parse, PHP_URL_PORT);
            if (\is_string($parsed_host)) {
                $remote = $parsed_host;
                if ($parsed_port !== null) {
                    $remote .= ':' . $parsed_port;
                }
            }
        }
        try {
            // one initial connectivity check on https and http
            if (!$this->checkConnectivity($clientService, $remote)) {
                return false;
            }
            if ($this->testUrl($clientService, 'https://' . $remote . '/ocs-provider/') ||
                $this->testUrl($clientService, 'https://' . $remote . '/ocs-provider/index.php') ||
                $this->testUrl($clientService, 'https://' . $remote . '/status.php', true)
            ) {
                return 'https';
            }

            if (
                $this->testUrl($clientService, 'http://' . $remote . '/ocs-provider/') ||
                $this->testUrl($clientService, 'http://' . $remote . '/ocs-provider/index.php') ||
                $this->testUrl($clientService, 'http://' . $remote . '/status.php', true)
            ) {
                return 'http';
            }
        } catch (ConnectException $e) {
            \OC::$server->getLogger()->logException($e, ['message' => "Remote $remote not reachable"]);
        }

        return false;
    }

    private function checkConnectivity(IClientService $clientService, string $remote): bool {
        foreach (['http://', 'https://'] as $schema) {
            try {
                if ($this->testUrl($clientService, $schema . $remote . '/status.php', false, true)) {
                    \OC::$server->getLogger()->error("Remote $schema$remote/status.php reachable");
                    return true;
                }
            } catch (\Exception $ex) {
                \OC::$server->getLogger()->logException($ex, ['message' => "Remote $schema$remote/status.php not reachable"]);
            }
        }

        \OC::$server->getLogger()->error("Remote $remote not reachable via http and https");
        return false;
    }
}