mirror of
https://github.com/nextcloud/server.git
synced 2026-06-21 06:29:50 -04:00
Co-authored-by: Micke Nordin <kano@sunet.se> Signed-off-by: Micke Nordin <kano@sunet.se> Signed-off-by: Enrique Pérez Arnaud <enrique@cazalla.net>
559 lines
19 KiB
PHP
559 lines
19 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
/**
|
|
* SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
|
|
* SPDX-FileCopyrightText: 2016 ownCloud, Inc.
|
|
* SPDX-License-Identifier: AGPL-3.0-only
|
|
*/
|
|
|
|
namespace OCA\Files_Sharing\External;
|
|
|
|
use GuzzleHttp\Exception\ClientException;
|
|
use GuzzleHttp\Exception\ConnectException;
|
|
use GuzzleHttp\Exception\RequestException;
|
|
use OC\Files\Storage\DAV;
|
|
use OC\ForbiddenException;
|
|
use OC\Share\Share;
|
|
use OCA\Files_Sharing\External\Manager as ExternalShareManager;
|
|
use OCA\Files_Sharing\ISharedStorage;
|
|
use OCP\AppFramework\Http;
|
|
use OCP\Constants;
|
|
use OCP\Federation\ICloudId;
|
|
use OCP\Files\Cache\ICache;
|
|
use OCP\Files\Cache\IScanner;
|
|
use OCP\Files\Cache\IWatcher;
|
|
use OCP\Files\NotFoundException;
|
|
use OCP\Files\Storage\IDisableEncryptionStorage;
|
|
use OCP\Files\Storage\IReliableEtagStorage;
|
|
use OCP\Files\Storage\IStorage;
|
|
use OCP\Files\StorageInvalidException;
|
|
use OCP\Files\StorageNotAvailableException;
|
|
use OCP\Http\Client\IClientService;
|
|
use OCP\Http\Client\LocalServerException;
|
|
use OCP\IAppConfig;
|
|
use OCP\ICacheFactory;
|
|
use OCP\IConfig;
|
|
use OCP\IUserSession;
|
|
use OCP\OCM\Exceptions\OCMArgumentException;
|
|
use OCP\OCM\Exceptions\OCMProviderException;
|
|
use OCP\OCM\IOCMDiscoveryService;
|
|
use OCP\Server;
|
|
use OCP\Share\IManager as IShareManager;
|
|
use Psr\Log\LoggerInterface;
|
|
|
|
class Storage extends DAV implements ISharedStorage, IDisableEncryptionStorage, IReliableEtagStorage {
|
|
private ICloudId $cloudId;
|
|
private string $mountPoint;
|
|
private string $token;
|
|
private ICacheFactory $memcacheFactory;
|
|
private IClientService $httpClient;
|
|
private bool $updateChecked = false;
|
|
private ExternalShareManager $manager;
|
|
private IConfig $config;
|
|
protected IAppConfig $appConfig;
|
|
private IShareManager $shareManager;
|
|
private bool $tokenRefreshed = false;
|
|
/** Unix timestamp until which the current access token is considered valid (0 = unknown/expired) */
|
|
private int $tokenExpiresAt = 0;
|
|
/** Number of consecutive token exchange failures (resets on success or DB-reuse) */
|
|
private int $refreshFailureCount = 0;
|
|
/** Unix timestamp before which the next exchange attempt must not be made (0 = no wait) */
|
|
private int $refreshBackoffUntil = 0;
|
|
|
|
private const REFRESH_MAX_ATTEMPTS = 3;
|
|
private const REFRESH_BACKOFF_SECONDS = 5;
|
|
|
|
/**
|
|
* @param array{HttpClientService: IClientService, manager: ExternalShareManager, cloudId: ICloudId, mountpoint: string, token: string, access_token: ?string, access_token_expires: ?int}|array $options
|
|
*/
|
|
public function __construct($options) {
|
|
$this->memcacheFactory = Server::get(ICacheFactory::class);
|
|
$this->httpClient = $options['HttpClientService'];
|
|
$this->manager = $options['manager'];
|
|
$this->cloudId = $options['cloudId'];
|
|
$this->logger = Server::get(LoggerInterface::class);
|
|
$discoveryService = $options['discoveryService'] ?? Server::get(IOCMDiscoveryService::class);
|
|
$this->config = Server::get(IConfig::class);
|
|
$this->appConfig = Server::get(IAppConfig::class);
|
|
$this->shareManager = Server::get(IShareManager::class);
|
|
|
|
// use default path to webdav if not found on discovery
|
|
try {
|
|
$ocmProvider = $discoveryService->discover($this->cloudId->getRemote());
|
|
$webDavEndpoint = $ocmProvider->extractProtocolEntry('file', 'webdav');
|
|
$remote = $ocmProvider->getEndPoint();
|
|
$authType = \Sabre\DAV\Client::AUTH_BASIC;
|
|
} catch (OCMProviderException|OCMArgumentException $e) {
|
|
$this->logger->notice('exception while retrieving webdav endpoint', ['exception' => $e]);
|
|
$webDavEndpoint = '/public.php/webdav';
|
|
$remote = $this->cloudId->getRemote();
|
|
$authType = \Sabre\DAV\Client::AUTH_BASIC;
|
|
}
|
|
|
|
// Only use Bearer auth when an access token is already stored.
|
|
// Shares created before the exchange-token capability was introduced have no
|
|
// stored token and must keep using basic auth for backwards compatibility.
|
|
if (!empty($options['access_token'])) {
|
|
$authType = \OC\Files\Storage\BearerAuthAwareSabreClient::AUTH_BEARER;
|
|
}
|
|
|
|
$host = parse_url($remote, PHP_URL_HOST);
|
|
// If host extraction fails (e.g., endpoint has no scheme), fall back to cloudId's remote
|
|
if ($host === null) {
|
|
$host = parse_url($this->cloudId->getRemote(), PHP_URL_HOST);
|
|
}
|
|
$port = parse_url($remote, PHP_URL_PORT);
|
|
if ($port === null) {
|
|
$port = parse_url($this->cloudId->getRemote(), PHP_URL_PORT);
|
|
}
|
|
$host .= ($port === null) ? '' : ':' . $port; // we add port if available
|
|
|
|
// in case remote NC is on a sub folder and using deprecated ocm provider
|
|
$tmpPath = rtrim(parse_url($this->cloudId->getRemote(), PHP_URL_PATH) ?? '', '/');
|
|
if (!str_starts_with($webDavEndpoint, $tmpPath)) {
|
|
$webDavEndpoint = $tmpPath . $webDavEndpoint;
|
|
}
|
|
|
|
$this->mountPoint = $options['mountpoint'];
|
|
$this->token = $options['token'];
|
|
$this->tokenExpiresAt = (int)($options['access_token_expires'] ?? 0);
|
|
|
|
// Determine scheme - fall back to cloudId's remote if $remote has no scheme
|
|
$scheme = parse_url($remote, PHP_URL_SCHEME) ?? parse_url($this->cloudId->getRemote(), PHP_URL_SCHEME) ?? 'https';
|
|
|
|
parent::__construct(
|
|
[
|
|
'secure' => ($scheme === 'https'),
|
|
'verify' => !$this->config->getSystemValueBool('sharing.federation.allowSelfSignedCertificates', false),
|
|
'host' => $host,
|
|
'root' => $webDavEndpoint,
|
|
'user' => $options['token'],
|
|
'authType' => $authType,
|
|
'password' => $authType === \OC\Files\Storage\BearerAuthAwareSabreClient::AUTH_BEARER
|
|
? (string)($options['access_token'] ?? '')
|
|
: (string)($options['password'] ?? ''),
|
|
'discoveryService' => $discoveryService,
|
|
]
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Refresh the access token. Extends parent to also persist to database.
|
|
*
|
|
* Uses expiry timestamps instead of a boolean flag so that concurrent
|
|
* processes can detect that another process already obtained a fresh token
|
|
* and reuse it rather than performing a redundant exchange.
|
|
*
|
|
* After a failed exchange, a 60-second backoff is applied so that
|
|
* subsequent file operations do not hammer the remote token endpoint.
|
|
* The DB is still consulted during backoff in case a concurrent process
|
|
* succeeded; only the outgoing exchange call is suppressed.
|
|
*
|
|
* @return string|null the access token (freshly exchanged or reused from
|
|
* DB), or null if refresh is currently not possible
|
|
*/
|
|
#[\Override]
|
|
protected function refreshAccessToken(): ?string {
|
|
$now = time();
|
|
|
|
// Fast path: in-memory token is still valid (single-process guard).
|
|
if ($this->tokenExpiresAt > $now && !empty($this->password)) {
|
|
return $this->password;
|
|
}
|
|
|
|
// Slow path: check DB — a concurrent process may have already refreshed.
|
|
$share = $this->manager->getShareByToken($this->token);
|
|
if ($share !== false) {
|
|
$dbExpiry = $share->getAccessTokenExpires();
|
|
$dbToken = $share->getAccessToken();
|
|
if ($dbExpiry !== null && $dbExpiry > $now && $dbToken !== null) {
|
|
// Another process already refreshed — reuse DB token and reset failure state.
|
|
$this->password = $dbToken;
|
|
$this->bearerToken = $dbToken;
|
|
$this->tokenExpiresAt = $dbExpiry;
|
|
$this->refreshFailureCount = 0;
|
|
$this->refreshBackoffUntil = 0;
|
|
$this->logger->debug('Reused access token refreshed by another process', ['app' => 'files_sharing']);
|
|
return $dbToken;
|
|
}
|
|
}
|
|
|
|
// Gave up after max attempts: stop trying for the lifetime of this instance.
|
|
if ($this->refreshFailureCount >= self::REFRESH_MAX_ATTEMPTS) {
|
|
return null;
|
|
}
|
|
|
|
// Still within the inter-attempt wait: don't hit the endpoint yet.
|
|
if ($this->refreshBackoffUntil > $now) {
|
|
return null;
|
|
}
|
|
|
|
// No valid token in DB — perform the exchange ourselves.
|
|
try {
|
|
$expiresAt = $now + 3600; // access tokens are valid for 1 hour
|
|
$newAccessToken = $this->exchangeRefreshToken();
|
|
$this->password = $newAccessToken;
|
|
$this->bearerToken = $newAccessToken;
|
|
$this->tokenExpiresAt = $expiresAt;
|
|
$this->refreshFailureCount = 0;
|
|
$this->refreshBackoffUntil = 0;
|
|
|
|
$this->manager->updateAccessToken($this->token, $newAccessToken, $expiresAt);
|
|
|
|
$this->logger->debug('Successfully refreshed access token', ['app' => 'files_sharing']);
|
|
return $newAccessToken;
|
|
} catch (\Exception $e) {
|
|
$this->refreshFailureCount++;
|
|
$this->refreshBackoffUntil = $now + self::REFRESH_BACKOFF_SECONDS;
|
|
$this->logger->warning('Failed to refresh access token (attempt {attempt}/{max})', [
|
|
'app' => 'files_sharing',
|
|
'attempt' => $this->refreshFailureCount,
|
|
'max' => self::REFRESH_MAX_ATTEMPTS,
|
|
'exception' => $e,
|
|
]);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
#[\Override]
|
|
public function getWatcher(string $path = '', ?IStorage $storage = null): IWatcher {
|
|
if (!$storage) {
|
|
$storage = $this;
|
|
}
|
|
if (!isset($this->watcher)) {
|
|
$this->watcher = new Watcher($storage);
|
|
$this->watcher->setPolicy(\OC\Files\Cache\Watcher::CHECK_ONCE);
|
|
}
|
|
return $this->watcher;
|
|
}
|
|
|
|
public function getRemoteUser(): string {
|
|
return $this->cloudId->getUser();
|
|
}
|
|
|
|
public function getRemote(): string {
|
|
return $this->cloudId->getRemote();
|
|
}
|
|
|
|
public function getMountPoint(): string {
|
|
return $this->mountPoint;
|
|
}
|
|
|
|
public function getToken(): string {
|
|
return $this->token;
|
|
}
|
|
|
|
public function getPassword(): ?string {
|
|
return $this->password;
|
|
}
|
|
|
|
#[\Override]
|
|
public function getId(): string {
|
|
return 'shared::' . md5($this->token . '@' . $this->getRemote());
|
|
}
|
|
|
|
#[\Override]
|
|
public function getCache(string $path = '', ?IStorage $storage = null): ICache {
|
|
if (is_null($this->cache)) {
|
|
$this->cache = new Cache($this, $this->cloudId);
|
|
}
|
|
return $this->cache;
|
|
}
|
|
|
|
#[\Override]
|
|
public function getScanner(string $path = '', ?IStorage $storage = null): IScanner {
|
|
if (!$storage) {
|
|
$storage = $this;
|
|
}
|
|
if (!isset($this->scanner)) {
|
|
$this->scanner = new Scanner($storage);
|
|
}
|
|
/** @var Scanner */
|
|
return $this->scanner;
|
|
}
|
|
|
|
#[\Override]
|
|
public function hasUpdated(string $path, int $time): bool {
|
|
// since for owncloud webdav servers we can rely on etag propagation we only need to check the root of the storage
|
|
// because of that we only do one check for the entire storage per request
|
|
if ($this->updateChecked) {
|
|
return false;
|
|
}
|
|
$this->updateChecked = true;
|
|
try {
|
|
return parent::hasUpdated('', $time);
|
|
} catch (StorageInvalidException $e) {
|
|
// check if it needs to be removed
|
|
$this->checkStorageAvailability();
|
|
throw $e;
|
|
} catch (StorageNotAvailableException $e) {
|
|
// check if it needs to be removed or just temp unavailable
|
|
$this->checkStorageAvailability();
|
|
throw $e;
|
|
}
|
|
}
|
|
|
|
#[\Override]
|
|
public function test(): bool {
|
|
try {
|
|
return parent::test();
|
|
} catch (StorageInvalidException $e) {
|
|
// check if it needs to be removed
|
|
$this->checkStorageAvailability();
|
|
throw $e;
|
|
} catch (StorageNotAvailableException $e) {
|
|
// check if it needs to be removed or just temp unavailable
|
|
$this->checkStorageAvailability();
|
|
throw $e;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check whether this storage is permanently or temporarily
|
|
* unavailable
|
|
*
|
|
* @throws StorageNotAvailableException
|
|
* @throws StorageInvalidException
|
|
*/
|
|
public function checkStorageAvailability(): void {
|
|
// see if we can find out why the share is unavailable
|
|
try {
|
|
$this->getShareInfo(0);
|
|
} catch (NotFoundException $e) {
|
|
// a 404 can either mean that the share no longer exists or there is no Nextcloud on the remote
|
|
if ($this->testRemote()) {
|
|
// valid Nextcloud instance means that the public share no longer exists
|
|
// since this is permanent (re-sharing the file will create a new token)
|
|
// we remove the invalid storage
|
|
$this->manager->removeShare($this->mountPoint);
|
|
$this->manager->getMountManager()->removeMount($this->mountPoint);
|
|
throw new StorageInvalidException('Remote share not found', 0, $e);
|
|
} else {
|
|
// Nextcloud instance is gone, likely to be a temporary server configuration error
|
|
throw new StorageNotAvailableException('No nextcloud instance found at remote', 0, $e);
|
|
}
|
|
} catch (ForbiddenException $e) {
|
|
// auth error, remove share for now (provide a dialog in the future)
|
|
$this->manager->removeShare($this->mountPoint);
|
|
$this->manager->getMountManager()->removeMount($this->mountPoint);
|
|
throw new StorageInvalidException('Auth error when getting remote share');
|
|
} catch (\GuzzleHttp\Exception\ConnectException $e) {
|
|
throw new StorageNotAvailableException('Failed to connect to remote instance', 0, $e);
|
|
} catch (\GuzzleHttp\Exception\RequestException $e) {
|
|
throw new StorageNotAvailableException('Error while sending request to remote instance', 0, $e);
|
|
}
|
|
}
|
|
|
|
#[\Override]
|
|
public function file_exists(string $path): bool {
|
|
if ($path === '') {
|
|
return true;
|
|
} else {
|
|
return parent::file_exists($path);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if the configured remote is a valid-federated share provider
|
|
*/
|
|
protected function testRemote(): bool {
|
|
try {
|
|
return $this->testRemoteUrl($this->getRemote() . '/ocm-provider/index.php')
|
|
|| $this->testRemoteUrl($this->getRemote() . '/ocm-provider/')
|
|
|| $this->testRemoteUrl($this->getRemote() . '/.well-known/ocm')
|
|
|| $this->testRemoteUrl($this->getRemote() . '/status.php');
|
|
} catch (\Exception $e) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private function testRemoteUrl(string $url): bool {
|
|
$cache = $this->memcacheFactory->createDistributed('files_sharing_remote_url');
|
|
$cached = $cache->get($url);
|
|
if ($cached !== null) {
|
|
return (bool)$cached;
|
|
}
|
|
|
|
$client = $this->httpClient->newClient();
|
|
try {
|
|
$result = $client->get($url, $this->getDefaultRequestOptions())->getBody();
|
|
$data = json_decode($result);
|
|
$returnValue = (is_object($data) && !empty($data->version));
|
|
} catch (ConnectException|ClientException|RequestException $e) {
|
|
$returnValue = false;
|
|
$this->logger->warning('Failed to test remote URL', ['exception' => $e]);
|
|
}
|
|
|
|
$cache->set($url, $returnValue, 60 * 60 * 24);
|
|
return $returnValue;
|
|
}
|
|
|
|
/**
|
|
* Check whether the remote is an ownCloud/Nextcloud. This is needed since some sharing
|
|
* features are not standardized.
|
|
*
|
|
* @throws LocalServerException
|
|
*/
|
|
public function remoteIsOwnCloud(): bool {
|
|
if (defined('PHPUNIT_RUN') || !$this->testRemoteUrl($this->getRemote() . '/status.php')) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* @return mixed
|
|
* @throws ForbiddenException
|
|
* @throws NotFoundException
|
|
* @throws \Exception
|
|
*/
|
|
public function getShareInfo(int $depth = -1) {
|
|
$remote = $this->getRemote();
|
|
$token = $this->getToken();
|
|
$password = $this->getPassword();
|
|
|
|
try {
|
|
// If remote is not an ownCloud do not try to get any share info
|
|
if (!$this->remoteIsOwnCloud()) {
|
|
return ['status' => 'unsupported'];
|
|
}
|
|
} catch (LocalServerException $e) {
|
|
// throw this to be on the safe side: the share will still be visible
|
|
// in the UI in case the failure is intermittent, and the user will
|
|
// be able to decide whether to remove it if it's really gone
|
|
throw new StorageNotAvailableException();
|
|
}
|
|
|
|
$url = rtrim($remote, '/') . '/index.php/apps/files_sharing/shareinfo?t=' . $token;
|
|
|
|
// TODO: DI
|
|
$client = Server::get(IClientService::class)->newClient();
|
|
try {
|
|
$response = $client->post($url, array_merge($this->getDefaultRequestOptions(), [
|
|
'body' => ['password' => $password, 'depth' => $depth],
|
|
]));
|
|
} catch (\GuzzleHttp\Exception\RequestException $e) {
|
|
$this->logger->warning('Failed to fetch share info', ['exception' => $e]);
|
|
if ($e->getCode() === Http::STATUS_UNAUTHORIZED || $e->getCode() === Http::STATUS_FORBIDDEN) {
|
|
throw new ForbiddenException();
|
|
}
|
|
if ($e->getCode() === Http::STATUS_NOT_FOUND) {
|
|
throw new NotFoundException();
|
|
}
|
|
// throw this to be on the safe side: the share will still be visible
|
|
// in the UI in case the failure is intermittent, and the user will
|
|
// be able to decide whether to remove it if it's really gone
|
|
throw new StorageNotAvailableException();
|
|
}
|
|
|
|
return json_decode($response->getBody(), true);
|
|
}
|
|
|
|
#[\Override]
|
|
public function getOwner(string $path): string|false {
|
|
return $this->cloudId->getDisplayId();
|
|
}
|
|
|
|
#[\Override]
|
|
public function isSharable(string $path): bool {
|
|
if ($this->shareManager->sharingDisabledForUser(Server::get(IUserSession::class)->getUser()?->getUID())
|
|
|| !$this->appConfig->getValueBool('core', 'shareapi_allow_resharing', true)) {
|
|
return false;
|
|
}
|
|
return (bool)($this->getPermissions($path) & Constants::PERMISSION_SHARE);
|
|
}
|
|
|
|
#[\Override]
|
|
public function getPermissions(string $path): int {
|
|
$response = $this->propfind($path);
|
|
if ($response === false) {
|
|
return 0;
|
|
}
|
|
|
|
$ocsPermissions = $response['{http://open-collaboration-services.org/ns}share-permissions'] ?? null;
|
|
$ocmPermissions = $response['{http://open-cloud-mesh.org/ns}share-permissions'] ?? null;
|
|
$ocPermissions = $response['{http://owncloud.org/ns}permissions'] ?? null;
|
|
// old federated sharing permissions
|
|
if ($ocsPermissions !== null) {
|
|
$permissions = (int)$ocsPermissions;
|
|
} elseif ($ocmPermissions !== null) {
|
|
// permissions provided by the OCM API
|
|
$permissions = $this->ocmPermissions2ncPermissions($ocmPermissions, $path);
|
|
} elseif ($ocPermissions !== null) {
|
|
return $this->parsePermissions($ocPermissions);
|
|
} else {
|
|
// use default permission if remote server doesn't provide the share permissions
|
|
$permissions = $this->getDefaultPermissions($path);
|
|
}
|
|
|
|
return $permissions;
|
|
}
|
|
|
|
#[\Override]
|
|
public function needsPartFile(): bool {
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Translate OCM Permissions to Nextcloud permissions
|
|
*
|
|
* @param string $ocmPermissions json encoded OCM permissions
|
|
* @param string $path path to file
|
|
* @return int
|
|
*/
|
|
protected function ocmPermissions2ncPermissions(string $ocmPermissions, string $path): int {
|
|
try {
|
|
$ocmPermissions = json_decode($ocmPermissions);
|
|
$ncPermissions = 0;
|
|
foreach ($ocmPermissions as $permission) {
|
|
switch (strtolower($permission)) {
|
|
case 'read':
|
|
$ncPermissions += Constants::PERMISSION_READ;
|
|
break;
|
|
case 'write':
|
|
$ncPermissions += Constants::PERMISSION_CREATE + Constants::PERMISSION_UPDATE;
|
|
break;
|
|
case 'share':
|
|
$ncPermissions += Constants::PERMISSION_SHARE;
|
|
break;
|
|
default:
|
|
throw new \Exception();
|
|
}
|
|
}
|
|
} catch (\Exception $e) {
|
|
$ncPermissions = $this->getDefaultPermissions($path);
|
|
}
|
|
|
|
return $ncPermissions;
|
|
}
|
|
|
|
/**
|
|
* Calculate the default permissions in case no permissions are provided
|
|
*/
|
|
protected function getDefaultPermissions(string $path): int {
|
|
if ($this->is_dir($path)) {
|
|
$permissions = Constants::PERMISSION_ALL;
|
|
} else {
|
|
$permissions = Constants::PERMISSION_ALL & ~Constants::PERMISSION_CREATE;
|
|
}
|
|
|
|
return $permissions;
|
|
}
|
|
|
|
#[\Override]
|
|
public function free_space(string $path): int|float|false {
|
|
return parent::free_space('');
|
|
}
|
|
|
|
private function getDefaultRequestOptions(): array {
|
|
$options = [
|
|
'timeout' => 10,
|
|
'connect_timeout' => 10,
|
|
];
|
|
if ($this->config->getSystemValueBool('sharing.federation.allowSelfSignedCertificates')) {
|
|
$options['verify'] = false;
|
|
}
|
|
return $options;
|
|
}
|
|
}
|