diff options
Diffstat (limited to 'apps/files_external/lib/Lib')
62 files changed, 7044 insertions, 0 deletions
diff --git a/apps/files_external/lib/Lib/Auth/AmazonS3/AccessKey.php b/apps/files_external/lib/Lib/Auth/AmazonS3/AccessKey.php new file mode 100644 index 00000000000..c86c88a13d7 --- /dev/null +++ b/apps/files_external/lib/Lib/Auth/AmazonS3/AccessKey.php @@ -0,0 +1,31 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2018-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Files_External\Lib\Auth\AmazonS3; + +use OCA\Files_External\Lib\Auth\AuthMechanism; +use OCA\Files_External\Lib\DefinitionParameter; +use OCP\IL10N; + +/** + * Amazon S3 access key authentication + */ +class AccessKey extends AuthMechanism { + public const SCHEME_AMAZONS3_ACCESSKEY = 'amazons3_accesskey'; + + public function __construct(IL10N $l) { + $this + ->setIdentifier('amazons3::accesskey') + ->setScheme(self::SCHEME_AMAZONS3_ACCESSKEY) + ->setText($l->t('Access key')) + ->addParameters([ + new DefinitionParameter('key', $l->t('Access key')), + (new DefinitionParameter('secret', $l->t('Secret key'))) + ->setType(DefinitionParameter::VALUE_PASSWORD), + ]); + } +} diff --git a/apps/files_external/lib/Lib/Auth/AuthMechanism.php b/apps/files_external/lib/Lib/Auth/AuthMechanism.php new file mode 100644 index 00000000000..7b0544100fb --- /dev/null +++ b/apps/files_external/lib/Lib/Auth/AuthMechanism.php @@ -0,0 +1,103 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2017-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Files_External\Lib\Auth; + +use OCA\Files_External\Lib\FrontendDefinitionTrait; +use OCA\Files_External\Lib\IdentifierTrait; +use OCA\Files_External\Lib\IFrontendDefinition; +use OCA\Files_External\Lib\IIdentifier; +use OCA\Files_External\Lib\StorageConfig; +use OCA\Files_External\Lib\StorageModifierTrait; +use OCA\Files_External\Lib\VisibilityTrait; + +/** + * Authentication mechanism + * + * An authentication mechanism can have services injected during construction, + * such as \OCP\IDB for database operations. This allows an authentication + * mechanism to perform advanced operations based on provided information. + * + * An authentication scheme defines the parameter interface, common to the + * storage implementation, the backend and the authentication mechanism. + * A storage implementation expects parameters according to the authentication + * scheme, which are provided from the authentication mechanism. + * + * This class uses the following traits: + * - VisibilityTrait + * Restrict usage to admin-only/none + * - FrontendDefinitionTrait + * Specify configuration parameters and other definitions + * - StorageModifierTrait + * Object can affect storage mounting + */ +class AuthMechanism implements \JsonSerializable, IIdentifier, IFrontendDefinition { + /** Standard authentication schemes */ + public const SCHEME_NULL = 'null'; + public const SCHEME_BUILTIN = 'builtin'; + public const SCHEME_PASSWORD = 'password'; + public const SCHEME_OAUTH2 = 'oauth2'; + public const SCHEME_PUBLICKEY = 'publickey'; + public const SCHEME_OPENSTACK = 'openstack'; + public const SCHEME_SMB = 'smb'; + + use VisibilityTrait; + use FrontendDefinitionTrait; + use StorageModifierTrait; + use IdentifierTrait; + + /** @var string */ + protected $scheme; + + /** + * Get the authentication scheme implemented + * See self::SCHEME_* constants + * + * @return string + */ + public function getScheme() { + return $this->scheme; + } + + /** + * @param string $scheme + * @return $this + */ + public function setScheme($scheme) { + $this->scheme = $scheme; + return $this; + } + + /** + * Serialize into JSON for client-side JS + */ + public function jsonSerialize(): array { + $data = $this->jsonSerializeDefinition(); + $data += $this->jsonSerializeIdentifier(); + + $data['scheme'] = $this->getScheme(); + $data['visibility'] = $this->getVisibility(); + + return $data; + } + + /** + * Check if parameters are satisfied in a StorageConfig + * + * @param StorageConfig $storage + * @return bool + */ + public function validateStorage(StorageConfig $storage) { + // does the backend actually support this scheme + $supportedSchemes = $storage->getBackend()->getAuthSchemes(); + if (!isset($supportedSchemes[$this->getScheme()])) { + return false; + } + + return $this->validateStorageDefinition($storage); + } +} diff --git a/apps/files_external/lib/Lib/Auth/Builtin.php b/apps/files_external/lib/Lib/Auth/Builtin.php new file mode 100644 index 00000000000..8e12a6daca6 --- /dev/null +++ b/apps/files_external/lib/Lib/Auth/Builtin.php @@ -0,0 +1,23 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2019-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Files_External\Lib\Auth; + +use OCP\IL10N; + +/** + * Builtin authentication mechanism, for legacy backends + */ +class Builtin extends AuthMechanism { + public function __construct(IL10N $l) { + $this + ->setIdentifier('builtin::builtin') + ->setScheme(self::SCHEME_BUILTIN) + ->setText($l->t('Builtin')) + ; + } +} diff --git a/apps/files_external/lib/Lib/Auth/IUserProvided.php b/apps/files_external/lib/Lib/Auth/IUserProvided.php new file mode 100644 index 00000000000..2350d7f6db4 --- /dev/null +++ b/apps/files_external/lib/Lib/Auth/IUserProvided.php @@ -0,0 +1,21 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Files_External\Lib\Auth; + +use OCP\IUser; + +/** + * For auth mechanisms where the user needs to provide credentials + */ +interface IUserProvided { + /** + * @param IUser $user the user for which to save the user provided options + * @param int $mountId the mount id to save the options for + * @param array $options the user provided options + */ + public function saveBackendOptions(IUser $user, $mountId, array $options); +} diff --git a/apps/files_external/lib/Lib/Auth/InvalidAuth.php b/apps/files_external/lib/Lib/Auth/InvalidAuth.php new file mode 100644 index 00000000000..2af24f1ea07 --- /dev/null +++ b/apps/files_external/lib/Lib/Auth/InvalidAuth.php @@ -0,0 +1,29 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2017-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud GmbH. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Files_External\Lib\Auth; + +/** + * Invalid authentication representing an auth mechanism + * that could not be resolved0 + */ +class InvalidAuth extends AuthMechanism { + + /** + * Constructs a new InvalidAuth with the id of the invalid auth + * for display purposes + * + * @param string $invalidId invalid id + */ + public function __construct($invalidId) { + $this + ->setIdentifier($invalidId) + ->setScheme(self::SCHEME_NULL) + ->setText('Unknown auth mechanism backend ' . $invalidId) + ; + } +} diff --git a/apps/files_external/lib/Lib/Auth/NullMechanism.php b/apps/files_external/lib/Lib/Auth/NullMechanism.php new file mode 100644 index 00000000000..8e2e5b656b2 --- /dev/null +++ b/apps/files_external/lib/Lib/Auth/NullMechanism.php @@ -0,0 +1,23 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2019-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Files_External\Lib\Auth; + +use OCP\IL10N; + +/** + * Null authentication mechanism + */ +class NullMechanism extends AuthMechanism { + public function __construct(IL10N $l) { + $this + ->setIdentifier('null::null') + ->setScheme(self::SCHEME_NULL) + ->setText($l->t('None')) + ; + } +} diff --git a/apps/files_external/lib/Lib/Auth/OAuth2/OAuth2.php b/apps/files_external/lib/Lib/Auth/OAuth2/OAuth2.php new file mode 100644 index 00000000000..beaf73c2344 --- /dev/null +++ b/apps/files_external/lib/Lib/Auth/OAuth2/OAuth2.php @@ -0,0 +1,37 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2018-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Files_External\Lib\Auth\OAuth2; + +use OCA\Files_External\Lib\Auth\AuthMechanism; +use OCA\Files_External\Lib\DefinitionParameter; +use OCP\IL10N; + +/** + * OAuth2 authentication + */ +class OAuth2 extends AuthMechanism { + public function __construct(IL10N $l) { + $this + ->setIdentifier('oauth2::oauth2') + ->setScheme(self::SCHEME_OAUTH2) + ->setText($l->t('OAuth2')) + ->addParameters([ + (new DefinitionParameter('configured', 'configured')) + ->setType(DefinitionParameter::VALUE_TEXT) + ->setFlag(DefinitionParameter::FLAG_HIDDEN), + new DefinitionParameter('client_id', $l->t('Client ID')), + (new DefinitionParameter('client_secret', $l->t('Client secret'))) + ->setType(DefinitionParameter::VALUE_PASSWORD), + (new DefinitionParameter('token', 'token')) + ->setType(DefinitionParameter::VALUE_PASSWORD) + ->setFlag(DefinitionParameter::FLAG_HIDDEN), + ]) + ->addCustomJs('oauth2') + ; + } +} diff --git a/apps/files_external/lib/Lib/Auth/OpenStack/OpenStackV2.php b/apps/files_external/lib/Lib/Auth/OpenStack/OpenStackV2.php new file mode 100644 index 00000000000..3b1c9f123af --- /dev/null +++ b/apps/files_external/lib/Lib/Auth/OpenStack/OpenStackV2.php @@ -0,0 +1,32 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2019-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Files_External\Lib\Auth\OpenStack; + +use OCA\Files_External\Lib\Auth\AuthMechanism; +use OCA\Files_External\Lib\DefinitionParameter; +use OCP\IL10N; + +/** + * OpenStack Keystone authentication + */ +class OpenStackV2 extends AuthMechanism { + public function __construct(IL10N $l) { + $this + ->setIdentifier('openstack::openstack') + ->setScheme(self::SCHEME_OPENSTACK) + ->setText($l->t('OpenStack v2')) + ->addParameters([ + new DefinitionParameter('user', $l->t('Login')), + (new DefinitionParameter('password', $l->t('Password'))) + ->setType(DefinitionParameter::VALUE_PASSWORD), + new DefinitionParameter('tenant', $l->t('Tenant name')), + new DefinitionParameter('url', $l->t('Identity endpoint URL')), + ]) + ; + } +} diff --git a/apps/files_external/lib/Lib/Auth/OpenStack/OpenStackV3.php b/apps/files_external/lib/Lib/Auth/OpenStack/OpenStackV3.php new file mode 100644 index 00000000000..b5d185fd374 --- /dev/null +++ b/apps/files_external/lib/Lib/Auth/OpenStack/OpenStackV3.php @@ -0,0 +1,34 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files_External\Lib\Auth\OpenStack; + +use OCA\Files_External\Lib\Auth\AuthMechanism; +use OCA\Files_External\Lib\DefinitionParameter; +use OCP\IL10N; + +/** + * OpenStack Keystone authentication + */ +class OpenStackV3 extends AuthMechanism { + public function __construct(IL10N $l) { + $this + ->setIdentifier('openstack::openstackv3') + ->setScheme(self::SCHEME_OPENSTACK) + ->setText($l->t('OpenStack v3')) + ->addParameters([ + new DefinitionParameter('user', $l->t('Login')), + new DefinitionParameter('domain', $l->t('Domain')), + (new DefinitionParameter('password', $l->t('Password'))) + ->setType(DefinitionParameter::VALUE_PASSWORD), + new DefinitionParameter('tenant', $l->t('Tenant name')), + new DefinitionParameter('url', $l->t('Identity endpoint URL')) + ]) + ; + } +} diff --git a/apps/files_external/lib/Lib/Auth/OpenStack/Rackspace.php b/apps/files_external/lib/Lib/Auth/OpenStack/Rackspace.php new file mode 100644 index 00000000000..b1d1068e586 --- /dev/null +++ b/apps/files_external/lib/Lib/Auth/OpenStack/Rackspace.php @@ -0,0 +1,30 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2018-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Files_External\Lib\Auth\OpenStack; + +use OCA\Files_External\Lib\Auth\AuthMechanism; +use OCA\Files_External\Lib\DefinitionParameter; +use OCP\IL10N; + +/** + * Rackspace authentication + */ +class Rackspace extends AuthMechanism { + public function __construct(IL10N $l) { + $this + ->setIdentifier('openstack::rackspace') + ->setScheme(self::SCHEME_OPENSTACK) + ->setText($l->t('Rackspace')) + ->addParameters([ + new DefinitionParameter('user', $l->t('Login')), + (new DefinitionParameter('key', $l->t('API key'))) + ->setType(DefinitionParameter::VALUE_PASSWORD), + ]) + ; + } +} diff --git a/apps/files_external/lib/Lib/Auth/Password/GlobalAuth.php b/apps/files_external/lib/Lib/Auth/Password/GlobalAuth.php new file mode 100644 index 00000000000..916b496b506 --- /dev/null +++ b/apps/files_external/lib/Lib/Auth/Password/GlobalAuth.php @@ -0,0 +1,80 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2015 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Files_External\Lib\Auth\Password; + +use OCA\Files_External\Lib\Auth\AuthMechanism; +use OCA\Files_External\Lib\InsufficientDataForMeaningfulAnswerException; +use OCA\Files_External\Lib\StorageConfig; +use OCA\Files_External\Service\BackendService; +use OCP\IL10N; +use OCP\IUser; +use OCP\Security\ICredentialsManager; + +/** + * Global Username and Password + */ +class GlobalAuth extends AuthMechanism { + public const CREDENTIALS_IDENTIFIER = 'password::global'; + private const PWD_PLACEHOLDER = '************************'; + + public function __construct( + IL10N $l, + protected ICredentialsManager $credentialsManager, + ) { + $this + ->setIdentifier('password::global') + ->setVisibility(BackendService::VISIBILITY_DEFAULT) + ->setScheme(self::SCHEME_PASSWORD) + ->setText($l->t('Global credentials')); + } + + public function getAuth($uid) { + $auth = $this->credentialsManager->retrieve($uid, self::CREDENTIALS_IDENTIFIER); + if (!is_array($auth)) { + return [ + 'user' => '', + 'password' => '' + ]; + } else { + $auth['password'] = self::PWD_PLACEHOLDER; + return $auth; + } + } + + public function saveAuth($uid, $user, $password) { + // Use old password if it has not changed. + if ($password === self::PWD_PLACEHOLDER) { + $auth = $this->credentialsManager->retrieve($uid, self::CREDENTIALS_IDENTIFIER); + $password = $auth['password']; + } + + $this->credentialsManager->store($uid, self::CREDENTIALS_IDENTIFIER, [ + 'user' => $user, + 'password' => $password + ]); + } + + /** + * @return void + */ + public function manipulateStorageConfig(StorageConfig &$storage, ?IUser $user = null) { + if ($storage->getType() === StorageConfig::MOUNT_TYPE_ADMIN) { + $uid = ''; + } elseif (is_null($user)) { + throw new InsufficientDataForMeaningfulAnswerException('No credentials saved'); + } else { + $uid = $user->getUID(); + } + $credentials = $this->credentialsManager->retrieve($uid, self::CREDENTIALS_IDENTIFIER); + + if (is_array($credentials)) { + $storage->setBackendOption('user', $credentials['user']); + $storage->setBackendOption('password', $credentials['password']); + } + } +} diff --git a/apps/files_external/lib/Lib/Auth/Password/LoginCredentials.php b/apps/files_external/lib/Lib/Auth/Password/LoginCredentials.php new file mode 100644 index 00000000000..ce38140b6ee --- /dev/null +++ b/apps/files_external/lib/Lib/Auth/Password/LoginCredentials.php @@ -0,0 +1,113 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2018-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2015 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Files_External\Lib\Auth\Password; + +use OCA\Files_External\Lib\Auth\AuthMechanism; +use OCA\Files_External\Lib\DefinitionParameter; +use OCA\Files_External\Lib\InsufficientDataForMeaningfulAnswerException; +use OCA\Files_External\Lib\StorageConfig; +use OCA\Files_External\Listener\StorePasswordListener; +use OCP\Authentication\Exceptions\CredentialsUnavailableException; +use OCP\Authentication\LoginCredentials\IStore as CredentialsStore; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\IL10N; +use OCP\ISession; +use OCP\IUser; +use OCP\IUserBackend; +use OCP\LDAP\ILDAPProviderFactory; +use OCP\Security\ICredentialsManager; +use OCP\User\Events\PasswordUpdatedEvent; +use OCP\User\Events\UserLoggedInEvent; + +/** + * Username and password from login credentials, saved in DB + */ +class LoginCredentials extends AuthMechanism { + public const CREDENTIALS_IDENTIFIER = 'password::logincredentials/credentials'; + + public function __construct( + IL10N $l, + protected ISession $session, + protected ICredentialsManager $credentialsManager, + private CredentialsStore $credentialsStore, + IEventDispatcher $eventDispatcher, + private ILDAPProviderFactory $ldapFactory, + ) { + $this + ->setIdentifier('password::logincredentials') + ->setScheme(self::SCHEME_PASSWORD) + ->setText($l->t('Log-in credentials, save in database')) + ->addParameters([ + (new DefinitionParameter('password', $l->t('Password'))) + ->setType(DefinitionParameter::VALUE_PASSWORD) + ->setFlag(DefinitionParameter::FLAG_HIDDEN) + ->setFlag(DefinitionParameter::FLAG_OPTIONAL), + ]); + + $eventDispatcher->addServiceListener(UserLoggedInEvent::class, StorePasswordListener::class); + $eventDispatcher->addServiceListener(PasswordUpdatedEvent::class, StorePasswordListener::class); + } + + private function getCredentials(IUser $user): array { + $credentials = $this->credentialsManager->retrieve($user->getUID(), self::CREDENTIALS_IDENTIFIER); + + if (is_null($credentials)) { + // nothing saved in db, try to get it from the session and save it + try { + $sessionCredentials = $this->credentialsStore->getLoginCredentials(); + + if ($sessionCredentials->getUID() !== $user->getUID()) { + // Can't take the credentials from the session as they are not the same user + throw new CredentialsUnavailableException(); + } + + $credentials = [ + 'user' => $sessionCredentials->getLoginName(), + 'password' => $sessionCredentials->getPassword(), + ]; + + $this->credentialsManager->store($user->getUID(), self::CREDENTIALS_IDENTIFIER, $credentials); + } catch (CredentialsUnavailableException $e) { + throw new InsufficientDataForMeaningfulAnswerException('No login credentials saved'); + } + } + + return $credentials; + } + + /** + * @return void + */ + public function manipulateStorageConfig(StorageConfig &$storage, ?IUser $user = null) { + if (!isset($user)) { + throw new InsufficientDataForMeaningfulAnswerException('No login credentials saved'); + } + $credentials = $this->getCredentials($user); + + $loginKey = $storage->getBackendOption('login_ldap_attr'); + if ($loginKey) { + $backend = $user->getBackend(); + if ($backend instanceof IUserBackend && $backend->getBackendName() === 'LDAP') { + $value = $this->getLdapPropertyForUser($user, $loginKey); + if ($value === null) { + throw new InsufficientDataForMeaningfulAnswerException('Custom ldap attribute not set for user ' . $user->getUID()); + } + $storage->setBackendOption('user', $value); + } else { + throw new InsufficientDataForMeaningfulAnswerException('Custom ldap attribute configured but user ' . $user->getUID() . ' is not an ldap user'); + } + } else { + $storage->setBackendOption('user', $credentials['user']); + } + $storage->setBackendOption('password', $credentials['password']); + } + + private function getLdapPropertyForUser(IUser $user, string $property): ?string { + return $this->ldapFactory->getLDAPProvider()->getUserAttribute($user->getUID(), $property); + } +} diff --git a/apps/files_external/lib/Lib/Auth/Password/Password.php b/apps/files_external/lib/Lib/Auth/Password/Password.php new file mode 100644 index 00000000000..d4291148e3e --- /dev/null +++ b/apps/files_external/lib/Lib/Auth/Password/Password.php @@ -0,0 +1,29 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2018-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Files_External\Lib\Auth\Password; + +use OCA\Files_External\Lib\Auth\AuthMechanism; +use OCA\Files_External\Lib\DefinitionParameter; +use OCP\IL10N; + +/** + * Basic password authentication mechanism + */ +class Password extends AuthMechanism { + public function __construct(IL10N $l) { + $this + ->setIdentifier('password::password') + ->setScheme(self::SCHEME_PASSWORD) + ->setText($l->t('Login and password')) + ->addParameters([ + new DefinitionParameter('user', $l->t('Login')), + (new DefinitionParameter('password', $l->t('Password'))) + ->setType(DefinitionParameter::VALUE_PASSWORD), + ]); + } +} diff --git a/apps/files_external/lib/Lib/Auth/Password/SessionCredentials.php b/apps/files_external/lib/Lib/Auth/Password/SessionCredentials.php new file mode 100644 index 00000000000..8f161073771 --- /dev/null +++ b/apps/files_external/lib/Lib/Auth/Password/SessionCredentials.php @@ -0,0 +1,67 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2017-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Files_External\Lib\Auth\Password; + +use OCA\Files_External\Lib\Auth\AuthMechanism; +use OCA\Files_External\Lib\DefinitionParameter; +use OCA\Files_External\Lib\InsufficientDataForMeaningfulAnswerException; +use OCA\Files_External\Lib\SessionStorageWrapper; +use OCA\Files_External\Lib\StorageConfig; +use OCP\Authentication\Exceptions\CredentialsUnavailableException; +use OCP\Authentication\LoginCredentials\IStore as CredentialsStore; +use OCP\Files\Storage\IStorage; +use OCP\Files\StorageAuthException; +use OCP\IL10N; +use OCP\IUser; + +/** + * Username and password from login credentials, saved in session + */ +class SessionCredentials extends AuthMechanism { + + public function __construct( + IL10N $l, + private CredentialsStore $credentialsStore, + ) { + $this->setIdentifier('password::sessioncredentials') + ->setScheme(self::SCHEME_PASSWORD) + ->setText($l->t('Log-in credentials, save in session')) + ->addParameters([ + (new DefinitionParameter('password', $l->t('Password'))) + ->setType(DefinitionParameter::VALUE_PASSWORD) + ->setFlag(DefinitionParameter::FLAG_HIDDEN) + ->setFlag(DefinitionParameter::FLAG_OPTIONAL), + ]); + } + + /** + * @return void + */ + public function manipulateStorageConfig(StorageConfig &$storage, ?IUser $user = null) { + try { + $credentials = $this->credentialsStore->getLoginCredentials(); + } catch (CredentialsUnavailableException $e) { + throw new InsufficientDataForMeaningfulAnswerException('No session credentials saved'); + } + + if ($user === null) { + throw new StorageAuthException('Session unavailable'); + } + + if ($credentials->getUID() !== $user->getUID()) { + throw new StorageAuthException('Session credentials for storage owner not available'); + } + + $storage->setBackendOption('user', $credentials->getLoginName()); + $storage->setBackendOption('password', $credentials->getPassword()); + } + + public function wrapStorage(IStorage $storage): IStorage { + return new SessionStorageWrapper(['storage' => $storage]); + } +} diff --git a/apps/files_external/lib/Lib/Auth/Password/UserGlobalAuth.php b/apps/files_external/lib/Lib/Auth/Password/UserGlobalAuth.php new file mode 100644 index 00000000000..cb7165261ac --- /dev/null +++ b/apps/files_external/lib/Lib/Auth/Password/UserGlobalAuth.php @@ -0,0 +1,73 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files_External\Lib\Auth\Password; + +use OCA\Files_External\Lib\Auth\AuthMechanism; +use OCA\Files_External\Lib\DefinitionParameter; +use OCA\Files_External\Lib\InsufficientDataForMeaningfulAnswerException; +use OCA\Files_External\Lib\StorageConfig; +use OCA\Files_External\Service\BackendService; +use OCP\IL10N; +use OCP\IUser; +use OCP\Security\ICredentialsManager; + +/** + * User provided Global Username and Password + */ +class UserGlobalAuth extends AuthMechanism { + private const CREDENTIALS_IDENTIFIER = 'password::global'; + + public function __construct( + IL10N $l, + protected ICredentialsManager $credentialsManager, + ) { + $this + ->setIdentifier('password::global::user') + ->setVisibility(BackendService::VISIBILITY_DEFAULT) + ->setScheme(self::SCHEME_PASSWORD) + ->setText($l->t('Global credentials, manually entered')); + } + + public function saveBackendOptions(IUser $user, $id, $backendOptions) { + // backendOptions are set when invoked via Files app + // but they are not set when invoked via ext storage settings + if (!isset($backendOptions['user']) && !isset($backendOptions['password'])) { + return; + } + + if ($backendOptions['password'] === DefinitionParameter::UNMODIFIED_PLACEHOLDER) { + $oldCredentials = $this->credentialsManager->retrieve($user->getUID(), self::CREDENTIALS_IDENTIFIER); + $backendOptions['password'] = $oldCredentials['password']; + } + + // make sure we're not setting any unexpected keys + $credentials = [ + 'user' => $backendOptions['user'], + 'password' => $backendOptions['password'], + ]; + $this->credentialsManager->store($user->getUID(), self::CREDENTIALS_IDENTIFIER, $credentials); + } + + /** + * @return void + */ + public function manipulateStorageConfig(StorageConfig &$storage, ?IUser $user = null) { + if ($user === null) { + throw new InsufficientDataForMeaningfulAnswerException('No credentials saved'); + } + + $uid = $user->getUID(); + $credentials = $this->credentialsManager->retrieve($uid, self::CREDENTIALS_IDENTIFIER); + + if (is_array($credentials)) { + $storage->setBackendOption('user', $credentials['user']); + $storage->setBackendOption('password', $credentials['password']); + } + } +} diff --git a/apps/files_external/lib/Lib/Auth/Password/UserProvided.php b/apps/files_external/lib/Lib/Auth/Password/UserProvided.php new file mode 100644 index 00000000000..b158392f6eb --- /dev/null +++ b/apps/files_external/lib/Lib/Auth/Password/UserProvided.php @@ -0,0 +1,77 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2018-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2015 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Files_External\Lib\Auth\Password; + +use OCA\Files_External\Lib\Auth\AuthMechanism; +use OCA\Files_External\Lib\Auth\IUserProvided; +use OCA\Files_External\Lib\DefinitionParameter; +use OCA\Files_External\Lib\InsufficientDataForMeaningfulAnswerException; +use OCA\Files_External\Lib\StorageConfig; +use OCA\Files_External\Service\BackendService; +use OCP\IL10N; +use OCP\IUser; +use OCP\Security\ICredentialsManager; + +/** + * User provided Username and Password + */ +class UserProvided extends AuthMechanism implements IUserProvided { + public const CREDENTIALS_IDENTIFIER_PREFIX = 'password::userprovided/'; + + public function __construct( + IL10N $l, + protected ICredentialsManager $credentialsManager, + ) { + $this + ->setIdentifier('password::userprovided') + ->setVisibility(BackendService::VISIBILITY_ADMIN) + ->setScheme(self::SCHEME_PASSWORD) + ->setText($l->t('Manually entered, store in database')) + ->addParameters([ + (new DefinitionParameter('user', $l->t('Login'))) + ->setFlag(DefinitionParameter::FLAG_USER_PROVIDED), + (new DefinitionParameter('password', $l->t('Password'))) + ->setType(DefinitionParameter::VALUE_PASSWORD) + ->setFlag(DefinitionParameter::FLAG_USER_PROVIDED), + ]); + } + + private function getCredentialsIdentifier($storageId) { + return self::CREDENTIALS_IDENTIFIER_PREFIX . $storageId; + } + + public function saveBackendOptions(IUser $user, $mountId, array $options) { + if ($options['password'] === DefinitionParameter::UNMODIFIED_PLACEHOLDER) { + $oldCredentials = $this->credentialsManager->retrieve($user->getUID(), $this->getCredentialsIdentifier($mountId)); + $options['password'] = $oldCredentials['password']; + } + + $this->credentialsManager->store($user->getUID(), $this->getCredentialsIdentifier($mountId), [ + 'user' => $options['user'], // explicitly copy the fields we want instead of just passing the entire $options array + 'password' => $options['password'] // this way we prevent users from being able to modify any other field + ]); + } + + /** + * @return void + */ + public function manipulateStorageConfig(StorageConfig &$storage, ?IUser $user = null) { + if (!isset($user)) { + throw new InsufficientDataForMeaningfulAnswerException('No credentials saved'); + } + $uid = $user->getUID(); + $credentials = $this->credentialsManager->retrieve($uid, $this->getCredentialsIdentifier($storage->getId())); + + if (!isset($credentials)) { + throw new InsufficientDataForMeaningfulAnswerException('No credentials saved'); + } + + $storage->setBackendOption('user', $credentials['user']); + $storage->setBackendOption('password', $credentials['password']); + } +} diff --git a/apps/files_external/lib/Lib/Auth/PublicKey/RSA.php b/apps/files_external/lib/Lib/Auth/PublicKey/RSA.php new file mode 100644 index 00000000000..ad95c743d2d --- /dev/null +++ b/apps/files_external/lib/Lib/Auth/PublicKey/RSA.php @@ -0,0 +1,75 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2017-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Files_External\Lib\Auth\PublicKey; + +use OCA\Files_External\Lib\Auth\AuthMechanism; +use OCA\Files_External\Lib\DefinitionParameter; +use OCA\Files_External\Lib\StorageConfig; +use OCP\IConfig; +use OCP\IL10N; +use OCP\IUser; +use phpseclib\Crypt\RSA as RSACrypt; + +/** + * RSA public key authentication + */ +class RSA extends AuthMechanism { + + public function __construct( + IL10N $l, + private IConfig $config, + ) { + $this + ->setIdentifier('publickey::rsa') + ->setScheme(self::SCHEME_PUBLICKEY) + ->setText($l->t('RSA public key')) + ->addParameters([ + new DefinitionParameter('user', $l->t('Login')), + new DefinitionParameter('public_key', $l->t('Public key')), + (new DefinitionParameter('private_key', 'private_key')) + ->setType(DefinitionParameter::VALUE_PASSWORD) + ->setFlag(DefinitionParameter::FLAG_HIDDEN), + ]) + ->addCustomJs('public_key') + ; + } + + /** + * @return void + */ + public function manipulateStorageConfig(StorageConfig &$storage, ?IUser $user = null) { + $auth = new RSACrypt(); + $auth->setPassword($this->config->getSystemValue('secret', '')); + if (!$auth->loadKey($storage->getBackendOption('private_key'))) { + // Add fallback routine for a time where secret was not enforced to be exists + $auth->setPassword(''); + if (!$auth->loadKey($storage->getBackendOption('private_key'))) { + throw new \RuntimeException('unable to load private key'); + } + } + $storage->setBackendOption('public_key_auth', $auth); + } + + /** + * Generate a keypair + * + * @param int $keyLenth + * @return array ['privatekey' => $privateKey, 'publickey' => $publicKey] + */ + public function createKey($keyLength) { + $rsa = new RSACrypt(); + $rsa->setPublicKeyFormat(RSACrypt::PUBLIC_FORMAT_OPENSSH); + $rsa->setPassword($this->config->getSystemValue('secret', '')); + + if ($keyLength !== 1024 && $keyLength !== 2048 && $keyLength !== 4096) { + $keyLength = 1024; + } + + return $rsa->createKey($keyLength); + } +} diff --git a/apps/files_external/lib/Lib/Auth/PublicKey/RSAPrivateKey.php b/apps/files_external/lib/Lib/Auth/PublicKey/RSAPrivateKey.php new file mode 100644 index 00000000000..8f58b71d5ac --- /dev/null +++ b/apps/files_external/lib/Lib/Auth/PublicKey/RSAPrivateKey.php @@ -0,0 +1,54 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files_External\Lib\Auth\PublicKey; + +use OCA\Files_External\Lib\Auth\AuthMechanism; +use OCA\Files_External\Lib\DefinitionParameter; +use OCA\Files_External\Lib\StorageConfig; +use OCP\IConfig; +use OCP\IL10N; +use OCP\IUser; +use phpseclib\Crypt\RSA as RSACrypt; + +/** + * RSA public key authentication + */ +class RSAPrivateKey extends AuthMechanism { + + public function __construct( + IL10N $l, + private IConfig $config, + ) { + $this + ->setIdentifier('publickey::rsa_private') + ->setScheme(self::SCHEME_PUBLICKEY) + ->setText($l->t('RSA private key')) + ->addParameters([ + new DefinitionParameter('user', $l->t('Login')), + (new DefinitionParameter('password', $l->t('Password'))) + ->setFlag(DefinitionParameter::FLAG_OPTIONAL) + ->setType(DefinitionParameter::VALUE_PASSWORD), + new DefinitionParameter('private_key', $l->t('Private key')), + ]); + } + + /** + * @return void + */ + public function manipulateStorageConfig(StorageConfig &$storage, ?IUser $user = null) { + $auth = new RSACrypt(); + $auth->setPassword($this->config->getSystemValue('secret', '')); + if (!$auth->loadKey($storage->getBackendOption('private_key'))) { + // Add fallback routine for a time where secret was not enforced to be exists + $auth->setPassword(''); + if (!$auth->loadKey($storage->getBackendOption('private_key'))) { + throw new \RuntimeException('unable to load private key'); + } + } + $storage->setBackendOption('public_key_auth', $auth); + } +} diff --git a/apps/files_external/lib/Lib/Auth/SMB/KerberosApacheAuth.php b/apps/files_external/lib/Lib/Auth/SMB/KerberosApacheAuth.php new file mode 100644 index 00000000000..26671110294 --- /dev/null +++ b/apps/files_external/lib/Lib/Auth/SMB/KerberosApacheAuth.php @@ -0,0 +1,35 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Files_External\Lib\Auth\SMB; + +use OCA\Files_External\Lib\Auth\AuthMechanism; +use OCA\Files_External\Lib\DefinitionParameter; +use OCP\Authentication\LoginCredentials\IStore; +use OCP\IL10N; + +class KerberosApacheAuth extends AuthMechanism { + public function __construct( + IL10N $l, + private IStore $credentialsStore, + ) { + $realm = new DefinitionParameter('default_realm', 'Default realm'); + $realm + ->setType(DefinitionParameter::VALUE_TEXT) + ->setFlag(DefinitionParameter::FLAG_OPTIONAL) + ->setTooltip($l->t('Kerberos default realm, defaults to "WORKGROUP"')); + $this + ->setIdentifier('smb::kerberosapache') + ->setScheme(self::SCHEME_SMB) + ->setText($l->t('Kerberos ticket Apache mode')) + ->addParameter($realm); + } + + public function getCredentialsStore(): IStore { + return $this->credentialsStore; + } +} diff --git a/apps/files_external/lib/Lib/Auth/SMB/KerberosAuth.php b/apps/files_external/lib/Lib/Auth/SMB/KerberosAuth.php new file mode 100644 index 00000000000..9210209192a --- /dev/null +++ b/apps/files_external/lib/Lib/Auth/SMB/KerberosAuth.php @@ -0,0 +1,19 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files_External\Lib\Auth\SMB; + +use OCA\Files_External\Lib\Auth\AuthMechanism; +use OCP\IL10N; + +class KerberosAuth extends AuthMechanism { + public function __construct(IL10N $l) { + $this + ->setIdentifier('smb::kerberos') + ->setScheme(self::SCHEME_SMB) + ->setText($l->t('Kerberos ticket')); + } +} diff --git a/apps/files_external/lib/Lib/Backend/AmazonS3.php b/apps/files_external/lib/Lib/Backend/AmazonS3.php new file mode 100644 index 00000000000..464b03b55e0 --- /dev/null +++ b/apps/files_external/lib/Lib/Backend/AmazonS3.php @@ -0,0 +1,54 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2017-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Files_External\Lib\Backend; + +use OCA\Files_External\Lib\Auth\AmazonS3\AccessKey; +use OCA\Files_External\Lib\Auth\AuthMechanism; +use OCA\Files_External\Lib\DefinitionParameter; +use OCA\Files_External\Lib\LegacyDependencyCheckPolyfill; +use OCP\IL10N; + +class AmazonS3 extends Backend { + use LegacyDependencyCheckPolyfill; + + public function __construct(IL10N $l, AccessKey $legacyAuth) { + $this + ->setIdentifier('amazons3') + ->addIdentifierAlias('\OC\Files\Storage\AmazonS3') // legacy compat + ->setStorageClass('\OCA\Files_External\Lib\Storage\AmazonS3') + ->setText($l->t('Amazon S3')) + ->addParameters([ + new DefinitionParameter('bucket', $l->t('Bucket')), + (new DefinitionParameter('hostname', $l->t('Hostname'))) + ->setFlag(DefinitionParameter::FLAG_OPTIONAL), + (new DefinitionParameter('port', $l->t('Port'))) + ->setFlag(DefinitionParameter::FLAG_OPTIONAL), + (new DefinitionParameter('region', $l->t('Region'))) + ->setFlag(DefinitionParameter::FLAG_OPTIONAL), + (new DefinitionParameter('storageClass', $l->t('Storage Class'))) + ->setFlag(DefinitionParameter::FLAG_OPTIONAL), + (new DefinitionParameter('use_ssl', $l->t('Enable SSL'))) + ->setType(DefinitionParameter::VALUE_BOOLEAN) + ->setDefaultValue(true), + (new DefinitionParameter('use_path_style', $l->t('Enable Path Style'))) + ->setType(DefinitionParameter::VALUE_BOOLEAN), + (new DefinitionParameter('legacy_auth', $l->t('Legacy (v2) authentication'))) + ->setType(DefinitionParameter::VALUE_BOOLEAN), + (new DefinitionParameter('useMultipartCopy', $l->t('Enable multipart copy'))) + ->setType(DefinitionParameter::VALUE_BOOLEAN) + ->setDefaultValue(true), + (new DefinitionParameter('sse_c_key', $l->t('SSE-C encryption key'))) + ->setType(DefinitionParameter::VALUE_PASSWORD) + ->setFlag(DefinitionParameter::FLAG_OPTIONAL), + ]) + ->addAuthScheme(AccessKey::SCHEME_AMAZONS3_ACCESSKEY) + ->addAuthScheme(AuthMechanism::SCHEME_NULL) + ->setLegacyAuthMechanism($legacyAuth) + ; + } +} diff --git a/apps/files_external/lib/Lib/Backend/Backend.php b/apps/files_external/lib/Lib/Backend/Backend.php new file mode 100644 index 00000000000..f7500ee24a4 --- /dev/null +++ b/apps/files_external/lib/Lib/Backend/Backend.php @@ -0,0 +1,145 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2017-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Files_External\Lib\Backend; + +use OCA\Files_External\Lib\Auth\AuthMechanism; +use OCA\Files_External\Lib\DependencyTrait; +use OCA\Files_External\Lib\FrontendDefinitionTrait; +use OCA\Files_External\Lib\IdentifierTrait; +use OCA\Files_External\Lib\IFrontendDefinition; +use OCA\Files_External\Lib\IIdentifier; +use OCA\Files_External\Lib\PriorityTrait; +use OCA\Files_External\Lib\StorageConfig; +use OCA\Files_External\Lib\StorageModifierTrait; +use OCA\Files_External\Lib\VisibilityTrait; +use OCP\Files\Storage\IStorage; + +/** + * Storage backend + * + * A backend can have services injected during construction, + * such as \OCP\IDB for database operations. This allows a backend + * to perform advanced operations based on provided information. + * + * An authentication scheme defines the parameter interface, common to the + * storage implementation, the backend and the authentication mechanism. + * A storage implementation expects parameters according to the authentication + * scheme, which are provided from the authentication mechanism. + * + * This class uses the following traits: + * - VisibilityTrait + * Restrict usage to admin-only/none + * - FrontendDefinitionTrait + * Specify configuration parameters and other definitions + * - PriorityTrait + * Allow objects to prioritize over others with the same mountpoint + * - DependencyTrait + * The object requires certain dependencies to be met + * - StorageModifierTrait + * Object can affect storage mounting + */ +class Backend implements \JsonSerializable, IIdentifier, IFrontendDefinition { + use VisibilityTrait; + use FrontendDefinitionTrait; + use PriorityTrait; + use DependencyTrait; + use StorageModifierTrait; + use IdentifierTrait; + + /** @var string storage class */ + private $storageClass; + + /** @var array 'scheme' => true, supported authentication schemes */ + private $authSchemes = []; + + /** @var AuthMechanism|callable authentication mechanism fallback */ + private $legacyAuthMechanism; + + /** + * @return class-string<IStorage> + */ + public function getStorageClass() { + return $this->storageClass; + } + + /** + * @param string $class + * @return $this + */ + public function setStorageClass($class) { + $this->storageClass = $class; + return $this; + } + + /** + * @return array + */ + public function getAuthSchemes() { + if (empty($this->authSchemes)) { + return [AuthMechanism::SCHEME_NULL => true]; + } + return $this->authSchemes; + } + + /** + * @param string $scheme + * @return self + */ + public function addAuthScheme($scheme) { + $this->authSchemes[$scheme] = true; + return $this; + } + + /** + * @param array $parameters storage parameters, for dynamic mechanism selection + * @return AuthMechanism + */ + public function getLegacyAuthMechanism(array $parameters = []) { + if (is_callable($this->legacyAuthMechanism)) { + return call_user_func($this->legacyAuthMechanism, $parameters); + } + return $this->legacyAuthMechanism; + } + + public function setLegacyAuthMechanism(AuthMechanism $authMechanism): self { + $this->legacyAuthMechanism = $authMechanism; + return $this; + } + + /** + * @param callable $callback dynamic auth mechanism selection + */ + public function setLegacyAuthMechanismCallback(callable $callback): self { + $this->legacyAuthMechanism = $callback; + return $this; + } + + /** + * Serialize into JSON for client-side JS + */ + public function jsonSerialize(): array { + $data = $this->jsonSerializeDefinition(); + $data += $this->jsonSerializeIdentifier(); + + $data['backend'] = $data['name']; // legacy compat + $data['priority'] = $this->getPriority(); + $data['authSchemes'] = $this->getAuthSchemes(); + + return $data; + } + + /** + * Check if parameters are satisfied in a StorageConfig + * + * @param StorageConfig $storage + * @return bool + */ + public function validateStorage(StorageConfig $storage) { + return $this->validateStorageDefinition($storage); + } +} diff --git a/apps/files_external/lib/Lib/Backend/DAV.php b/apps/files_external/lib/Lib/Backend/DAV.php new file mode 100644 index 00000000000..dea9e7c5e77 --- /dev/null +++ b/apps/files_external/lib/Lib/Backend/DAV.php @@ -0,0 +1,37 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2018-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Files_External\Lib\Backend; + +use OCA\Files_External\Lib\Auth\AuthMechanism; +use OCA\Files_External\Lib\Auth\Password\Password; +use OCA\Files_External\Lib\DefinitionParameter; +use OCA\Files_External\Lib\LegacyDependencyCheckPolyfill; +use OCP\IL10N; + +class DAV extends Backend { + use LegacyDependencyCheckPolyfill; + + public function __construct(IL10N $l, Password $legacyAuth) { + $this + ->setIdentifier('dav') + ->addIdentifierAlias('\OC\Files\Storage\DAV') // legacy compat + ->setStorageClass('\OC\Files\Storage\DAV') + ->setText($l->t('WebDAV')) + ->addParameters([ + new DefinitionParameter('host', $l->t('URL')), + (new DefinitionParameter('root', $l->t('Remote subfolder'))) + ->setFlag(DefinitionParameter::FLAG_OPTIONAL), + (new DefinitionParameter('secure', $l->t('Secure https://'))) + ->setType(DefinitionParameter::VALUE_BOOLEAN) + ->setDefaultValue(true), + ]) + ->addAuthScheme(AuthMechanism::SCHEME_PASSWORD) + ->setLegacyAuthMechanism($legacyAuth) + ; + } +} diff --git a/apps/files_external/lib/Lib/Backend/FTP.php b/apps/files_external/lib/Lib/Backend/FTP.php new file mode 100644 index 00000000000..72a8184c9b9 --- /dev/null +++ b/apps/files_external/lib/Lib/Backend/FTP.php @@ -0,0 +1,39 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2018-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Files_External\Lib\Backend; + +use OCA\Files_External\Lib\Auth\AuthMechanism; +use OCA\Files_External\Lib\Auth\Password\Password; +use OCA\Files_External\Lib\DefinitionParameter; +use OCA\Files_External\Lib\LegacyDependencyCheckPolyfill; +use OCP\IL10N; + +class FTP extends Backend { + use LegacyDependencyCheckPolyfill; + + public function __construct(IL10N $l, Password $legacyAuth) { + $this + ->setIdentifier('ftp') + ->addIdentifierAlias('\OC\Files\Storage\FTP') // legacy compat + ->setStorageClass('\OCA\Files_External\Lib\Storage\FTP') + ->setText($l->t('FTP')) + ->addParameters([ + new DefinitionParameter('host', $l->t('Host')), + (new DefinitionParameter('port', $l->t('Port'))) + ->setFlag(DefinitionParameter::FLAG_OPTIONAL), + (new DefinitionParameter('root', $l->t('Remote subfolder'))) + ->setFlag(DefinitionParameter::FLAG_OPTIONAL), + (new DefinitionParameter('secure', $l->t('Secure ftps://'))) + ->setType(DefinitionParameter::VALUE_BOOLEAN) + ->setDefaultValue(true), + ]) + ->addAuthScheme(AuthMechanism::SCHEME_PASSWORD) + ->setLegacyAuthMechanism($legacyAuth) + ; + } +} diff --git a/apps/files_external/lib/Lib/Backend/InvalidBackend.php b/apps/files_external/lib/Lib/Backend/InvalidBackend.php new file mode 100644 index 00000000000..48912c0e49e --- /dev/null +++ b/apps/files_external/lib/Lib/Backend/InvalidBackend.php @@ -0,0 +1,50 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2017-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud GmbH. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Files_External\Lib\Backend; + +use OCA\Files_External\Lib\StorageConfig; +use OCP\Files\StorageNotAvailableException; +use OCP\IUser; + +/** + * Invalid storage backend representing a backend + * that could not be resolved + */ +class InvalidBackend extends Backend { + + /** + * Constructs a new InvalidBackend with the id of the invalid backend + * for display purposes + * + * @param string $invalidId id of the backend that did not exist + */ + public function __construct( + private $invalidId, + ) { + $this + ->setIdentifier($this->invalidId) + ->setStorageClass('\OC\Files\Storage\FailedStorage') + ->setText('Unknown storage backend ' . $this->invalidId); + } + + /** + * Returns the invalid backend id + * + * @return string invalid backend id + */ + public function getInvalidId() { + return $this->invalidId; + } + + /** + * @return void + */ + public function manipulateStorageConfig(StorageConfig &$storage, ?IUser $user = null) { + $storage->setBackendOption('exception', new \Exception('Unknown storage backend "' . $this->invalidId . '"', StorageNotAvailableException::STATUS_ERROR)); + } +} diff --git a/apps/files_external/lib/Lib/Backend/LegacyBackend.php b/apps/files_external/lib/Lib/Backend/LegacyBackend.php new file mode 100644 index 00000000000..9c7e5b01bc3 --- /dev/null +++ b/apps/files_external/lib/Lib/Backend/LegacyBackend.php @@ -0,0 +1,83 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2018-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Files_External\Lib\Backend; + +use OCA\Files_External\Lib\Auth\Builtin; +use OCA\Files_External\Lib\DefinitionParameter; +use OCA\Files_External\Lib\LegacyDependencyCheckPolyfill; +use OCA\Files_External\Lib\MissingDependency; + +/** + * Legacy compatibility for OCA\Files_External\MountConfig::registerBackend() + */ +class LegacyBackend extends Backend { + use LegacyDependencyCheckPolyfill { + LegacyDependencyCheckPolyfill::checkDependencies as doCheckDependencies; + } + + /** @var bool */ + protected $hasDependencies = false; + + /** + * @param string $class + * @param array $definition + * @param Builtin $authMechanism + */ + public function __construct($class, array $definition, Builtin $authMechanism) { + $this + ->setIdentifier($class) + ->setStorageClass($class) + ->setText($definition['backend']) + ->addAuthScheme(Builtin::SCHEME_BUILTIN) + ->setLegacyAuthMechanism($authMechanism) + ; + + foreach ($definition['configuration'] as $name => $placeholder) { + $flags = DefinitionParameter::FLAG_NONE; + $type = DefinitionParameter::VALUE_TEXT; + if ($placeholder[0] === '&') { + $flags = DefinitionParameter::FLAG_OPTIONAL; + $placeholder = substr($placeholder, 1); + } + switch ($placeholder[0]) { + case '!': + $type = DefinitionParameter::VALUE_BOOLEAN; + $placeholder = substr($placeholder, 1); + break; + case '*': + $type = DefinitionParameter::VALUE_PASSWORD; + $placeholder = substr($placeholder, 1); + break; + } + $this->addParameter((new DefinitionParameter($name, $placeholder)) + ->setType($type) + ->setFlags($flags) + ); + } + + if (isset($definition['priority'])) { + $this->setPriority($definition['priority']); + } + if (isset($definition['custom'])) { + $this->addCustomJs($definition['custom']); + } + if (isset($definition['has_dependencies']) && $definition['has_dependencies']) { + $this->hasDependencies = true; + } + } + + /** + * @return MissingDependency[] + */ + public function checkDependencies() { + if ($this->hasDependencies) { + return $this->doCheckDependencies(); + } + return []; + } +} diff --git a/apps/files_external/lib/Lib/Backend/Local.php b/apps/files_external/lib/Lib/Backend/Local.php new file mode 100644 index 00000000000..56940b8e83b --- /dev/null +++ b/apps/files_external/lib/Lib/Backend/Local.php @@ -0,0 +1,38 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2018-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Files_External\Lib\Backend; + +use OCA\Files_External\Lib\Auth\AuthMechanism; +use OCA\Files_External\Lib\Auth\NullMechanism; +use OCA\Files_External\Lib\DefinitionParameter; +use OCA\Files_External\Lib\StorageConfig; +use OCA\Files_External\Service\BackendService; +use OCP\IL10N; +use OCP\IUser; + +class Local extends Backend { + public function __construct(IL10N $l, NullMechanism $legacyAuth) { + $this + ->setIdentifier('local') + ->addIdentifierAlias('\OC\Files\Storage\Local') // legacy compat + ->setStorageClass('\OC\Files\Storage\Local') + ->setText($l->t('Local')) + ->addParameters([ + new DefinitionParameter('datadir', $l->t('Location')), + ]) + ->setAllowedVisibility(BackendService::VISIBILITY_ADMIN) + ->setPriority(BackendService::PRIORITY_DEFAULT + 50) + ->addAuthScheme(AuthMechanism::SCHEME_NULL) + ->setLegacyAuthMechanism($legacyAuth) + ; + } + + public function manipulateStorageConfig(StorageConfig &$storage, ?IUser $user = null): void { + $storage->setBackendOption('isExternal', true); + } +} diff --git a/apps/files_external/lib/Lib/Backend/OwnCloud.php b/apps/files_external/lib/Lib/Backend/OwnCloud.php new file mode 100644 index 00000000000..0c0e2c6d300 --- /dev/null +++ b/apps/files_external/lib/Lib/Backend/OwnCloud.php @@ -0,0 +1,34 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2017-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Files_External\Lib\Backend; + +use OCA\Files_External\Lib\Auth\AuthMechanism; +use OCA\Files_External\Lib\Auth\Password\Password; +use OCA\Files_External\Lib\DefinitionParameter; +use OCP\IL10N; + +class OwnCloud extends Backend { + public function __construct(IL10N $l, Password $legacyAuth) { + $this + ->setIdentifier('owncloud') + ->addIdentifierAlias('\OC\Files\Storage\OwnCloud') // legacy compat + ->setStorageClass('\OCA\Files_External\Lib\Storage\OwnCloud') + ->setText($l->t('Nextcloud')) + ->addParameters([ + new DefinitionParameter('host', $l->t('URL')), + (new DefinitionParameter('root', $l->t('Remote subfolder'))) + ->setFlag(DefinitionParameter::FLAG_OPTIONAL), + (new DefinitionParameter('secure', $l->t('Secure https://'))) + ->setType(DefinitionParameter::VALUE_BOOLEAN) + ->setDefaultValue(true), + ]) + ->addAuthScheme(AuthMechanism::SCHEME_PASSWORD) + ->setLegacyAuthMechanism($legacyAuth) + ; + } +} diff --git a/apps/files_external/lib/Lib/Backend/SFTP.php b/apps/files_external/lib/Lib/Backend/SFTP.php new file mode 100644 index 00000000000..0926cf7fd93 --- /dev/null +++ b/apps/files_external/lib/Lib/Backend/SFTP.php @@ -0,0 +1,34 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2017-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Files_External\Lib\Backend; + +use OCA\Files_External\Lib\Auth\AuthMechanism; +use OCA\Files_External\Lib\Auth\Password\Password; +use OCA\Files_External\Lib\DefinitionParameter; +use OCP\IL10N; + +class SFTP extends Backend { + public function __construct(IL10N $l, Password $legacyAuth) { + $this + ->setIdentifier('sftp') + ->addIdentifierAlias('\OC\Files\Storage\SFTP') // legacy compat + ->setStorageClass('\OCA\Files_External\Lib\Storage\SFTP') + ->setText($l->t('SFTP')) + ->addParameters([ + new DefinitionParameter('host', $l->t('Host')), + (new DefinitionParameter('port', $l->t('Port'))) + ->setFlag(DefinitionParameter::FLAG_OPTIONAL), + (new DefinitionParameter('root', $l->t('Root'))) + ->setFlag(DefinitionParameter::FLAG_OPTIONAL), + ]) + ->addAuthScheme(AuthMechanism::SCHEME_PASSWORD) + ->addAuthScheme(AuthMechanism::SCHEME_PUBLICKEY) + ->setLegacyAuthMechanism($legacyAuth) + ; + } +} diff --git a/apps/files_external/lib/Lib/Backend/SFTP_Key.php b/apps/files_external/lib/Lib/Backend/SFTP_Key.php new file mode 100644 index 00000000000..278fae3fba7 --- /dev/null +++ b/apps/files_external/lib/Lib/Backend/SFTP_Key.php @@ -0,0 +1,31 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2018-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Files_External\Lib\Backend; + +use OCA\Files_External\Lib\Auth\AuthMechanism; +use OCA\Files_External\Lib\Auth\PublicKey\RSA; +use OCA\Files_External\Lib\DefinitionParameter; +use OCP\IL10N; + +class SFTP_Key extends Backend { + public function __construct(IL10N $l, RSA $legacyAuth, SFTP $sftpBackend) { + $this + ->setIdentifier('\OC\Files\Storage\SFTP_Key') + ->setStorageClass('\OCA\Files_External\Lib\Storage\SFTP') + ->setText($l->t('SFTP with secret key login')) + ->addParameters([ + new DefinitionParameter('host', $l->t('Host')), + (new DefinitionParameter('root', $l->t('Remote subfolder'))) + ->setFlag(DefinitionParameter::FLAG_OPTIONAL), + ]) + ->addAuthScheme(AuthMechanism::SCHEME_PUBLICKEY) + ->setLegacyAuthMechanism($legacyAuth) + ->deprecateTo($sftpBackend) + ; + } +} diff --git a/apps/files_external/lib/Lib/Backend/SMB.php b/apps/files_external/lib/Lib/Backend/SMB.php new file mode 100644 index 00000000000..e86ad98880c --- /dev/null +++ b/apps/files_external/lib/Lib/Backend/SMB.php @@ -0,0 +1,140 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2018-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ + +namespace OCA\Files_External\Lib\Backend; + +use Icewind\SMB\BasicAuth; +use Icewind\SMB\KerberosAuth; +use Icewind\SMB\KerberosTicket; +use Icewind\SMB\Native\NativeServer; +use Icewind\SMB\Wrapped\Server; +use OCA\Files_External\Lib\Auth\AuthMechanism; +use OCA\Files_External\Lib\Auth\Password\Password; +use OCA\Files_External\Lib\Auth\SMB\KerberosApacheAuth as KerberosApacheAuthMechanism; +use OCA\Files_External\Lib\DefinitionParameter; +use OCA\Files_External\Lib\InsufficientDataForMeaningfulAnswerException; +use OCA\Files_External\Lib\MissingDependency; +use OCA\Files_External\Lib\Storage\SystemBridge; +use OCA\Files_External\Lib\StorageConfig; +use OCP\IL10N; +use OCP\IUser; + +class SMB extends Backend { + public function __construct(IL10N $l, Password $legacyAuth) { + $this + ->setIdentifier('smb') + ->addIdentifierAlias('\OC\Files\Storage\SMB')// legacy compat + ->setStorageClass('\OCA\Files_External\Lib\Storage\SMB') + ->setText($l->t('SMB/CIFS')) + ->addParameters([ + new DefinitionParameter('host', $l->t('Host')), + new DefinitionParameter('share', $l->t('Share')), + (new DefinitionParameter('root', $l->t('Remote subfolder'))) + ->setFlag(DefinitionParameter::FLAG_OPTIONAL), + (new DefinitionParameter('domain', $l->t('Domain'))) + ->setFlag(DefinitionParameter::FLAG_OPTIONAL), + (new DefinitionParameter('show_hidden', $l->t('Show hidden files'))) + ->setType(DefinitionParameter::VALUE_BOOLEAN) + ->setFlag(DefinitionParameter::FLAG_OPTIONAL), + (new DefinitionParameter('case_sensitive', $l->t('Case sensitive file system'))) + ->setType(DefinitionParameter::VALUE_BOOLEAN) + ->setFlag(DefinitionParameter::FLAG_OPTIONAL) + ->setDefaultValue(true) + ->setTooltip($l->t('Disabling it will allow to use a case insensitive file system, but comes with a performance penalty')), + (new DefinitionParameter('check_acl', $l->t('Verify ACL access when listing files'))) + ->setType(DefinitionParameter::VALUE_BOOLEAN) + ->setFlag(DefinitionParameter::FLAG_OPTIONAL) + ->setTooltip($l->t("Check the ACL's of each file or folder inside a directory to filter out items where the account has no read permissions, comes with a performance penalty")), + (new DefinitionParameter('timeout', $l->t('Timeout'))) + ->setType(DefinitionParameter::VALUE_TEXT) + ->setFlag(DefinitionParameter::FLAG_OPTIONAL) + ->setFlag(DefinitionParameter::FLAG_HIDDEN), + ]) + ->addAuthScheme(AuthMechanism::SCHEME_PASSWORD) + ->addAuthScheme(AuthMechanism::SCHEME_SMB) + ->setLegacyAuthMechanism($legacyAuth); + } + + public function manipulateStorageConfig(StorageConfig &$storage, ?IUser $user = null): void { + $auth = $storage->getAuthMechanism(); + if ($auth->getScheme() === AuthMechanism::SCHEME_PASSWORD) { + if (!is_string($storage->getBackendOption('user')) || !is_string($storage->getBackendOption('password'))) { + throw new \InvalidArgumentException('user or password is not set'); + } + + $smbAuth = new BasicAuth( + $storage->getBackendOption('user'), + $storage->getBackendOption('domain'), + $storage->getBackendOption('password') + ); + } else { + switch ($auth->getIdentifier()) { + case 'smb::kerberos': + $smbAuth = new KerberosAuth(); + break; + case 'smb::kerberosapache': + if (!$auth instanceof KerberosApacheAuthMechanism) { + throw new \InvalidArgumentException('invalid authentication backend'); + } + $credentialsStore = $auth->getCredentialsStore(); + $kerbAuth = new KerberosAuth(); + $kerbAuth->setTicket(KerberosTicket::fromEnv()); + // check if a kerberos ticket is available, else fallback to session credentials + if ($kerbAuth->getTicket()?->isValid()) { + $smbAuth = $kerbAuth; + } else { + try { + $credentials = $credentialsStore->getLoginCredentials(); + $loginName = $credentials->getLoginName(); + $pass = $credentials->getPassword(); + preg_match('/(.*)@(.*)/', $loginName, $matches); + $realm = $storage->getBackendOption('default_realm'); + if (empty($realm)) { + $realm = 'WORKGROUP'; + } + if (count($matches) === 0) { + $username = $loginName; + $workgroup = $realm; + } else { + [, $username, $workgroup] = $matches; + } + $smbAuth = new BasicAuth( + $username, + $workgroup, + $pass + ); + } catch (\Exception) { + throw new InsufficientDataForMeaningfulAnswerException('No session credentials saved'); + } + } + + break; + default: + throw new \InvalidArgumentException('unknown authentication backend'); + } + } + + $storage->setBackendOption('auth', $smbAuth); + } + + public function checkDependencies(): array { + $system = \OCP\Server::get(SystemBridge::class); + if (NativeServer::available($system)) { + return []; + } elseif (Server::available($system)) { + $missing = new MissingDependency('php-smbclient'); + $missing->setOptional(true); + $missing->setMessage('The php-smbclient library provides improved compatibility and performance for SMB storages.'); + return [$missing]; + } else { + $missing = new MissingDependency('php-smbclient'); + $missing->setMessage('Either the php-smbclient library (preferred) or the smbclient binary is required for SMB storages.'); + return [$missing, new MissingDependency('smbclient')]; + } + } +} diff --git a/apps/files_external/lib/Lib/Backend/SMB_OC.php b/apps/files_external/lib/Lib/Backend/SMB_OC.php new file mode 100644 index 00000000000..bcb8d0fbf16 --- /dev/null +++ b/apps/files_external/lib/Lib/Backend/SMB_OC.php @@ -0,0 +1,57 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2018-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Files_External\Lib\Backend; + +use OCA\Files_External\Lib\Auth\AuthMechanism; +use OCA\Files_External\Lib\Auth\Password\SessionCredentials; +use OCA\Files_External\Lib\DefinitionParameter; +use OCA\Files_External\Lib\LegacyDependencyCheckPolyfill; +use OCA\Files_External\Lib\StorageConfig; +use OCA\Files_External\Service\BackendService; +use OCP\IL10N; +use OCP\IUser; + +/** + * Deprecated SMB_OC class - use SMB with the password::sessioncredentials auth mechanism + */ +class SMB_OC extends Backend { + use LegacyDependencyCheckPolyfill; + + public function __construct(IL10N $l, SessionCredentials $legacyAuth, SMB $smbBackend) { + $this + ->setIdentifier('\OC\Files\Storage\SMB_OC') + ->setStorageClass('\OCA\Files_External\Lib\Storage\SMB') + ->setText($l->t('SMB/CIFS using OC login')) + ->addParameters([ + new DefinitionParameter('host', $l->t('Host')), + (new DefinitionParameter('username_as_share', $l->t('Login as share'))) + ->setType(DefinitionParameter::VALUE_BOOLEAN), + (new DefinitionParameter('share', $l->t('Share'))) + ->setFlag(DefinitionParameter::FLAG_OPTIONAL), + (new DefinitionParameter('root', $l->t('Remote subfolder'))) + ->setFlag(DefinitionParameter::FLAG_OPTIONAL), + ]) + ->setPriority(BackendService::PRIORITY_DEFAULT - 10) + ->addAuthScheme(AuthMechanism::SCHEME_PASSWORD) + ->setLegacyAuthMechanism($legacyAuth) + ->deprecateTo($smbBackend) + ; + } + + /** + * @return void + */ + public function manipulateStorageConfig(StorageConfig &$storage, ?IUser $user = null) { + $username_as_share = ($storage->getBackendOption('username_as_share') === true); + + if ($username_as_share) { + $share = '/' . $storage->getBackendOption('user'); + $storage->setBackendOption('share', $share); + } + } +} diff --git a/apps/files_external/lib/Lib/Backend/Swift.php b/apps/files_external/lib/Lib/Backend/Swift.php new file mode 100644 index 00000000000..37527ba3dbb --- /dev/null +++ b/apps/files_external/lib/Lib/Backend/Swift.php @@ -0,0 +1,43 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2018-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Files_External\Lib\Backend; + +use OCA\Files_External\Lib\Auth\AuthMechanism; +use OCA\Files_External\Lib\Auth\OpenStack\OpenStackV2; +use OCA\Files_External\Lib\Auth\OpenStack\Rackspace; +use OCA\Files_External\Lib\DefinitionParameter; +use OCA\Files_External\Lib\LegacyDependencyCheckPolyfill; +use OCP\IL10N; + +class Swift extends Backend { + use LegacyDependencyCheckPolyfill; + + public function __construct(IL10N $l, OpenStackV2 $openstackAuth, Rackspace $rackspaceAuth) { + $this + ->setIdentifier('swift') + ->addIdentifierAlias('\OC\Files\Storage\Swift') // legacy compat + ->setStorageClass('\OCA\Files_External\Lib\Storage\Swift') + ->setText($l->t('OpenStack Object Storage')) + ->addParameters([ + (new DefinitionParameter('service_name', $l->t('Service name'))) + ->setFlag(DefinitionParameter::FLAG_OPTIONAL), + new DefinitionParameter('region', $l->t('Region')), + new DefinitionParameter('bucket', $l->t('Bucket')), + (new DefinitionParameter('timeout', $l->t('Request timeout (seconds)'))) + ->setFlag(DefinitionParameter::FLAG_OPTIONAL), + ]) + ->addAuthScheme(AuthMechanism::SCHEME_OPENSTACK) + ->setLegacyAuthMechanismCallback(function (array $params) use ($openstackAuth, $rackspaceAuth) { + if (isset($params['options']['key']) && $params['options']['key']) { + return $rackspaceAuth; + } + return $openstackAuth; + }) + ; + } +} diff --git a/apps/files_external/lib/Lib/Config/IAuthMechanismProvider.php b/apps/files_external/lib/Lib/Config/IAuthMechanismProvider.php new file mode 100644 index 00000000000..0c2e90a243c --- /dev/null +++ b/apps/files_external/lib/Lib/Config/IAuthMechanismProvider.php @@ -0,0 +1,23 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2019-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Files_External\Lib\Config; + +use OCA\Files_External\Lib\Auth\AuthMechanism; + +/** + * Provider of external storage auth mechanisms + * @since 9.1.0 + */ +interface IAuthMechanismProvider { + + /** + * @since 9.1.0 + * @return AuthMechanism[] + */ + public function getAuthMechanisms(); +} diff --git a/apps/files_external/lib/Lib/Config/IBackendProvider.php b/apps/files_external/lib/Lib/Config/IBackendProvider.php new file mode 100644 index 00000000000..44c460c3138 --- /dev/null +++ b/apps/files_external/lib/Lib/Config/IBackendProvider.php @@ -0,0 +1,23 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2019-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Files_External\Lib\Config; + +use OCA\Files_External\Lib\Backend\Backend; + +/** + * Provider of external storage backends + * @since 9.1.0 + */ +interface IBackendProvider { + + /** + * @since 9.1.0 + * @return Backend[] + */ + public function getBackends(); +} diff --git a/apps/files_external/lib/Lib/DefinitionParameter.php b/apps/files_external/lib/Lib/DefinitionParameter.php new file mode 100644 index 00000000000..a73dd2df967 --- /dev/null +++ b/apps/files_external/lib/Lib/DefinitionParameter.php @@ -0,0 +1,217 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2019-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Files_External\Lib; + +/** + * Parameter for an external storage definition + */ +class DefinitionParameter implements \JsonSerializable { + // placeholder value for password fields, when the client updates a storage configuration + // placeholder values are ignored and the field is left unmodified + public const UNMODIFIED_PLACEHOLDER = '__unmodified__'; + + /** Value constants */ + public const VALUE_TEXT = 0; + public const VALUE_BOOLEAN = 1; + public const VALUE_PASSWORD = 2; + + /** Flag constants */ + public const FLAG_NONE = 0; + public const FLAG_OPTIONAL = 1; + public const FLAG_USER_PROVIDED = 2; + public const FLAG_HIDDEN = 4; + + /** @var string human-readable parameter tooltip */ + private string $tooltip = ''; + + /** @var int value type, see self::VALUE_* constants */ + private int $type = self::VALUE_TEXT; + + /** @var int flags, see self::FLAG_* constants */ + private int $flags = self::FLAG_NONE; + + /** + * @param string $name parameter name + * @param string $text parameter description + * @param mixed $defaultValue default value + */ + public function __construct( + private string $name, + private string $text, + private $defaultValue = null, + ) { + } + + /** + * @return string + */ + public function getName(): string { + return $this->name; + } + + /** + * @return string + */ + public function getText(): string { + return $this->text; + } + + /** + * Get value type + * + * @return int + */ + public function getType(): int { + return $this->type; + } + + /** + * Set value type + * + * @param int $type + * @return self + */ + public function setType(int $type) { + $this->type = $type; + return $this; + } + + /** + * @return mixed default value + */ + public function getDefaultValue() { + return $this->defaultValue; + } + + /** + * @param mixed $defaultValue default value + * @return self + */ + public function setDefaultValue($defaultValue) { + $this->defaultValue = $defaultValue; + return $this; + } + + /** + * @return string + */ + public function getTypeName(): string { + switch ($this->type) { + case self::VALUE_BOOLEAN: + return 'boolean'; + case self::VALUE_TEXT: + return 'text'; + case self::VALUE_PASSWORD: + return 'password'; + default: + return 'unknown'; + } + } + + /** + * @return int + */ + public function getFlags(): int { + return $this->flags; + } + + /** + * @param int $flags + * @return self + */ + public function setFlags(int $flags) { + $this->flags = $flags; + return $this; + } + + /** + * @param int $flag + * @return self + */ + public function setFlag(int $flag) { + $this->flags |= $flag; + return $this; + } + + /** + * @param int $flag + * @return bool + */ + public function isFlagSet(int $flag): bool { + return (bool)($this->flags & $flag); + } + + /** + * @return string + */ + public function getTooltip(): string { + return $this->tooltip; + } + + /** + * @param string $tooltip + * @return self + */ + public function setTooltip(string $tooltip) { + $this->tooltip = $tooltip; + return $this; + } + + /** + * Serialize into JSON for client-side JS + */ + public function jsonSerialize(): array { + $result = [ + 'value' => $this->getText(), + 'flags' => $this->getFlags(), + 'type' => $this->getType(), + 'tooltip' => $this->getTooltip(), + ]; + $defaultValue = $this->getDefaultValue(); + if ($defaultValue) { + $result['defaultValue'] = $defaultValue; + } + return $result; + } + + public function isOptional(): bool { + return $this->isFlagSet(self::FLAG_OPTIONAL) || $this->isFlagSet(self::FLAG_USER_PROVIDED); + } + + /** + * Validate a parameter value against this + * Convert type as necessary + * + * @param mixed $value Value to check + * @return bool success + */ + public function validateValue(&$value): bool { + switch ($this->getType()) { + case self::VALUE_BOOLEAN: + if (!is_bool($value)) { + switch ($value) { + case 'true': + $value = true; + break; + case 'false': + $value = false; + break; + default: + return false; + } + } + break; + default: + if (!$value && !$this->isOptional()) { + return false; + } + break; + } + return true; + } +} diff --git a/apps/files_external/lib/Lib/DependencyTrait.php b/apps/files_external/lib/Lib/DependencyTrait.php new file mode 100644 index 00000000000..644132b82bc --- /dev/null +++ b/apps/files_external/lib/Lib/DependencyTrait.php @@ -0,0 +1,23 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2018-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Files_External\Lib; + +/** + * Trait for objects that have dependencies for use + */ +trait DependencyTrait { + + /** + * Check if object is valid for use + * + * @return MissingDependency[] Unsatisfied dependencies + */ + public function checkDependencies() { + return []; // no dependencies by default + } +} diff --git a/apps/files_external/lib/Lib/FrontendDefinitionTrait.php b/apps/files_external/lib/Lib/FrontendDefinitionTrait.php new file mode 100644 index 00000000000..0f280d1d486 --- /dev/null +++ b/apps/files_external/lib/Lib/FrontendDefinitionTrait.php @@ -0,0 +1,107 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2017-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Files_External\Lib; + +/** + * Trait for objects that have a frontend representation + */ +trait FrontendDefinitionTrait { + + /** @var string human-readable mechanism name */ + private string $text = ''; + + /** @var array<string, DefinitionParameter> parameters for mechanism */ + private array $parameters = []; + + /** @var string[] custom JS */ + private array $customJs = []; + + public function getText(): string { + return $this->text; + } + + public function setText(string $text): self { + $this->text = $text; + return $this; + } + + public static function lexicalCompare(IFrontendDefinition $a, IFrontendDefinition $b): int { + return strcmp($a->getText(), $b->getText()); + } + + /** + * @return array<string, DefinitionParameter> + */ + public function getParameters(): array { + return $this->parameters; + } + + /** + * @param list<DefinitionParameter> $parameters + */ + public function addParameters(array $parameters): self { + foreach ($parameters as $parameter) { + $this->addParameter($parameter); + } + return $this; + } + + public function addParameter(DefinitionParameter $parameter): self { + $this->parameters[$parameter->getName()] = $parameter; + return $this; + } + + /** + * @return string[] + */ + public function getCustomJs(): array { + return $this->customJs; + } + + /** + * @param string $custom + * @return self + */ + public function addCustomJs(string $custom): self { + $this->customJs[] = $custom; + return $this; + } + + /** + * Serialize into JSON for client-side JS + */ + public function jsonSerializeDefinition(): array { + $configuration = []; + foreach ($this->getParameters() as $parameter) { + $configuration[$parameter->getName()] = $parameter; + } + + $data = [ + 'name' => $this->getText(), + 'configuration' => $configuration, + 'custom' => $this->getCustomJs(), + ]; + return $data; + } + + /** + * Check if parameters are satisfied in a StorageConfig + */ + public function validateStorageDefinition(StorageConfig $storage): bool { + foreach ($this->getParameters() as $name => $parameter) { + $value = $storage->getBackendOption($name); + if (!is_null($value) || !$parameter->isOptional()) { + if (!$parameter->validateValue($value)) { + return false; + } + $storage->setBackendOption($name, $value); + } + } + return true; + } +} diff --git a/apps/files_external/lib/Lib/IFrontendDefinition.php b/apps/files_external/lib/Lib/IFrontendDefinition.php new file mode 100644 index 00000000000..c8b06a1c30b --- /dev/null +++ b/apps/files_external/lib/Lib/IFrontendDefinition.php @@ -0,0 +1,43 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files_External\Lib; + +interface IFrontendDefinition { + + public function getText(): string; + + public function setText(string $text): self; + + /** + * @return array<string, DefinitionParameter> + */ + public function getParameters(): array; + + /** + * @param list<DefinitionParameter> $parameters + */ + public function addParameters(array $parameters): self; + + public function addParameter(DefinitionParameter $parameter): self; + + /** + * @return string[] + */ + public function getCustomJs(): array; + + public function addCustomJs(string $custom): self; + + /** + * Serialize into JSON for client-side JS + */ + public function jsonSerializeDefinition(): array; + + /** + * Check if parameters are satisfied in a StorageConfig + */ + public function validateStorageDefinition(StorageConfig $storage): bool; +} diff --git a/apps/files_external/lib/Lib/IIdentifier.php b/apps/files_external/lib/Lib/IIdentifier.php new file mode 100644 index 00000000000..0677409a3cf --- /dev/null +++ b/apps/files_external/lib/Lib/IIdentifier.php @@ -0,0 +1,14 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files_External\Lib; + +interface IIdentifier { + + public function getIdentifier(): string; + + public function setIdentifier(string $identifier): self; +} diff --git a/apps/files_external/lib/Lib/IdentifierTrait.php b/apps/files_external/lib/Lib/IdentifierTrait.php new file mode 100644 index 00000000000..f5ffde32307 --- /dev/null +++ b/apps/files_external/lib/Lib/IdentifierTrait.php @@ -0,0 +1,63 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2017-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Files_External\Lib; + +/** + * Trait for objects requiring an identifier (and/or identifier aliases) + * Also supports deprecation to a different object, linking the objects + */ +trait IdentifierTrait { + + protected string $identifier = ''; + + /** @var string[] */ + protected array $identifierAliases = []; + protected ?IIdentifier $deprecateTo = null; + + public function getIdentifier(): string { + return $this->identifier; + } + + public function setIdentifier(string $identifier): self { + $this->identifier = $identifier; + $this->identifierAliases[] = $identifier; + return $this; + } + + /** + * @return string[] + */ + public function getIdentifierAliases(): array { + return $this->identifierAliases; + } + + public function addIdentifierAlias(string $alias): self { + $this->identifierAliases[] = $alias; + return $this; + } + + public function getDeprecateTo(): ?IIdentifier { + return $this->deprecateTo; + } + + public function deprecateTo(IIdentifier $destinationObject): self { + $this->deprecateTo = $destinationObject; + return $this; + } + + public function jsonSerializeIdentifier(): array { + $data = [ + 'identifier' => $this->identifier, + 'identifierAliases' => $this->identifierAliases, + ]; + if ($this->deprecateTo) { + $data['deprecateTo'] = $this->deprecateTo->getIdentifier(); + } + return $data; + } +} diff --git a/apps/files_external/lib/Lib/InsufficientDataForMeaningfulAnswerException.php b/apps/files_external/lib/Lib/InsufficientDataForMeaningfulAnswerException.php new file mode 100644 index 00000000000..1e872b35072 --- /dev/null +++ b/apps/files_external/lib/Lib/InsufficientDataForMeaningfulAnswerException.php @@ -0,0 +1,27 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2017-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Files_External\Lib; + +use OCP\Files\StorageNotAvailableException; + +/** + * Authentication mechanism or backend has insufficient data + */ +class InsufficientDataForMeaningfulAnswerException extends StorageNotAvailableException { + /** + * StorageNotAvailableException constructor. + * + * @param string $message + * @param int $code + * @param \Exception|null $previous + * @since 6.0.0 + */ + public function __construct($message = '', $code = self::STATUS_INDETERMINATE, ?\Exception $previous = null) { + parent::__construct($message, $code, $previous); + } +} diff --git a/apps/files_external/lib/Lib/LegacyDependencyCheckPolyfill.php b/apps/files_external/lib/Lib/LegacyDependencyCheckPolyfill.php new file mode 100644 index 00000000000..f6311fae83e --- /dev/null +++ b/apps/files_external/lib/Lib/LegacyDependencyCheckPolyfill.php @@ -0,0 +1,54 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2018-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Files_External\Lib; + +use OCP\Files\Storage\IStorage; + +/** + * Polyfill for checking dependencies using legacy Storage::checkDependencies() + */ +trait LegacyDependencyCheckPolyfill { + + /** + * @return class-string<IStorage> + */ + abstract public function getStorageClass(); + + /** + * Check if object is valid for use + * + * @return MissingDependency[] Unsatisfied dependencies + */ + public function checkDependencies() { + $ret = []; + + $result = call_user_func([$this->getStorageClass(), 'checkDependencies']); + if ($result !== true) { + if (!is_array($result)) { + $result = [$result]; + } + foreach ($result as $key => $value) { + if (!($value instanceof MissingDependency)) { + $module = null; + $message = null; + if (is_numeric($key)) { + $module = $value; + } else { + $module = $key; + $message = $value; + } + $value = new MissingDependency($module); + $value->setMessage($message); + } + $ret[] = $value; + } + } + + return $ret; + } +} diff --git a/apps/files_external/lib/Lib/MissingDependency.php b/apps/files_external/lib/Lib/MissingDependency.php new file mode 100644 index 00000000000..c2da7fcadbf --- /dev/null +++ b/apps/files_external/lib/Lib/MissingDependency.php @@ -0,0 +1,51 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2019-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Files_External\Lib; + +/** + * External storage backend dependency + */ +class MissingDependency { + + /** @var string|null Custom message */ + private ?string $message = null; + private bool $optional = false; + + /** + * @param string $dependency + */ + public function __construct( + private readonly string $dependency, + ) { + } + + public function getDependency(): string { + return $this->dependency; + } + + public function getMessage(): ?string { + return $this->message; + } + + /** + * @param string $message + * @return self + */ + public function setMessage($message) { + $this->message = $message; + return $this; + } + + public function isOptional(): bool { + return $this->optional; + } + + public function setOptional(bool $optional): void { + $this->optional = $optional; + } +} diff --git a/apps/files_external/lib/Lib/Notify/SMBNotifyHandler.php b/apps/files_external/lib/Lib/Notify/SMBNotifyHandler.php new file mode 100644 index 00000000000..2812df6ad6a --- /dev/null +++ b/apps/files_external/lib/Lib/Notify/SMBNotifyHandler.php @@ -0,0 +1,130 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files_External\Lib\Notify; + +use OC\Files\Notify\Change; +use OC\Files\Notify\RenameChange; +use OCP\Files\Notify\IChange; +use OCP\Files\Notify\INotifyHandler; + +class SMBNotifyHandler implements INotifyHandler { + /** + * @var string + */ + private $root; + + private $oldRenamePath = null; + + /** + * SMBNotifyHandler constructor. + * + * @param \Icewind\SMB\INotifyHandler $shareNotifyHandler + * @param string $root + */ + public function __construct( + private \Icewind\SMB\INotifyHandler $shareNotifyHandler, + $root, + ) { + $this->root = str_replace('\\', '/', $root); + } + + private function relativePath($fullPath) { + if ($fullPath === $this->root) { + return ''; + } elseif (substr($fullPath, 0, strlen($this->root)) === $this->root) { + return substr($fullPath, strlen($this->root)); + } else { + return null; + } + } + + public function listen(callable $callback) { + $oldRenamePath = null; + $this->shareNotifyHandler->listen(function (\Icewind\SMB\Change $shareChange) use ($callback) { + $change = $this->mapChange($shareChange); + if (!is_null($change)) { + return $callback($change); + } else { + return true; + } + }); + } + + /** + * Get all changes detected since the start of the notify process or the last call to getChanges + * + * @return IChange[] + */ + public function getChanges() { + $shareChanges = $this->shareNotifyHandler->getChanges(); + $changes = []; + foreach ($shareChanges as $shareChange) { + $change = $this->mapChange($shareChange); + if ($change) { + $changes[] = $change; + } + } + return $changes; + } + + /** + * Stop listening for changes + * + * Note that any pending changes will be discarded + */ + public function stop() { + $this->shareNotifyHandler->stop(); + } + + /** + * @param \Icewind\SMB\Change $change + * @return IChange|null + */ + private function mapChange(\Icewind\SMB\Change $change) { + $path = $this->relativePath($change->getPath()); + if (is_null($path)) { + return null; + } + if ($change->getCode() === \Icewind\SMB\INotifyHandler::NOTIFY_RENAMED_OLD) { + $this->oldRenamePath = $path; + return null; + } + $type = $this->mapNotifyType($change->getCode()); + if (is_null($type)) { + return null; + } + if ($type === IChange::RENAMED) { + if (!is_null($this->oldRenamePath)) { + $result = new RenameChange($type, $this->oldRenamePath, $path); + $this->oldRenamePath = null; + } else { + $result = null; + } + } else { + $result = new Change($type, $path); + } + return $result; + } + + private function mapNotifyType($smbType) { + switch ($smbType) { + case \Icewind\SMB\INotifyHandler::NOTIFY_ADDED: + return IChange::ADDED; + case \Icewind\SMB\INotifyHandler::NOTIFY_REMOVED: + return IChange::REMOVED; + case \Icewind\SMB\INotifyHandler::NOTIFY_MODIFIED: + case \Icewind\SMB\INotifyHandler::NOTIFY_ADDED_STREAM: + case \Icewind\SMB\INotifyHandler::NOTIFY_MODIFIED_STREAM: + case \Icewind\SMB\INotifyHandler::NOTIFY_REMOVED_STREAM: + return IChange::MODIFIED; + case \Icewind\SMB\INotifyHandler::NOTIFY_RENAMED_NEW: + return IChange::RENAMED; + default: + return null; + } + } +} diff --git a/apps/files_external/lib/Lib/PersonalMount.php b/apps/files_external/lib/Lib/PersonalMount.php new file mode 100644 index 00000000000..d9dbddd1449 --- /dev/null +++ b/apps/files_external/lib/Lib/PersonalMount.php @@ -0,0 +1,70 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2018-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Files_External\Lib; + +use OC\Files\Mount\MoveableMount; +use OCA\Files_External\Config\ExternalMountPoint; +use OCA\Files_External\Service\UserStoragesService; +use OCP\Files\Storage\IStorage; +use OCP\Files\Storage\IStorageFactory; + +/** + * Person mount points can be moved by the user + */ +class PersonalMount extends ExternalMountPoint implements MoveableMount { + /** + * @param UserStoragesService $storagesService + * @param int $storageId + * @param IStorage $storage + * @param string $mountpoint + * @param array $arguments (optional) configuration for the storage backend + * @param IStorageFactory $loader + * @param array $mountOptions mount specific options + * @param int $externalStorageId + */ + public function __construct( + protected UserStoragesService $storagesService, + StorageConfig $storageConfig, + /** @var int id of the external storage (mount) (not the numeric id of the resulting storage!) */ + protected $numericExternalStorageId, + $storage, + $mountpoint, + $arguments = null, + $loader = null, + $mountOptions = null, + $mountId = null, + ) { + parent::__construct($storageConfig, $storage, $mountpoint, $arguments, $loader, $mountOptions, $mountId); + } + + /** + * Move the mount point to $target + * + * @param string $target the target mount point + * @return bool + */ + public function moveMount($target) { + $storage = $this->storagesService->getStorage($this->numericExternalStorageId); + // remove "/$user/files" prefix + $targetParts = explode('/', trim($target, '/'), 3); + $storage->setMountPoint($targetParts[2]); + $this->storagesService->updateStorage($storage); + $this->setMountPoint($target); + return true; + } + + /** + * Remove the mount points + * + * @return bool + */ + public function removeMount() { + $this->storagesService->removeStorage($this->numericExternalStorageId); + return true; + } +} diff --git a/apps/files_external/lib/Lib/PriorityTrait.php b/apps/files_external/lib/Lib/PriorityTrait.php new file mode 100644 index 00000000000..fad2c07e58c --- /dev/null +++ b/apps/files_external/lib/Lib/PriorityTrait.php @@ -0,0 +1,35 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2019-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Files_External\Lib; + +use OCA\Files_External\Service\BackendService; + +/** + * Trait to implement priority mechanics for a configuration class + */ +trait PriorityTrait { + + /** @var int initial priority */ + protected $priority = BackendService::PRIORITY_DEFAULT; + + /** + * @return int + */ + public function getPriority() { + return $this->priority; + } + + /** + * @param int $priority + * @return self + */ + public function setPriority($priority) { + $this->priority = $priority; + return $this; + } +} diff --git a/apps/files_external/lib/Lib/SessionStorageWrapper.php b/apps/files_external/lib/Lib/SessionStorageWrapper.php new file mode 100644 index 00000000000..8754041b2fa --- /dev/null +++ b/apps/files_external/lib/Lib/SessionStorageWrapper.php @@ -0,0 +1,25 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2018-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Files_External\Lib; + +use OC\Files\Storage\Wrapper\PermissionsMask; +use OCP\Constants; + +/** + * Wrap Storage in PermissionsMask for session ephemeral use + */ +class SessionStorageWrapper extends PermissionsMask { + /** + * @param array $parameters ['storage' => $storage] + */ + public function __construct(array $parameters) { + // disable sharing permission + $parameters['mask'] = Constants::PERMISSION_ALL & ~Constants::PERMISSION_SHARE; + parent::__construct($parameters); + } +} diff --git a/apps/files_external/lib/Lib/Storage/AmazonS3.php b/apps/files_external/lib/Lib/Storage/AmazonS3.php new file mode 100644 index 00000000000..5dc9e114532 --- /dev/null +++ b/apps/files_external/lib/Lib/Storage/AmazonS3.php @@ -0,0 +1,760 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Files_External\Lib\Storage; + +use Aws\S3\Exception\S3Exception; +use Icewind\Streams\CallbackWrapper; +use Icewind\Streams\CountWrapper; +use Icewind\Streams\IteratorDirectory; +use OC\Files\Cache\CacheEntry; +use OC\Files\ObjectStore\S3ConnectionTrait; +use OC\Files\ObjectStore\S3ObjectTrait; +use OC\Files\Storage\Common; +use OCP\Cache\CappedMemoryCache; +use OCP\Constants; +use OCP\Files\FileInfo; +use OCP\Files\IMimeTypeDetector; +use OCP\ICache; +use OCP\ICacheFactory; +use OCP\ITempManager; +use OCP\Server; +use Psr\Log\LoggerInterface; + +class AmazonS3 extends Common { + use S3ConnectionTrait; + use S3ObjectTrait; + + private LoggerInterface $logger; + + public function needsPartFile(): bool { + return false; + } + + /** @var CappedMemoryCache<array|false> */ + private CappedMemoryCache $objectCache; + + /** @var CappedMemoryCache<bool> */ + private CappedMemoryCache $directoryCache; + + /** @var CappedMemoryCache<array> */ + private CappedMemoryCache $filesCache; + + private IMimeTypeDetector $mimeDetector; + private ?bool $versioningEnabled = null; + private ICache $memCache; + + public function __construct(array $parameters) { + parent::__construct($parameters); + $this->parseParams($parameters); + $this->id = 'amazon::external::' . md5($this->params['hostname'] . ':' . $this->params['bucket'] . ':' . $this->params['key']); + $this->objectCache = new CappedMemoryCache(); + $this->directoryCache = new CappedMemoryCache(); + $this->filesCache = new CappedMemoryCache(); + $this->mimeDetector = Server::get(IMimeTypeDetector::class); + /** @var ICacheFactory $cacheFactory */ + $cacheFactory = Server::get(ICacheFactory::class); + $this->memCache = $cacheFactory->createLocal('s3-external'); + $this->logger = Server::get(LoggerInterface::class); + } + + private function normalizePath(string $path): string { + $path = trim($path, '/'); + + if (!$path) { + $path = '.'; + } + + return $path; + } + + private function isRoot(string $path): bool { + return $path === '.'; + } + + private function cleanKey(string $path): string { + if ($this->isRoot($path)) { + return '/'; + } + return $path; + } + + private function clearCache(): void { + $this->objectCache = new CappedMemoryCache(); + $this->directoryCache = new CappedMemoryCache(); + $this->filesCache = new CappedMemoryCache(); + } + + private function invalidateCache(string $key): void { + unset($this->objectCache[$key]); + $keys = array_keys($this->objectCache->getData()); + $keyLength = strlen($key); + foreach ($keys as $existingKey) { + if (substr($existingKey, 0, $keyLength) === $key) { + unset($this->objectCache[$existingKey]); + } + } + unset($this->filesCache[$key]); + $keys = array_keys($this->directoryCache->getData()); + $keyLength = strlen($key); + foreach ($keys as $existingKey) { + if (substr($existingKey, 0, $keyLength) === $key) { + unset($this->directoryCache[$existingKey]); + } + } + unset($this->directoryCache[$key]); + } + + private function headObject(string $key): array|false { + if (!isset($this->objectCache[$key])) { + try { + $this->objectCache[$key] = $this->getConnection()->headObject([ + 'Bucket' => $this->bucket, + 'Key' => $key + ] + $this->getSSECParameters())->toArray(); + } catch (S3Exception $e) { + if ($e->getStatusCode() >= 500) { + throw $e; + } + $this->objectCache[$key] = false; + } + } + + if (is_array($this->objectCache[$key]) && !isset($this->objectCache[$key]['Key'])) { + /** @psalm-suppress InvalidArgument Psalm doesn't understand nested arrays well */ + $this->objectCache[$key]['Key'] = $key; + } + return $this->objectCache[$key]; + } + + /** + * Return true if directory exists + * + * There are no folders in s3. A folder like structure could be archived + * by prefixing files with the folder name. + * + * Implementation from flysystem-aws-s3-v3: + * https://github.com/thephpleague/flysystem-aws-s3-v3/blob/8241e9cc5b28f981e0d24cdaf9867f14c7498ae4/src/AwsS3Adapter.php#L670-L694 + * + * @throws \Exception + */ + private function doesDirectoryExist(string $path): bool { + if ($path === '.' || $path === '') { + return true; + } + $path = rtrim($path, '/') . '/'; + + if (isset($this->directoryCache[$path])) { + return $this->directoryCache[$path]; + } + try { + // Maybe this isn't an actual key, but a prefix. + // Do a prefix listing of objects to determine. + $result = $this->getConnection()->listObjectsV2([ + 'Bucket' => $this->bucket, + 'Prefix' => $path, + 'MaxKeys' => 1, + ]); + + if (isset($result['Contents'])) { + $this->directoryCache[$path] = true; + return true; + } + + // empty directories have their own object + $object = $this->headObject($path); + + if ($object) { + $this->directoryCache[$path] = true; + return true; + } + } catch (S3Exception $e) { + if ($e->getStatusCode() >= 400 && $e->getStatusCode() < 500) { + $this->directoryCache[$path] = false; + } + throw $e; + } + + + $this->directoryCache[$path] = false; + return false; + } + + protected function remove(string $path): bool { + // remember fileType to reduce http calls + $fileType = $this->filetype($path); + if ($fileType === 'dir') { + return $this->rmdir($path); + } elseif ($fileType === 'file') { + return $this->unlink($path); + } else { + return false; + } + } + + public function mkdir(string $path): bool { + $path = $this->normalizePath($path); + + if ($this->is_dir($path)) { + return false; + } + + try { + $this->getConnection()->putObject([ + 'Bucket' => $this->bucket, + 'Key' => $path . '/', + 'Body' => '', + 'ContentType' => FileInfo::MIMETYPE_FOLDER + ] + $this->getSSECParameters()); + $this->testTimeout(); + } catch (S3Exception $e) { + $this->logger->error($e->getMessage(), [ + 'app' => 'files_external', + 'exception' => $e, + ]); + return false; + } + + $this->invalidateCache($path); + + return true; + } + + public function file_exists(string $path): bool { + return $this->filetype($path) !== false; + } + + + public function rmdir(string $path): bool { + $path = $this->normalizePath($path); + + if ($this->isRoot($path)) { + return $this->clearBucket(); + } + + if (!$this->file_exists($path)) { + return false; + } + + $this->invalidateCache($path); + return $this->batchDelete($path); + } + + protected function clearBucket(): bool { + $this->clearCache(); + return $this->batchDelete(); + } + + private function batchDelete(?string $path = null): bool { + // TODO explore using https://docs.aws.amazon.com/aws-sdk-php/v3/api/class-Aws.S3.BatchDelete.html + $params = [ + 'Bucket' => $this->bucket + ]; + if ($path !== null) { + $params['Prefix'] = $path . '/'; + } + try { + $connection = $this->getConnection(); + // Since there are no real directories on S3, we need + // to delete all objects prefixed with the path. + do { + // instead of the iterator, manually loop over the list ... + $objects = $connection->listObjects($params); + // ... so we can delete the files in batches + if (isset($objects['Contents'])) { + $connection->deleteObjects([ + 'Bucket' => $this->bucket, + 'Delete' => [ + 'Objects' => $objects['Contents'] + ] + ]); + $this->testTimeout(); + } + // we reached the end when the list is no longer truncated + } while ($objects['IsTruncated']); + if ($path !== '' && $path !== null) { + $this->deleteObject($path); + } + } catch (S3Exception $e) { + $this->logger->error($e->getMessage(), [ + 'app' => 'files_external', + 'exception' => $e, + ]); + return false; + } + return true; + } + + public function opendir(string $path) { + try { + $content = iterator_to_array($this->getDirectoryContent($path)); + return IteratorDirectory::wrap(array_map(function (array $item) { + return $item['name']; + }, $content)); + } catch (S3Exception $e) { + return false; + } + } + + public function stat(string $path): array|false { + $path = $this->normalizePath($path); + + if ($this->is_dir($path)) { + $stat = $this->getDirectoryMetaData($path); + } else { + $object = $this->headObject($path); + if ($object === false) { + return false; + } + $stat = $this->objectToMetaData($object); + } + $stat['atime'] = time(); + + return $stat; + } + + /** + * Return content length for object + * + * When the information is already present (e.g. opendir has been called before) + * this value is return. Otherwise a headObject is emitted. + */ + private function getContentLength(string $path): int { + if (isset($this->filesCache[$path])) { + return (int)$this->filesCache[$path]['ContentLength']; + } + + $result = $this->headObject($path); + if (isset($result['ContentLength'])) { + return (int)$result['ContentLength']; + } + + return 0; + } + + /** + * Return last modified for object + * + * When the information is already present (e.g. opendir has been called before) + * this value is return. Otherwise a headObject is emitted. + */ + private function getLastModified(string $path): string { + if (isset($this->filesCache[$path])) { + return $this->filesCache[$path]['LastModified']; + } + + $result = $this->headObject($path); + if (isset($result['LastModified'])) { + return $result['LastModified']; + } + + return 'now'; + } + + public function is_dir(string $path): bool { + $path = $this->normalizePath($path); + + if (isset($this->filesCache[$path])) { + return false; + } + + try { + return $this->doesDirectoryExist($path); + } catch (S3Exception $e) { + $this->logger->error($e->getMessage(), [ + 'app' => 'files_external', + 'exception' => $e, + ]); + return false; + } + } + + public function filetype(string $path): string|false { + $path = $this->normalizePath($path); + + if ($this->isRoot($path)) { + return 'dir'; + } + + try { + if (isset($this->directoryCache[$path]) && $this->directoryCache[$path]) { + return 'dir'; + } + if (isset($this->filesCache[$path]) || $this->headObject($path)) { + return 'file'; + } + if ($this->doesDirectoryExist($path)) { + return 'dir'; + } + } catch (S3Exception $e) { + $this->logger->error($e->getMessage(), [ + 'app' => 'files_external', + 'exception' => $e, + ]); + return false; + } + + return false; + } + + public function getPermissions(string $path): int { + $type = $this->filetype($path); + if (!$type) { + return 0; + } + return $type === 'dir' ? Constants::PERMISSION_ALL : Constants::PERMISSION_ALL - Constants::PERMISSION_CREATE; + } + + public function unlink(string $path): bool { + $path = $this->normalizePath($path); + + if ($this->is_dir($path)) { + return $this->rmdir($path); + } + + try { + $this->deleteObject($path); + $this->invalidateCache($path); + } catch (S3Exception $e) { + $this->logger->error($e->getMessage(), [ + 'app' => 'files_external', + 'exception' => $e, + ]); + return false; + } + + return true; + } + + public function fopen(string $path, string $mode) { + $path = $this->normalizePath($path); + + switch ($mode) { + case 'r': + case 'rb': + // Don't try to fetch empty files + $stat = $this->stat($path); + if (is_array($stat) && isset($stat['size']) && $stat['size'] === 0) { + return fopen('php://memory', $mode); + } + + try { + return $this->readObject($path); + } catch (\Exception $e) { + $this->logger->error($e->getMessage(), [ + 'app' => 'files_external', + 'exception' => $e, + ]); + return false; + } + case 'w': + case 'wb': + $tmpFile = Server::get(ITempManager::class)->getTemporaryFile(); + + $handle = fopen($tmpFile, 'w'); + return CallbackWrapper::wrap($handle, null, null, function () use ($path, $tmpFile): void { + $this->writeBack($tmpFile, $path); + }); + case 'a': + case 'ab': + case 'r+': + case 'w+': + case 'wb+': + case 'a+': + case 'x': + case 'x+': + case 'c': + case 'c+': + if (strrpos($path, '.') !== false) { + $ext = substr($path, strrpos($path, '.')); + } else { + $ext = ''; + } + $tmpFile = Server::get(ITempManager::class)->getTemporaryFile($ext); + if ($this->file_exists($path)) { + $source = $this->readObject($path); + file_put_contents($tmpFile, $source); + } + + $handle = fopen($tmpFile, $mode); + return CallbackWrapper::wrap($handle, null, null, function () use ($path, $tmpFile): void { + $this->writeBack($tmpFile, $path); + }); + } + return false; + } + + public function touch(string $path, ?int $mtime = null): bool { + if (is_null($mtime)) { + $mtime = time(); + } + $metadata = [ + 'lastmodified' => gmdate(\DateTime::RFC1123, $mtime) + ]; + + try { + if ($this->file_exists($path)) { + return false; + } + + $mimeType = $this->mimeDetector->detectPath($path); + $this->getConnection()->putObject([ + 'Bucket' => $this->bucket, + 'Key' => $this->cleanKey($path), + 'Metadata' => $metadata, + 'Body' => '', + 'ContentType' => $mimeType, + 'MetadataDirective' => 'REPLACE', + ] + $this->getSSECParameters()); + $this->testTimeout(); + } catch (S3Exception $e) { + $this->logger->error($e->getMessage(), [ + 'app' => 'files_external', + 'exception' => $e, + ]); + return false; + } + + $this->invalidateCache($path); + return true; + } + + public function copy(string $source, string $target, ?bool $isFile = null): bool { + $source = $this->normalizePath($source); + $target = $this->normalizePath($target); + + if ($isFile === true || $this->is_file($source)) { + try { + $this->copyObject($source, $target, [ + 'StorageClass' => $this->storageClass, + ]); + $this->testTimeout(); + } catch (S3Exception $e) { + $this->logger->error($e->getMessage(), [ + 'app' => 'files_external', + 'exception' => $e, + ]); + return false; + } + } else { + $this->remove($target); + + try { + $this->mkdir($target); + $this->testTimeout(); + } catch (S3Exception $e) { + $this->logger->error($e->getMessage(), [ + 'app' => 'files_external', + 'exception' => $e, + ]); + return false; + } + + foreach ($this->getDirectoryContent($source) as $item) { + $childSource = $source . '/' . $item['name']; + $childTarget = $target . '/' . $item['name']; + $this->copy($childSource, $childTarget, $item['mimetype'] !== FileInfo::MIMETYPE_FOLDER); + } + } + + $this->invalidateCache($target); + + return true; + } + + public function rename(string $source, string $target): bool { + $source = $this->normalizePath($source); + $target = $this->normalizePath($target); + + if ($this->is_file($source)) { + if ($this->copy($source, $target) === false) { + return false; + } + + if ($this->unlink($source) === false) { + $this->unlink($target); + return false; + } + } else { + if ($this->copy($source, $target) === false) { + return false; + } + + if ($this->rmdir($source) === false) { + $this->rmdir($target); + return false; + } + } + + return true; + } + + public function test(): bool { + $this->getConnection()->headBucket([ + 'Bucket' => $this->bucket + ]); + return true; + } + + public function getId(): string { + return $this->id; + } + + public function writeBack(string $tmpFile, string $path): bool { + try { + $source = fopen($tmpFile, 'r'); + $this->writeObject($path, $source, $this->mimeDetector->detectPath($path)); + $this->invalidateCache($path); + + unlink($tmpFile); + return true; + } catch (S3Exception $e) { + $this->logger->error($e->getMessage(), [ + 'app' => 'files_external', + 'exception' => $e, + ]); + return false; + } + } + + /** + * check if curl is installed + */ + public static function checkDependencies(): bool { + return true; + } + + public function getDirectoryContent(string $directory): \Traversable { + $path = $this->normalizePath($directory); + + if ($this->isRoot($path)) { + $path = ''; + } else { + $path .= '/'; + } + + $results = $this->getConnection()->getPaginator('ListObjectsV2', [ + 'Bucket' => $this->bucket, + 'Delimiter' => '/', + 'Prefix' => $path, + ]); + + foreach ($results as $result) { + // sub folders + if (is_array($result['CommonPrefixes'])) { + foreach ($result['CommonPrefixes'] as $prefix) { + $dir = $this->getDirectoryMetaData($prefix['Prefix']); + if ($dir) { + yield $dir; + } + } + } + if (is_array($result['Contents'])) { + foreach ($result['Contents'] as $object) { + $this->objectCache[$object['Key']] = $object; + if ($object['Key'] !== $path) { + yield $this->objectToMetaData($object); + } + } + } + } + } + + private function objectToMetaData(array $object): array { + return [ + 'name' => basename($object['Key']), + 'mimetype' => $this->mimeDetector->detectPath($object['Key']), + 'mtime' => strtotime($object['LastModified']), + 'storage_mtime' => strtotime($object['LastModified']), + 'etag' => trim($object['ETag'], '"'), + 'permissions' => Constants::PERMISSION_ALL - Constants::PERMISSION_CREATE, + 'size' => (int)($object['Size'] ?? $object['ContentLength']), + ]; + } + + private function getDirectoryMetaData(string $path): ?array { + $path = trim($path, '/'); + // when versioning is enabled, delete markers are returned as part of CommonPrefixes + // resulting in "ghost" folders, verify that each folder actually exists + if ($this->versioningEnabled() && !$this->doesDirectoryExist($path)) { + return null; + } + $cacheEntry = $this->getCache()->get($path); + if ($cacheEntry instanceof CacheEntry) { + return $cacheEntry->getData(); + } else { + return [ + 'name' => basename($path), + 'mimetype' => FileInfo::MIMETYPE_FOLDER, + 'mtime' => time(), + 'storage_mtime' => time(), + 'etag' => uniqid(), + 'permissions' => Constants::PERMISSION_ALL, + 'size' => -1, + ]; + } + } + + public function versioningEnabled(): bool { + if ($this->versioningEnabled === null) { + $cached = $this->memCache->get('versioning-enabled::' . $this->getBucket()); + if ($cached === null) { + $this->versioningEnabled = $this->getVersioningStatusFromBucket(); + $this->memCache->set('versioning-enabled::' . $this->getBucket(), $this->versioningEnabled, 60); + } else { + $this->versioningEnabled = $cached; + } + } + return $this->versioningEnabled; + } + + protected function getVersioningStatusFromBucket(): bool { + try { + $result = $this->getConnection()->getBucketVersioning(['Bucket' => $this->getBucket()]); + return $result->get('Status') === 'Enabled'; + } catch (S3Exception $s3Exception) { + // This is needed for compatibility with Storj gateway which does not support versioning yet + if ($s3Exception->getAwsErrorCode() === 'NotImplemented' || $s3Exception->getAwsErrorCode() === 'AccessDenied') { + return false; + } + throw $s3Exception; + } + } + + public function hasUpdated(string $path, int $time): bool { + // for files we can get the proper mtime + if ($path !== '' && $object = $this->headObject($path)) { + $stat = $this->objectToMetaData($object); + return $stat['mtime'] > $time; + } else { + // for directories, the only real option we have is to do a prefix listing and iterate over all objects + // however, since this is just as expensive as just re-scanning the directory, we can simply return true + // and have the scanner figure out if anything has actually changed + return true; + } + } + + public function writeStream(string $path, $stream, ?int $size = null): int { + if ($size === null) { + $size = 0; + // track the number of bytes read from the input stream to return as the number of written bytes. + $stream = CountWrapper::wrap($stream, function (int $writtenSize) use (&$size): void { + $size = $writtenSize; + }); + } + + if (!is_resource($stream)) { + throw new \InvalidArgumentException('Invalid stream provided'); + } + + $path = $this->normalizePath($path); + $this->writeObject($path, $stream, $this->mimeDetector->detectPath($path)); + $this->invalidateCache($path); + + return $size; + } +} diff --git a/apps/files_external/lib/Lib/Storage/FTP.php b/apps/files_external/lib/Lib/Storage/FTP.php new file mode 100644 index 00000000000..944964de7a6 --- /dev/null +++ b/apps/files_external/lib/Lib/Storage/FTP.php @@ -0,0 +1,364 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Files_External\Lib\Storage; + +use Icewind\Streams\CallbackWrapper; +use Icewind\Streams\CountWrapper; +use Icewind\Streams\IteratorDirectory; +use OC\Files\Storage\Common; +use OC\Files\Storage\PolyFill\CopyDirectory; +use OCP\Constants; +use OCP\Files\FileInfo; +use OCP\Files\IMimeTypeDetector; +use OCP\Files\StorageNotAvailableException; +use OCP\ITempManager; +use OCP\Server; +use Psr\Log\LoggerInterface; + +class FTP extends Common { + use CopyDirectory; + + private $root; + private $host; + private $password; + private $username; + private $secure; + private $port; + private $utf8Mode; + + /** @var FtpConnection|null */ + private $connection; + + public function __construct(array $parameters) { + if (isset($parameters['host']) && isset($parameters['user']) && isset($parameters['password'])) { + $this->host = $parameters['host']; + $this->username = $parameters['user']; + $this->password = $parameters['password']; + if (isset($parameters['secure'])) { + if (is_string($parameters['secure'])) { + $this->secure = ($parameters['secure'] === 'true'); + } else { + $this->secure = (bool)$parameters['secure']; + } + } else { + $this->secure = false; + } + $this->root = isset($parameters['root']) ? '/' . ltrim($parameters['root']) : '/'; + $this->port = $parameters['port'] ?? 21; + $this->utf8Mode = isset($parameters['utf8']) && $parameters['utf8']; + } else { + throw new \Exception('Creating ' . self::class . ' storage failed, required parameters not set'); + } + } + + public function __destruct() { + $this->connection = null; + } + + protected function getConnection(): FtpConnection { + if (!$this->connection) { + try { + $this->connection = new FtpConnection( + $this->secure, + $this->host, + $this->port, + $this->username, + $this->password + ); + } catch (\Exception $e) { + throw new StorageNotAvailableException('Failed to create ftp connection', 0, $e); + } + if ($this->utf8Mode) { + if (!$this->connection->setUtf8Mode()) { + throw new StorageNotAvailableException('Could not set UTF-8 mode'); + } + } + } + + return $this->connection; + } + + public function getId(): string { + return 'ftp::' . $this->username . '@' . $this->host . '/' . $this->root; + } + + protected function buildPath(string $path): string { + return rtrim($this->root . '/' . $path, '/'); + } + + public static function checkDependencies(): array|bool { + if (function_exists('ftp_login')) { + return true; + } else { + return ['ftp']; + } + } + + public function filemtime(string $path): int|false { + $result = $this->getConnection()->mdtm($this->buildPath($path)); + + if ($result === -1) { + if ($this->is_dir($path)) { + $list = $this->getConnection()->mlsd($this->buildPath($path)); + if (!$list) { + Server::get(LoggerInterface::class)->warning("Unable to get last modified date for ftp folder ($path), failed to list folder contents"); + return time(); + } + $currentDir = current(array_filter($list, function ($item) { + return $item['type'] === 'cdir'; + })); + if ($currentDir) { + [$modify] = explode('.', $currentDir['modify'] ?? '', 2); + $time = \DateTime::createFromFormat('YmdHis', $modify); + if ($time === false) { + throw new \Exception("Invalid date format for directory: $currentDir"); + } + return $time->getTimestamp(); + } else { + Server::get(LoggerInterface::class)->warning("Unable to get last modified date for ftp folder ($path), folder contents doesn't include current folder"); + return time(); + } + } else { + return false; + } + } else { + return $result; + } + } + + public function filesize(string $path): false|int|float { + $result = $this->getConnection()->size($this->buildPath($path)); + if ($result === -1) { + return false; + } else { + return $result; + } + } + + public function rmdir(string $path): bool { + if ($this->is_dir($path)) { + $result = $this->getConnection()->rmdir($this->buildPath($path)); + // recursive rmdir support depends on the ftp server + if ($result) { + return $result; + } else { + return $this->recursiveRmDir($path); + } + } elseif ($this->is_file($path)) { + return $this->unlink($path); + } else { + return false; + } + } + + private function recursiveRmDir(string $path): bool { + $contents = $this->getDirectoryContent($path); + $result = true; + foreach ($contents as $content) { + if ($content['mimetype'] === FileInfo::MIMETYPE_FOLDER) { + $result = $result && $this->recursiveRmDir($path . '/' . $content['name']); + } else { + $result = $result && $this->getConnection()->delete($this->buildPath($path . '/' . $content['name'])); + } + } + $result = $result && $this->getConnection()->rmdir($this->buildPath($path)); + + return $result; + } + + public function test(): bool { + try { + return $this->getConnection()->systype() !== false; + } catch (\Exception $e) { + return false; + } + } + + public function stat(string $path): array|false { + if (!$this->file_exists($path)) { + return false; + } + return [ + 'mtime' => $this->filemtime($path), + 'size' => $this->filesize($path), + ]; + } + + public function file_exists(string $path): bool { + if ($path === '' || $path === '.' || $path === '/') { + return true; + } + return $this->filetype($path) !== false; + } + + public function unlink(string $path): bool { + switch ($this->filetype($path)) { + case 'dir': + return $this->rmdir($path); + case 'file': + return $this->getConnection()->delete($this->buildPath($path)); + default: + return false; + } + } + + public function opendir(string $path) { + $files = $this->getConnection()->nlist($this->buildPath($path)); + return IteratorDirectory::wrap($files); + } + + public function mkdir(string $path): bool { + if ($this->is_dir($path)) { + return false; + } + return $this->getConnection()->mkdir($this->buildPath($path)) !== false; + } + + public function is_dir(string $path): bool { + if ($path === '') { + return true; + } + if ($this->getConnection()->chdir($this->buildPath($path)) === true) { + $this->getConnection()->chdir('/'); + return true; + } else { + return false; + } + } + + public function is_file(string $path): bool { + return $this->filesize($path) !== false; + } + + public function filetype(string $path): string|false { + if ($this->is_dir($path)) { + return 'dir'; + } elseif ($this->is_file($path)) { + return 'file'; + } else { + return false; + } + } + + public function fopen(string $path, string $mode) { + $useExisting = true; + switch ($mode) { + case 'r': + case 'rb': + return $this->readStream($path); + case 'w': + case 'w+': + case 'wb': + case 'wb+': + $useExisting = false; + // no break + case 'a': + case 'ab': + case 'r+': + case 'a+': + case 'x': + case 'x+': + case 'c': + case 'c+': + //emulate these + if ($useExisting and $this->file_exists($path)) { + if (!$this->isUpdatable($path)) { + return false; + } + $tmpFile = $this->getCachedFile($path); + } else { + if (!$this->isCreatable(dirname($path))) { + return false; + } + $tmpFile = Server::get(ITempManager::class)->getTemporaryFile(); + } + $source = fopen($tmpFile, $mode); + return CallbackWrapper::wrap($source, null, null, function () use ($tmpFile, $path): void { + $this->writeStream($path, fopen($tmpFile, 'r')); + unlink($tmpFile); + }); + } + return false; + } + + public function writeStream(string $path, $stream, ?int $size = null): int { + if ($size === null) { + $stream = CountWrapper::wrap($stream, function ($writtenSize) use (&$size): void { + $size = $writtenSize; + }); + } + + $this->getConnection()->fput($this->buildPath($path), $stream); + fclose($stream); + + return $size; + } + + public function readStream(string $path) { + $stream = fopen('php://temp', 'w+'); + $result = $this->getConnection()->fget($stream, $this->buildPath($path)); + rewind($stream); + + if (!$result) { + fclose($stream); + return false; + } + return $stream; + } + + public function touch(string $path, ?int $mtime = null): bool { + if ($this->file_exists($path)) { + return false; + } else { + $this->file_put_contents($path, ''); + return true; + } + } + + public function rename(string $source, string $target): bool { + $this->unlink($target); + return $this->getConnection()->rename($this->buildPath($source), $this->buildPath($target)); + } + + public function getDirectoryContent(string $directory): \Traversable { + $files = $this->getConnection()->mlsd($this->buildPath($directory)); + $mimeTypeDetector = Server::get(IMimeTypeDetector::class); + + foreach ($files as $file) { + $name = $file['name']; + if ($file['type'] === 'cdir' || $file['type'] === 'pdir') { + continue; + } + $permissions = Constants::PERMISSION_ALL - Constants::PERMISSION_CREATE; + $isDir = $file['type'] === 'dir'; + if ($isDir) { + $permissions += Constants::PERMISSION_CREATE; + } + + $data = []; + $data['mimetype'] = $isDir ? FileInfo::MIMETYPE_FOLDER : $mimeTypeDetector->detectPath($name); + + // strip fractional seconds + [$modify] = explode('.', $file['modify'], 2); + $mtime = \DateTime::createFromFormat('YmdGis', $modify); + $data['mtime'] = $mtime === false ? time() : $mtime->getTimestamp(); + if ($isDir) { + $data['size'] = -1; //unknown + } elseif (isset($file['size'])) { + $data['size'] = $file['size']; + } else { + $data['size'] = $this->filesize($directory . '/' . $name); + } + $data['etag'] = uniqid(); + $data['storage_mtime'] = $data['mtime']; + $data['permissions'] = $permissions; + $data['name'] = $name; + + yield $data; + } + } +} diff --git a/apps/files_external/lib/Lib/Storage/FtpConnection.php b/apps/files_external/lib/Lib/Storage/FtpConnection.php new file mode 100644 index 00000000000..a064bf9b100 --- /dev/null +++ b/apps/files_external/lib/Lib/Storage/FtpConnection.php @@ -0,0 +1,222 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Files_External\Lib\Storage; + +/** + * Low level wrapper around the ftp functions that smooths over some difference between servers + */ +class FtpConnection { + private \FTP\Connection $connection; + + public function __construct(bool $secure, string $hostname, int $port, string $username, string $password) { + if ($secure) { + $connection = ftp_ssl_connect($hostname, $port); + } else { + $connection = ftp_connect($hostname, $port); + } + + if ($connection === false) { + throw new \Exception('Failed to connect to ftp'); + } + + if (ftp_login($connection, $username, $password) === false) { + throw new \Exception('Failed to connect to login to ftp'); + } + + ftp_pasv($connection, true); + $this->connection = $connection; + } + + public function __destruct() { + ftp_close($this->connection); + } + + public function setUtf8Mode(): bool { + $response = ftp_raw($this->connection, 'OPTS UTF8 ON'); + return substr($response[0], 0, 3) === '200'; + } + + public function fput(string $path, $handle) { + return @ftp_fput($this->connection, $path, $handle, FTP_BINARY); + } + + public function fget($handle, string $path) { + return @ftp_fget($this->connection, $handle, $path, FTP_BINARY); + } + + public function mkdir(string $path) { + return @ftp_mkdir($this->connection, $path); + } + + public function chdir(string $path) { + return @ftp_chdir($this->connection, $path); + } + + public function delete(string $path) { + return @ftp_delete($this->connection, $path); + } + + public function rmdir(string $path) { + return @ftp_rmdir($this->connection, $path); + } + + public function rename(string $source, string $target) { + return @ftp_rename($this->connection, $source, $target); + } + + public function mdtm(string $path): int { + $result = @ftp_mdtm($this->connection, $path); + + // filezilla doesn't like empty path with mdtm + if ($result === -1 && $path === '') { + $result = @ftp_mdtm($this->connection, '/'); + } + return $result; + } + + public function size(string $path) { + return @ftp_size($this->connection, $path); + } + + public function systype() { + return @ftp_systype($this->connection); + } + + public function nlist(string $path) { + $files = @ftp_nlist($this->connection, $path); + return array_map(function ($name) { + if (str_contains($name, '/')) { + $name = basename($name); + } + return $name; + }, $files); + } + + public function mlsd(string $path) { + $files = @ftp_mlsd($this->connection, $path); + + if ($files !== false) { + return array_map(function ($file) { + if (str_contains($file['name'], '/')) { + $file['name'] = basename($file['name']); + } + return $file; + }, $files); + } else { + // not all servers support mlsd, in those cases we parse the raw list ourselves + $rawList = @ftp_rawlist($this->connection, '-aln ' . $path); + if ($rawList === false) { + return false; + } + return $this->parseRawList($rawList, $path); + } + } + + // rawlist parsing logic is based on the ftp implementation from https://github.com/thephpleague/flysystem + private function parseRawList(array $rawList, string $directory): array { + return array_map(function ($item) use ($directory) { + return $this->parseRawListItem($item, $directory); + }, $rawList); + } + + private function parseRawListItem(string $item, string $directory): array { + $isWindows = preg_match('/^[0-9]{2,4}-[0-9]{2}-[0-9]{2}/', $item); + + return $isWindows ? $this->parseWindowsItem($item, $directory) : $this->parseUnixItem($item, $directory); + } + + private function parseUnixItem(string $item, string $directory): array { + $item = preg_replace('#\s+#', ' ', $item, 7); + + if (count(explode(' ', $item, 9)) !== 9) { + throw new \RuntimeException("Metadata can't be parsed from item '$item' , not enough parts."); + } + + [$permissions, /* $number */, /* $owner */, /* $group */, $size, $month, $day, $time, $name] = explode(' ', $item, 9); + if ($name === '.') { + $type = 'cdir'; + } elseif ($name === '..') { + $type = 'pdir'; + } else { + $type = substr($permissions, 0, 1) === 'd' ? 'dir' : 'file'; + } + + $parsedDate = (new \DateTime()) + ->setTimestamp(strtotime("$month $day $time")); + $tomorrow = (new \DateTime())->add(new \DateInterval('P1D')); + + // since the provided date doesn't include the year, we either set it to the correct year + // or when the date would otherwise be in the future (by more then 1 day to account for timezone errors) + // we use last year + if ($parsedDate > $tomorrow) { + $parsedDate = $parsedDate->sub(new \DateInterval('P1Y')); + } + + $formattedDate = $parsedDate + ->format('YmdHis'); + + return [ + 'type' => $type, + 'name' => $name, + 'modify' => $formattedDate, + 'perm' => $this->normalizePermissions($permissions), + 'size' => (int)$size, + ]; + } + + private function normalizePermissions(string $permissions) { + $isDir = substr($permissions, 0, 1) === 'd'; + // remove the type identifier and only use owner permissions + $permissions = substr($permissions, 1, 4); + + // map the string rights to the ftp counterparts + $filePermissionsMap = ['r' => 'r', 'w' => 'fadfw']; + $dirPermissionsMap = ['r' => 'e', 'w' => 'flcdmp']; + + $map = $isDir ? $dirPermissionsMap : $filePermissionsMap; + + return array_reduce(str_split($permissions), function ($ftpPermissions, $permission) use ($map) { + if (isset($map[$permission])) { + $ftpPermissions .= $map[$permission]; + } + return $ftpPermissions; + }, ''); + } + + private function parseWindowsItem(string $item, string $directory): array { + $item = preg_replace('#\s+#', ' ', trim($item), 3); + + if (count(explode(' ', $item, 4)) !== 4) { + throw new \RuntimeException("Metadata can't be parsed from item '$item' , not enough parts."); + } + + [$date, $time, $size, $name] = explode(' ', $item, 4); + + // Check for the correct date/time format + $format = strlen($date) === 8 ? 'm-d-yH:iA' : 'Y-m-dH:i'; + $formattedDate = \DateTime::createFromFormat($format, $date . $time)->format('YmdGis'); + + if ($name === '.') { + $type = 'cdir'; + } elseif ($name === '..') { + $type = 'pdir'; + } else { + $type = ($size === '<DIR>') ? 'dir' : 'file'; + } + + return [ + 'type' => $type, + 'name' => $name, + 'modify' => $formattedDate, + 'perm' => ($type === 'file') ? 'adfrw' : 'flcdmpe', + 'size' => (int)$size, + ]; + } +} diff --git a/apps/files_external/lib/Lib/Storage/OwnCloud.php b/apps/files_external/lib/Lib/Storage/OwnCloud.php new file mode 100644 index 00000000000..12c305de750 --- /dev/null +++ b/apps/files_external/lib/Lib/Storage/OwnCloud.php @@ -0,0 +1,63 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2017-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Files_External\Lib\Storage; + +use OC\Files\Storage\DAV; +use OCP\Files\Storage\IDisableEncryptionStorage; +use Sabre\DAV\Client; + +/** + * Nextcloud backend for external storage based on DAV backend. + * + * The Nextcloud URL consists of three parts: + * http://%host/%context/remote.php/webdav/%root + * + */ +class OwnCloud extends DAV implements IDisableEncryptionStorage { + public const OC_URL_SUFFIX = 'remote.php/webdav'; + + public function __construct(array $parameters) { + // extract context path from host if specified + // (owncloud install path on host) + $host = $parameters['host']; + // strip protocol + if (substr($host, 0, 8) === 'https://') { + $host = substr($host, 8); + $parameters['secure'] = true; + } elseif (substr($host, 0, 7) === 'http://') { + $host = substr($host, 7); + $parameters['secure'] = false; + } + $contextPath = ''; + $hostSlashPos = strpos($host, '/'); + if ($hostSlashPos !== false) { + $contextPath = substr($host, $hostSlashPos); + $host = substr($host, 0, $hostSlashPos); + } + + if (!str_ends_with($contextPath, '/')) { + $contextPath .= '/'; + } + + if (isset($parameters['root'])) { + $root = '/' . ltrim($parameters['root'], '/'); + } else { + $root = '/'; + } + + $parameters['host'] = $host; + $parameters['root'] = $contextPath . self::OC_URL_SUFFIX . $root; + $parameters['authType'] = Client::AUTH_BASIC; + + parent::__construct($parameters); + } + + public function needsPartFile(): bool { + return false; + } +} diff --git a/apps/files_external/lib/Lib/Storage/SFTP.php b/apps/files_external/lib/Lib/Storage/SFTP.php new file mode 100644 index 00000000000..a2f5bafcca1 --- /dev/null +++ b/apps/files_external/lib/Lib/Storage/SFTP.php @@ -0,0 +1,523 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2017-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Files_External\Lib\Storage; + +use Icewind\Streams\CallbackWrapper; +use Icewind\Streams\CountWrapper; +use Icewind\Streams\IteratorDirectory; +use Icewind\Streams\RetryWrapper; +use OC\Files\Storage\Common; +use OC\Files\View; +use OCP\Cache\CappedMemoryCache; +use OCP\Constants; +use OCP\Files\FileInfo; +use OCP\Files\IMimeTypeDetector; +use OCP\Server; +use phpseclib\Net\SFTP\Stream; + +/** + * Uses phpseclib's Net\SFTP class and the Net\SFTP\Stream stream wrapper to + * provide access to SFTP servers. + */ +class SFTP extends Common { + private $host; + private $user; + private $root; + private $port = 22; + + private $auth = []; + + /** + * @var \phpseclib\Net\SFTP + */ + protected $client; + private CappedMemoryCache $knownMTimes; + + private IMimeTypeDetector $mimeTypeDetector; + + public const COPY_CHUNK_SIZE = 8 * 1024 * 1024; + + /** + * @param string $host protocol://server:port + * @return array [$server, $port] + */ + private function splitHost(string $host): array { + $input = $host; + if (!str_contains($host, '://')) { + // add a protocol to fix parse_url behavior with ipv6 + $host = 'http://' . $host; + } + + $parsed = parse_url($host); + if (is_array($parsed) && isset($parsed['port'])) { + return [$parsed['host'], $parsed['port']]; + } elseif (is_array($parsed)) { + return [$parsed['host'], 22]; + } else { + return [$input, 22]; + } + } + + public function __construct(array $parameters) { + // Register sftp:// + Stream::register(); + + $parsedHost = $this->splitHost($parameters['host']); + + $this->host = $parsedHost[0]; + $this->port = $parsedHost[1]; + + if (!isset($parameters['user'])) { + throw new \UnexpectedValueException('no authentication parameters specified'); + } + $this->user = $parameters['user']; + + if (isset($parameters['public_key_auth'])) { + $this->auth[] = $parameters['public_key_auth']; + } + if (isset($parameters['password']) && $parameters['password'] !== '') { + $this->auth[] = $parameters['password']; + } + + if ($this->auth === []) { + throw new \UnexpectedValueException('no authentication parameters specified'); + } + + $this->root + = isset($parameters['root']) ? $this->cleanPath($parameters['root']) : '/'; + + $this->root = '/' . ltrim($this->root, '/'); + $this->root = rtrim($this->root, '/') . '/'; + + $this->knownMTimes = new CappedMemoryCache(); + + $this->mimeTypeDetector = Server::get(IMimeTypeDetector::class); + } + + /** + * Returns the connection. + * + * @return \phpseclib\Net\SFTP connected client instance + * @throws \Exception when the connection failed + */ + public function getConnection(): \phpseclib\Net\SFTP { + if (!is_null($this->client)) { + return $this->client; + } + + $hostKeys = $this->readHostKeys(); + $this->client = new \phpseclib\Net\SFTP($this->host, $this->port); + + // The SSH Host Key MUST be verified before login(). + $currentHostKey = $this->client->getServerPublicHostKey(); + if (array_key_exists($this->host, $hostKeys)) { + if ($hostKeys[$this->host] !== $currentHostKey) { + throw new \Exception('Host public key does not match known key'); + } + } else { + $hostKeys[$this->host] = $currentHostKey; + $this->writeHostKeys($hostKeys); + } + + $login = false; + foreach ($this->auth as $auth) { + /** @psalm-suppress TooManyArguments */ + $login = $this->client->login($this->user, $auth); + if ($login === true) { + break; + } + } + + if ($login === false) { + throw new \Exception('Login failed'); + } + return $this->client; + } + + public function test(): bool { + if ( + !isset($this->host) + || !isset($this->user) + ) { + return false; + } + return $this->getConnection()->nlist() !== false; + } + + public function getId(): string { + $id = 'sftp::' . $this->user . '@' . $this->host; + if ($this->port !== 22) { + $id .= ':' . $this->port; + } + // note: this will double the root slash, + // we should not change it to keep compatible with + // old storage ids + $id .= '/' . $this->root; + return $id; + } + + public function getHost(): string { + return $this->host; + } + + public function getRoot(): string { + return $this->root; + } + + public function getUser(): string { + return $this->user; + } + + private function absPath(string $path): string { + return $this->root . $this->cleanPath($path); + } + + private function hostKeysPath(): string|false { + try { + $userId = \OC_User::getUser(); + if ($userId === false) { + return false; + } + + $view = new View('/' . $userId . '/files_external'); + + return $view->getLocalFile('ssh_hostKeys'); + } catch (\Exception $e) { + } + return false; + } + + protected function writeHostKeys(array $keys): bool { + try { + $keyPath = $this->hostKeysPath(); + if ($keyPath && file_exists($keyPath)) { + $fp = fopen($keyPath, 'w'); + foreach ($keys as $host => $key) { + fwrite($fp, $host . '::' . $key . "\n"); + } + fclose($fp); + return true; + } + } catch (\Exception $e) { + } + return false; + } + + protected function readHostKeys(): array { + try { + $keyPath = $this->hostKeysPath(); + if (file_exists($keyPath)) { + $hosts = []; + $keys = []; + $lines = file($keyPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); + if ($lines) { + foreach ($lines as $line) { + $hostKeyArray = explode('::', $line, 2); + if (count($hostKeyArray) === 2) { + $hosts[] = $hostKeyArray[0]; + $keys[] = $hostKeyArray[1]; + } + } + return array_combine($hosts, $keys); + } + } + } catch (\Exception $e) { + } + return []; + } + + public function mkdir(string $path): bool { + try { + return $this->getConnection()->mkdir($this->absPath($path)); + } catch (\Exception $e) { + return false; + } + } + + public function rmdir(string $path): bool { + try { + $result = $this->getConnection()->delete($this->absPath($path), true); + // workaround: stray stat cache entry when deleting empty folders + // see https://github.com/phpseclib/phpseclib/issues/706 + $this->getConnection()->clearStatCache(); + return $result; + } catch (\Exception $e) { + return false; + } + } + + public function opendir(string $path) { + try { + $list = $this->getConnection()->nlist($this->absPath($path)); + if ($list === false) { + return false; + } + + $id = md5('sftp:' . $path); + $dirStream = []; + foreach ($list as $file) { + if ($file !== '.' && $file !== '..') { + $dirStream[] = $file; + } + } + return IteratorDirectory::wrap($dirStream); + } catch (\Exception $e) { + return false; + } + } + + public function filetype(string $path): string|false { + try { + $stat = $this->getConnection()->stat($this->absPath($path)); + if (!is_array($stat) || !array_key_exists('type', $stat)) { + return false; + } + if ((int)$stat['type'] === NET_SFTP_TYPE_REGULAR) { + return 'file'; + } + + if ((int)$stat['type'] === NET_SFTP_TYPE_DIRECTORY) { + return 'dir'; + } + } catch (\Exception $e) { + } + return false; + } + + public function file_exists(string $path): bool { + try { + return $this->getConnection()->stat($this->absPath($path)) !== false; + } catch (\Exception $e) { + return false; + } + } + + public function unlink(string $path): bool { + try { + return $this->getConnection()->delete($this->absPath($path), true); + } catch (\Exception $e) { + return false; + } + } + + public function fopen(string $path, string $mode) { + $path = $this->cleanPath($path); + try { + $absPath = $this->absPath($path); + $connection = $this->getConnection(); + switch ($mode) { + case 'r': + case 'rb': + $stat = $this->stat($path); + if (!$stat) { + return false; + } + SFTPReadStream::register(); + $context = stream_context_create(['sftp' => ['session' => $connection, 'size' => $stat['size']]]); + $handle = fopen('sftpread://' . trim($absPath, '/'), 'r', false, $context); + return RetryWrapper::wrap($handle); + case 'w': + case 'wb': + SFTPWriteStream::register(); + // the SFTPWriteStream doesn't go through the "normal" methods so it doesn't clear the stat cache. + $connection->_remove_from_stat_cache($absPath); + $context = stream_context_create(['sftp' => ['session' => $connection]]); + $fh = fopen('sftpwrite://' . trim($absPath, '/'), 'w', false, $context); + if ($fh) { + $fh = CallbackWrapper::wrap($fh, null, null, function () use ($path): void { + $this->knownMTimes->set($path, time()); + }); + } + return $fh; + case 'a': + case 'ab': + case 'r+': + case 'w+': + case 'wb+': + case 'a+': + case 'x': + case 'x+': + case 'c': + case 'c+': + $context = stream_context_create(['sftp' => ['session' => $connection]]); + $handle = fopen($this->constructUrl($path), $mode, false, $context); + return RetryWrapper::wrap($handle); + } + } catch (\Exception $e) { + } + return false; + } + + public function touch(string $path, ?int $mtime = null): bool { + try { + if (!is_null($mtime)) { + return false; + } + if (!$this->file_exists($path)) { + return $this->getConnection()->put($this->absPath($path), ''); + } else { + return false; + } + } catch (\Exception $e) { + return false; + } + } + + /** + * @throws \Exception + */ + public function getFile(string $path, string $target): void { + $this->getConnection()->get($path, $target); + } + + public function rename(string $source, string $target): bool { + try { + if ($this->file_exists($target)) { + $this->unlink($target); + } + return $this->getConnection()->rename( + $this->absPath($source), + $this->absPath($target) + ); + } catch (\Exception $e) { + return false; + } + } + + /** + * @return array{mtime: int, size: int, ctime: int}|false + */ + public function stat(string $path): array|false { + try { + $path = $this->cleanPath($path); + $stat = $this->getConnection()->stat($this->absPath($path)); + + $mtime = isset($stat['mtime']) ? (int)$stat['mtime'] : -1; + $size = isset($stat['size']) ? (int)$stat['size'] : 0; + + // the mtime can't be less than when we last touched it + if ($knownMTime = $this->knownMTimes->get($path)) { + $mtime = max($mtime, $knownMTime); + } + + return [ + 'mtime' => $mtime, + 'size' => $size, + 'ctime' => -1 + ]; + } catch (\Exception $e) { + return false; + } + } + + public function constructUrl(string $path): string { + // Do not pass the password here. We want to use the Net_SFTP object + // supplied via stream context or fail. We only supply username and + // hostname because this might show up in logs (they are not used). + $url = 'sftp://' . urlencode($this->user) . '@' . $this->host . ':' . $this->port . $this->root . $path; + return $url; + } + + public function file_put_contents(string $path, mixed $data): int|float|false { + /** @psalm-suppress InternalMethod */ + $result = $this->getConnection()->put($this->absPath($path), $data); + if ($result) { + return strlen($data); + } else { + return false; + } + } + + public function writeStream(string $path, $stream, ?int $size = null): int { + if ($size === null) { + $stream = CountWrapper::wrap($stream, function (int $writtenSize) use (&$size): void { + $size = $writtenSize; + }); + if (!$stream) { + throw new \Exception('Failed to wrap stream'); + } + } + /** @psalm-suppress InternalMethod */ + $result = $this->getConnection()->put($this->absPath($path), $stream); + fclose($stream); + if ($result) { + if ($size === null) { + throw new \Exception('Failed to get written size from sftp storage wrapper'); + } + return $size; + } else { + throw new \Exception('Failed to write steam to sftp storage'); + } + } + + public function copy(string $source, string $target): bool { + if ($this->is_dir($source) || $this->is_dir($target)) { + return parent::copy($source, $target); + } else { + $absSource = $this->absPath($source); + $absTarget = $this->absPath($target); + + $connection = $this->getConnection(); + $size = $connection->size($absSource); + if ($size === false) { + return false; + } + for ($i = 0; $i < $size; $i += self::COPY_CHUNK_SIZE) { + /** @psalm-suppress InvalidArgument */ + $chunk = $connection->get($absSource, false, $i, self::COPY_CHUNK_SIZE); + if ($chunk === false) { + return false; + } + /** @psalm-suppress InternalMethod */ + if (!$connection->put($absTarget, $chunk, \phpseclib\Net\SFTP::SOURCE_STRING, $i)) { + return false; + } + } + return true; + } + } + + public function getPermissions(string $path): int { + $stat = $this->getConnection()->stat($this->absPath($path)); + if (!$stat) { + return 0; + } + if ($stat['type'] === NET_SFTP_TYPE_DIRECTORY) { + return Constants::PERMISSION_ALL; + } else { + return Constants::PERMISSION_ALL - Constants::PERMISSION_CREATE; + } + } + + public function getMetaData(string $path): ?array { + $stat = $this->getConnection()->stat($this->absPath($path)); + if (!$stat) { + return null; + } + + if ($stat['type'] === NET_SFTP_TYPE_DIRECTORY) { + $stat['permissions'] = Constants::PERMISSION_ALL; + } else { + $stat['permissions'] = Constants::PERMISSION_ALL - Constants::PERMISSION_CREATE; + } + + if ($stat['type'] === NET_SFTP_TYPE_DIRECTORY) { + $stat['size'] = -1; + $stat['mimetype'] = FileInfo::MIMETYPE_FOLDER; + } else { + $stat['mimetype'] = $this->mimeTypeDetector->detectPath($path); + } + + $stat['etag'] = $this->getETag($path); + $stat['storage_mtime'] = $stat['mtime']; + $stat['name'] = basename($path); + + $keys = ['size', 'mtime', 'mimetype', 'etag', 'storage_mtime', 'permissions', 'name']; + return array_intersect_key($stat, array_flip($keys)); + } +} diff --git a/apps/files_external/lib/Lib/Storage/SFTPReadStream.php b/apps/files_external/lib/Lib/Storage/SFTPReadStream.php new file mode 100644 index 00000000000..7dedbd7035a --- /dev/null +++ b/apps/files_external/lib/Lib/Storage/SFTPReadStream.php @@ -0,0 +1,217 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files_External\Lib\Storage; + +use Icewind\Streams\File; +use phpseclib\Net\SSH2; + +class SFTPReadStream implements File { + /** @var resource */ + public $context; + + /** @var \phpseclib\Net\SFTP */ + private $sftp; + + /** @var string */ + private $handle; + + /** @var int */ + private $internalPosition = 0; + + /** @var int */ + private $readPosition = 0; + + /** @var bool */ + private $eof = false; + + private $buffer = ''; + private bool $pendingRead = false; + private int $size = 0; + + public static function register($protocol = 'sftpread') { + if (in_array($protocol, stream_get_wrappers(), true)) { + return false; + } + return stream_wrapper_register($protocol, get_called_class()); + } + + /** + * Load the source from the stream context and return the context options + * + * @throws \BadMethodCallException + */ + protected function loadContext(string $name) { + $context = stream_context_get_options($this->context); + if (isset($context[$name])) { + $context = $context[$name]; + } else { + throw new \BadMethodCallException('Invalid context, "' . $name . '" options not set'); + } + if (isset($context['session']) and $context['session'] instanceof \phpseclib\Net\SFTP) { + $this->sftp = $context['session']; + } else { + throw new \BadMethodCallException('Invalid context, session not set'); + } + if (isset($context['size'])) { + $this->size = $context['size']; + } + return $context; + } + + public function stream_open($path, $mode, $options, &$opened_path) { + [, $path] = explode('://', $path); + $path = '/' . ltrim($path); + $path = str_replace('//', '/', $path); + + $this->loadContext('sftp'); + + if (!($this->sftp->bitmap & SSH2::MASK_LOGIN)) { + return false; + } + + $remote_file = $this->sftp->_realpath($path); + if ($remote_file === false) { + return false; + } + + $packet = pack('Na*N2', strlen($remote_file), $remote_file, NET_SFTP_OPEN_READ, 0); + if (!$this->sftp->_send_sftp_packet(NET_SFTP_OPEN, $packet)) { + return false; + } + + $response = $this->sftp->_get_sftp_packet(); + switch ($this->sftp->packet_type) { + case NET_SFTP_HANDLE: + $this->handle = substr($response, 4); + break; + case NET_SFTP_STATUS: // presumably SSH_FX_NO_SUCH_FILE or SSH_FX_PERMISSION_DENIED + $this->sftp->_logError($response); + return false; + default: + user_error('Expected SSH_FXP_HANDLE or SSH_FXP_STATUS'); + return false; + } + + $this->request_chunk(256 * 1024); + + return true; + } + + public function stream_seek($offset, $whence = SEEK_SET) { + switch ($whence) { + case SEEK_SET: + $this->seekTo($offset); + break; + case SEEK_CUR: + $this->seekTo($this->readPosition + $offset); + break; + case SEEK_END: + $this->seekTo($this->size + $offset); + break; + } + return true; + } + + private function seekTo(int $offset): void { + $this->internalPosition = $offset; + $this->readPosition = $offset; + $this->buffer = ''; + $this->request_chunk(256 * 1024); + } + + public function stream_tell() { + return $this->readPosition; + } + + public function stream_read($count) { + if (!$this->eof && strlen($this->buffer) < $count) { + $chunk = $this->read_chunk(); + $this->buffer .= $chunk; + if (!$this->eof) { + $this->request_chunk(256 * 1024); + } + } + + $data = substr($this->buffer, 0, $count); + $this->buffer = substr($this->buffer, $count); + $this->readPosition += strlen($data); + + return $data; + } + + private function request_chunk(int $size) { + if ($this->pendingRead) { + $this->sftp->_get_sftp_packet(); + } + + $packet = pack('Na*N3', strlen($this->handle), $this->handle, $this->internalPosition / 4294967296, $this->internalPosition, $size); + $this->pendingRead = true; + return $this->sftp->_send_sftp_packet(NET_SFTP_READ, $packet); + } + + private function read_chunk() { + $this->pendingRead = false; + $response = $this->sftp->_get_sftp_packet(); + + switch ($this->sftp->packet_type) { + case NET_SFTP_DATA: + $temp = substr($response, 4); + $len = strlen($temp); + $this->internalPosition += $len; + return $temp; + case NET_SFTP_STATUS: + [1 => $status] = unpack('N', substr($response, 0, 4)); + if ($status == NET_SFTP_STATUS_EOF) { + $this->eof = true; + } + return ''; + default: + return ''; + } + } + + public function stream_write($data) { + return false; + } + + public function stream_set_option($option, $arg1, $arg2) { + return false; + } + + public function stream_truncate($size) { + return false; + } + + public function stream_stat() { + return false; + } + + public function stream_lock($operation) { + return false; + } + + public function stream_flush() { + return false; + } + + public function stream_eof() { + return $this->eof; + } + + public function stream_close() { + // we still have a read request incoming that needs to be handled before we can close + if ($this->pendingRead) { + $this->sftp->_get_sftp_packet(); + } + if (!$this->sftp->_close_handle($this->handle)) { + return false; + } + return true; + } +} diff --git a/apps/files_external/lib/Lib/Storage/SFTPWriteStream.php b/apps/files_external/lib/Lib/Storage/SFTPWriteStream.php new file mode 100644 index 00000000000..d64e89b5462 --- /dev/null +++ b/apps/files_external/lib/Lib/Storage/SFTPWriteStream.php @@ -0,0 +1,165 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files_External\Lib\Storage; + +use Icewind\Streams\File; +use phpseclib\Net\SSH2; + +class SFTPWriteStream implements File { + /** @var resource */ + public $context; + + /** @var \phpseclib\Net\SFTP */ + private $sftp; + + /** @var string */ + private $handle; + + /** @var int */ + private $internalPosition = 0; + + /** @var int */ + private $writePosition = 0; + + /** @var bool */ + private $eof = false; + + private $buffer = ''; + + public static function register($protocol = 'sftpwrite') { + if (in_array($protocol, stream_get_wrappers(), true)) { + return false; + } + return stream_wrapper_register($protocol, get_called_class()); + } + + /** + * Load the source from the stream context and return the context options + * + * @throws \BadMethodCallException + */ + protected function loadContext(string $name) { + $context = stream_context_get_options($this->context); + if (isset($context[$name])) { + $context = $context[$name]; + } else { + throw new \BadMethodCallException('Invalid context, "' . $name . '" options not set'); + } + if (isset($context['session']) and $context['session'] instanceof \phpseclib\Net\SFTP) { + $this->sftp = $context['session']; + } else { + throw new \BadMethodCallException('Invalid context, session not set'); + } + return $context; + } + + public function stream_open($path, $mode, $options, &$opened_path) { + [, $path] = explode('://', $path); + $path = '/' . ltrim($path); + $path = str_replace('//', '/', $path); + + $this->loadContext('sftp'); + + if (!($this->sftp->bitmap & SSH2::MASK_LOGIN)) { + return false; + } + + $remote_file = $this->sftp->_realpath($path); + if ($remote_file === false) { + return false; + } + + $packet = pack('Na*N2', strlen($remote_file), $remote_file, NET_SFTP_OPEN_WRITE | NET_SFTP_OPEN_CREATE | NET_SFTP_OPEN_TRUNCATE, 0); + if (!$this->sftp->_send_sftp_packet(NET_SFTP_OPEN, $packet)) { + return false; + } + + $response = $this->sftp->_get_sftp_packet(); + switch ($this->sftp->packet_type) { + case NET_SFTP_HANDLE: + $this->handle = substr($response, 4); + break; + case NET_SFTP_STATUS: // presumably SSH_FX_NO_SUCH_FILE or SSH_FX_PERMISSION_DENIED + $this->sftp->_logError($response); + return false; + default: + user_error('Expected SSH_FXP_HANDLE or SSH_FXP_STATUS'); + return false; + } + + return true; + } + + public function stream_seek($offset, $whence = SEEK_SET) { + return false; + } + + public function stream_tell() { + return $this->writePosition; + } + + public function stream_read($count) { + return false; + } + + public function stream_write($data) { + $written = strlen($data); + $this->writePosition += $written; + + $this->buffer .= $data; + + if (strlen($this->buffer) > 64 * 1024) { + if (!$this->stream_flush()) { + return false; + } + } + + return $written; + } + + public function stream_set_option($option, $arg1, $arg2) { + return false; + } + + public function stream_truncate($size) { + return false; + } + + public function stream_stat() { + return false; + } + + public function stream_lock($operation) { + return false; + } + + public function stream_flush() { + $size = strlen($this->buffer); + $packet = pack('Na*N3a*', strlen($this->handle), $this->handle, $this->internalPosition / 4294967296, $this->internalPosition, $size, $this->buffer); + if (!$this->sftp->_send_sftp_packet(NET_SFTP_WRITE, $packet)) { + return false; + } + $this->internalPosition += $size; + $this->buffer = ''; + + return $this->sftp->_read_put_responses(1); + } + + public function stream_eof() { + return $this->eof; + } + + public function stream_close() { + $this->stream_flush(); + if (!$this->sftp->_close_handle($this->handle)) { + return false; + } + return true; + } +} diff --git a/apps/files_external/lib/Lib/Storage/SMB.php b/apps/files_external/lib/Lib/Storage/SMB.php new file mode 100644 index 00000000000..8f8750864e1 --- /dev/null +++ b/apps/files_external/lib/Lib/Storage/SMB.php @@ -0,0 +1,727 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ + +namespace OCA\Files_External\Lib\Storage; + +use Icewind\SMB\ACL; +use Icewind\SMB\BasicAuth; +use Icewind\SMB\Exception\AlreadyExistsException; +use Icewind\SMB\Exception\ConnectException; +use Icewind\SMB\Exception\Exception; +use Icewind\SMB\Exception\ForbiddenException; +use Icewind\SMB\Exception\InvalidArgumentException; +use Icewind\SMB\Exception\InvalidTypeException; +use Icewind\SMB\Exception\NotFoundException; +use Icewind\SMB\Exception\OutOfSpaceException; +use Icewind\SMB\Exception\TimedOutException; +use Icewind\SMB\IFileInfo; +use Icewind\SMB\Native\NativeServer; +use Icewind\SMB\Options; +use Icewind\SMB\ServerFactory; +use Icewind\SMB\Wrapped\Server; +use Icewind\Streams\CallbackWrapper; +use Icewind\Streams\IteratorDirectory; +use OC\Files\Filesystem; +use OC\Files\Storage\Common; +use OCA\Files_External\Lib\Notify\SMBNotifyHandler; +use OCP\Cache\CappedMemoryCache; +use OCP\Constants; +use OCP\Files\EntityTooLargeException; +use OCP\Files\IMimeTypeDetector; +use OCP\Files\Notify\IChange; +use OCP\Files\Notify\IRenameChange; +use OCP\Files\NotPermittedException; +use OCP\Files\Storage\INotifyStorage; +use OCP\Files\StorageAuthException; +use OCP\Files\StorageNotAvailableException; +use OCP\ITempManager; +use Psr\Log\LoggerInterface; + +class SMB extends Common implements INotifyStorage { + /** + * @var \Icewind\SMB\IServer + */ + protected $server; + + /** + * @var \Icewind\SMB\IShare + */ + protected $share; + + /** + * @var string + */ + protected $root; + + /** @var CappedMemoryCache<IFileInfo> */ + protected CappedMemoryCache $statCache; + + /** @var LoggerInterface */ + protected $logger; + + /** @var bool */ + protected $showHidden; + + private bool $caseSensitive; + + /** @var bool */ + protected $checkAcl; + + public function __construct(array $parameters) { + if (!isset($parameters['host'])) { + throw new \Exception('Invalid configuration, no host provided'); + } + + if (isset($parameters['auth'])) { + $auth = $parameters['auth']; + } elseif (isset($parameters['user']) && isset($parameters['password']) && isset($parameters['share'])) { + [$workgroup, $user] = $this->splitUser($parameters['user']); + $auth = new BasicAuth($user, $workgroup, $parameters['password']); + } else { + throw new \Exception('Invalid configuration, no credentials provided'); + } + + if (isset($parameters['logger'])) { + if (!$parameters['logger'] instanceof LoggerInterface) { + throw new \Exception( + 'Invalid logger. Got ' + . get_class($parameters['logger']) + . ' Expected ' . LoggerInterface::class + ); + } + $this->logger = $parameters['logger']; + } else { + $this->logger = \OCP\Server::get(LoggerInterface::class); + } + + $options = new Options(); + if (isset($parameters['timeout'])) { + $timeout = (int)$parameters['timeout']; + if ($timeout > 0) { + $options->setTimeout($timeout); + } + } + $system = \OCP\Server::get(SystemBridge::class); + $serverFactory = new ServerFactory($options, $system); + $this->server = $serverFactory->createServer($parameters['host'], $auth); + $this->share = $this->server->getShare(trim($parameters['share'], '/')); + + $this->root = $parameters['root'] ?? '/'; + $this->root = '/' . ltrim($this->root, '/'); + $this->root = rtrim($this->root, '/') . '/'; + + $this->showHidden = isset($parameters['show_hidden']) && $parameters['show_hidden']; + $this->caseSensitive = (bool)($parameters['case_sensitive'] ?? true); + $this->checkAcl = isset($parameters['check_acl']) && $parameters['check_acl']; + + $this->statCache = new CappedMemoryCache(); + parent::__construct($parameters); + } + + private function splitUser(string $user): array { + if (str_contains($user, '/')) { + return explode('/', $user, 2); + } elseif (str_contains($user, '\\')) { + return explode('\\', $user); + } + + return [null, $user]; + } + + public function getId(): string { + // FIXME: double slash to keep compatible with the old storage ids, + // failure to do so will lead to creation of a new storage id and + // loss of shares from the storage + return 'smb::' . $this->server->getAuth()->getUsername() . '@' . $this->server->getHost() . '//' . $this->share->getName() . '/' . $this->root; + } + + protected function buildPath(string $path): string { + return Filesystem::normalizePath($this->root . '/' . $path, true, false, true); + } + + protected function relativePath(string $fullPath): ?string { + if ($fullPath === $this->root) { + return ''; + } elseif (substr($fullPath, 0, strlen($this->root)) === $this->root) { + return substr($fullPath, strlen($this->root)); + } else { + return null; + } + } + + /** + * @throws StorageAuthException + * @throws \OCP\Files\NotFoundException + * @throws \OCP\Files\ForbiddenException + */ + protected function getFileInfo(string $path): IFileInfo { + try { + $path = $this->buildPath($path); + $cached = $this->statCache[$path] ?? null; + if ($cached instanceof IFileInfo) { + return $cached; + } else { + $stat = $this->share->stat($path); + $this->statCache[$path] = $stat; + return $stat; + } + } catch (ConnectException $e) { + $this->throwUnavailable($e); + } catch (NotFoundException $e) { + throw new \OCP\Files\NotFoundException($e->getMessage(), 0, $e); + } catch (ForbiddenException $e) { + // with php-smbclient, this exception is thrown when the provided password is invalid. + // Possible is also ForbiddenException with a different error code, so we check it. + if ($e->getCode() === 1) { + $this->throwUnavailable($e); + } + throw new \OCP\Files\ForbiddenException($e->getMessage(), false, $e); + } + } + + /** + * @throws StorageAuthException + */ + protected function throwUnavailable(\Exception $e): never { + $this->logger->error('Error while getting file info', ['exception' => $e]); + throw new StorageAuthException($e->getMessage(), $e); + } + + /** + * get the acl from fileinfo that is relevant for the configured user + */ + private function getACL(IFileInfo $file): ?ACL { + try { + $acls = $file->getAcls(); + } catch (Exception $e) { + $this->logger->warning('Error while getting file acls', ['exception' => $e]); + return null; + } + foreach ($acls as $user => $acl) { + [, $user] = $this->splitUser($user); // strip domain + if ($user === $this->server->getAuth()->getUsername()) { + return $acl; + } + } + + return null; + } + + /** + * @return \Generator<IFileInfo> + * @throws StorageNotAvailableException + */ + protected function getFolderContents(string $path): iterable { + try { + $path = ltrim($this->buildPath($path), '/'); + try { + $files = $this->share->dir($path); + } catch (ForbiddenException $e) { + $this->logger->critical($e->getMessage(), ['exception' => $e]); + throw new NotPermittedException(); + } catch (InvalidTypeException $e) { + return; + } + foreach ($files as $file) { + $this->statCache[$path . '/' . $file->getName()] = $file; + } + + foreach ($files as $file) { + try { + // the isHidden check is done before checking the config boolean to ensure that the metadata is always fetch + // so we trigger the below exceptions where applicable + $hide = $file->isHidden() && !$this->showHidden; + + if ($this->checkAcl && $acl = $this->getACL($file)) { + // if there is no explicit deny, we assume it's allowed + // this doesn't take inheritance fully into account but if read permissions is denied for a parent we wouldn't be in this folder + // additionally, it's better to have false negatives here then false positives + if ($acl->denies(ACL::MASK_READ) || $acl->denies(ACL::MASK_EXECUTE)) { + $this->logger->debug('Hiding non readable entry ' . $file->getName()); + continue; + } + } + + if ($hide) { + $this->logger->debug('hiding hidden file ' . $file->getName()); + } + if (!$hide) { + yield $file; + } + } catch (ForbiddenException $e) { + $this->logger->debug($e->getMessage(), ['exception' => $e]); + } catch (NotFoundException $e) { + $this->logger->debug('Hiding forbidden entry ' . $file->getName(), ['exception' => $e]); + } + } + } catch (ConnectException $e) { + $this->logger->error('Error while getting folder content', ['exception' => $e]); + throw new StorageNotAvailableException($e->getMessage(), (int)$e->getCode(), $e); + } catch (NotFoundException $e) { + throw new \OCP\Files\NotFoundException($e->getMessage(), 0, $e); + } + } + + protected function formatInfo(IFileInfo $info): array { + $result = [ + 'size' => $info->getSize(), + 'mtime' => $info->getMTime(), + ]; + if ($info->isDirectory()) { + $result['type'] = 'dir'; + } else { + $result['type'] = 'file'; + } + return $result; + } + + /** + * Rename the files. If the source or the target is the root, the rename won't happen. + * + * @param string $source the old name of the path + * @param string $target the new name of the path + */ + public function rename(string $source, string $target, bool $retry = true): bool { + if ($this->isRootDir($source) || $this->isRootDir($target)) { + return false; + } + if ($this->caseSensitive === false + && mb_strtolower($target) === mb_strtolower($source) + ) { + // Forbid changing case only on case-insensitive file system + return false; + } + + $absoluteSource = $this->buildPath($source); + $absoluteTarget = $this->buildPath($target); + try { + $result = $this->share->rename($absoluteSource, $absoluteTarget); + } catch (AlreadyExistsException $e) { + if ($retry) { + $this->remove($target); + $result = $this->share->rename($absoluteSource, $absoluteTarget); + } else { + $this->logger->warning($e->getMessage(), ['exception' => $e]); + return false; + } + } catch (InvalidArgumentException $e) { + if ($retry) { + $this->remove($target); + $result = $this->share->rename($absoluteSource, $absoluteTarget); + } else { + $this->logger->warning($e->getMessage(), ['exception' => $e]); + return false; + } + } catch (\Exception $e) { + $this->logger->warning($e->getMessage(), ['exception' => $e]); + return false; + } + unset($this->statCache[$absoluteSource], $this->statCache[$absoluteTarget]); + return $result; + } + + public function stat(string $path, bool $retry = true): array|false { + try { + $result = $this->formatInfo($this->getFileInfo($path)); + } catch (\OCP\Files\ForbiddenException $e) { + return false; + } catch (\OCP\Files\NotFoundException $e) { + return false; + } catch (TimedOutException $e) { + if ($retry) { + return $this->stat($path, false); + } else { + throw new StorageNotAvailableException($e->getMessage(), $e->getCode(), $e); + } + } + if ($this->remoteIsShare() && $this->isRootDir($path)) { + $result['mtime'] = $this->shareMTime(); + } + return $result; + } + + /** + * get the best guess for the modification time of the share + */ + private function shareMTime(): int { + $highestMTime = 0; + $files = $this->share->dir($this->root); + foreach ($files as $fileInfo) { + try { + if ($fileInfo->getMTime() > $highestMTime) { + $highestMTime = $fileInfo->getMTime(); + } + } catch (NotFoundException $e) { + // Ignore this, can happen on unavailable DFS shares + } catch (ForbiddenException $e) { + // Ignore this too - it's a symlink + } + } + return $highestMTime; + } + + /** + * Check if the path is our root dir (not the smb one) + */ + private function isRootDir(string $path): bool { + return $path === '' || $path === '/' || $path === '.'; + } + + /** + * Check if our root points to a smb share + */ + private function remoteIsShare(): bool { + return $this->share->getName() && (!$this->root || $this->root === '/'); + } + + public function unlink(string $path): bool { + if ($this->isRootDir($path)) { + return false; + } + + try { + if ($this->is_dir($path)) { + return $this->rmdir($path); + } else { + $path = $this->buildPath($path); + unset($this->statCache[$path]); + $this->share->del($path); + return true; + } + } catch (NotFoundException $e) { + return false; + } catch (ForbiddenException $e) { + return false; + } catch (ConnectException $e) { + $this->logger->error('Error while deleting file', ['exception' => $e]); + throw new StorageNotAvailableException($e->getMessage(), (int)$e->getCode(), $e); + } + } + + /** + * check if a file or folder has been updated since $time + */ + public function hasUpdated(string $path, int $time): bool { + if (!$path and $this->root === '/') { + // mtime doesn't work for shares, but giving the nature of the backend, + // doing a full update is still just fast enough + return true; + } else { + $actualTime = $this->filemtime($path); + return $actualTime > $time || $actualTime === 0; + } + } + + /** + * @return resource|false + */ + public function fopen(string $path, string $mode) { + $fullPath = $this->buildPath($path); + try { + switch ($mode) { + case 'r': + case 'rb': + if (!$this->file_exists($path)) { + $this->logger->warning('Failed to open ' . $path . ' on ' . $this->getId() . ', file doesn\'t exist.'); + return false; + } + return $this->share->read($fullPath); + case 'w': + case 'wb': + $source = $this->share->write($fullPath); + return CallBackWrapper::wrap($source, null, null, function () use ($fullPath): void { + unset($this->statCache[$fullPath]); + }); + case 'a': + case 'ab': + case 'r+': + case 'w+': + case 'wb+': + case 'a+': + case 'x': + case 'x+': + case 'c': + case 'c+': + //emulate these + if (strrpos($path, '.') !== false) { + $ext = substr($path, strrpos($path, '.')); + } else { + $ext = ''; + } + if ($this->file_exists($path)) { + if (!$this->isUpdatable($path)) { + $this->logger->warning('Failed to open ' . $path . ' on ' . $this->getId() . ', file not updatable.'); + return false; + } + $tmpFile = $this->getCachedFile($path); + } else { + if (!$this->isCreatable(dirname($path))) { + $this->logger->warning('Failed to open ' . $path . ' on ' . $this->getId() . ', parent directory not writable.'); + return false; + } + $tmpFile = \OCP\Server::get(ITempManager::class)->getTemporaryFile($ext); + } + $source = fopen($tmpFile, $mode); + $share = $this->share; + return CallbackWrapper::wrap($source, null, null, function () use ($tmpFile, $fullPath, $share): void { + unset($this->statCache[$fullPath]); + $share->put($tmpFile, $fullPath); + unlink($tmpFile); + }); + } + return false; + } catch (NotFoundException $e) { + $this->logger->warning('Failed to open ' . $path . ' on ' . $this->getId() . ', not found.', ['exception' => $e]); + return false; + } catch (ForbiddenException $e) { + $this->logger->warning('Failed to open ' . $path . ' on ' . $this->getId() . ', forbidden.', ['exception' => $e]); + return false; + } catch (OutOfSpaceException $e) { + $this->logger->warning('Failed to open ' . $path . ' on ' . $this->getId() . ', out of space.', ['exception' => $e]); + throw new EntityTooLargeException('not enough available space to create file', 0, $e); + } catch (ConnectException $e) { + $this->logger->error('Error while opening file ' . $path . ' on ' . $this->getId(), ['exception' => $e]); + throw new StorageNotAvailableException($e->getMessage(), (int)$e->getCode(), $e); + } + } + + public function rmdir(string $path): bool { + if ($this->isRootDir($path)) { + return false; + } + + try { + $this->statCache = new CappedMemoryCache(); + $content = $this->share->dir($this->buildPath($path)); + foreach ($content as $file) { + if ($file->isDirectory()) { + $this->rmdir($path . '/' . $file->getName()); + } else { + $this->share->del($file->getPath()); + } + } + $this->share->rmdir($this->buildPath($path)); + return true; + } catch (NotFoundException $e) { + return false; + } catch (ForbiddenException $e) { + return false; + } catch (ConnectException $e) { + $this->logger->error('Error while removing folder', ['exception' => $e]); + throw new StorageNotAvailableException($e->getMessage(), (int)$e->getCode(), $e); + } + } + + public function touch(string $path, ?int $mtime = null): bool { + try { + if (!$this->file_exists($path)) { + $fh = $this->share->write($this->buildPath($path)); + fclose($fh); + return true; + } + return false; + } catch (OutOfSpaceException $e) { + throw new EntityTooLargeException('not enough available space to create file', 0, $e); + } catch (ConnectException $e) { + $this->logger->error('Error while creating file', ['exception' => $e]); + throw new StorageNotAvailableException($e->getMessage(), (int)$e->getCode(), $e); + } + } + + public function getMetaData(string $path): ?array { + try { + $fileInfo = $this->getFileInfo($path); + } catch (\OCP\Files\NotFoundException $e) { + return null; + } catch (\OCP\Files\ForbiddenException $e) { + return null; + } + + return $this->getMetaDataFromFileInfo($fileInfo); + } + + private function getMetaDataFromFileInfo(IFileInfo $fileInfo): array { + $permissions = Constants::PERMISSION_READ + Constants::PERMISSION_SHARE; + + if ( + !$fileInfo->isReadOnly() || $fileInfo->isDirectory() + ) { + $permissions += Constants::PERMISSION_DELETE; + $permissions += Constants::PERMISSION_UPDATE; + if ($fileInfo->isDirectory()) { + $permissions += Constants::PERMISSION_CREATE; + } + } + + $data = []; + if ($fileInfo->isDirectory()) { + $data['mimetype'] = 'httpd/unix-directory'; + } else { + $data['mimetype'] = \OCP\Server::get(IMimeTypeDetector::class)->detectPath($fileInfo->getPath()); + } + $data['mtime'] = $fileInfo->getMTime(); + if ($fileInfo->isDirectory()) { + $data['size'] = -1; //unknown + } else { + $data['size'] = $fileInfo->getSize(); + } + $data['etag'] = $this->getETag($fileInfo->getPath()); + $data['storage_mtime'] = $data['mtime']; + $data['permissions'] = $permissions; + $data['name'] = $fileInfo->getName(); + + return $data; + } + + public function opendir(string $path) { + try { + $files = $this->getFolderContents($path); + } catch (NotFoundException $e) { + return false; + } catch (NotPermittedException $e) { + return false; + } + $names = array_map(function ($info) { + /** @var IFileInfo $info */ + return $info->getName(); + }, iterator_to_array($files)); + return IteratorDirectory::wrap($names); + } + + public function getDirectoryContent(string $directory): \Traversable { + try { + $files = $this->getFolderContents($directory); + foreach ($files as $file) { + yield $this->getMetaDataFromFileInfo($file); + } + } catch (NotFoundException $e) { + return; + } catch (NotPermittedException $e) { + return; + } + } + + public function filetype(string $path): string|false { + try { + return $this->getFileInfo($path)->isDirectory() ? 'dir' : 'file'; + } catch (\OCP\Files\NotFoundException $e) { + return false; + } catch (\OCP\Files\ForbiddenException $e) { + return false; + } + } + + public function mkdir(string $path): bool { + $path = $this->buildPath($path); + try { + $this->share->mkdir($path); + return true; + } catch (ConnectException $e) { + $this->logger->error('Error while creating folder', ['exception' => $e]); + throw new StorageNotAvailableException($e->getMessage(), (int)$e->getCode(), $e); + } catch (Exception $e) { + return false; + } + } + + public function file_exists(string $path): bool { + try { + // Case sensitive filesystem doesn't matter for root directory + if ($this->caseSensitive === false && $path !== '') { + $filename = basename($path); + $siblings = $this->getDirectoryContent(dirname($path)); + foreach ($siblings as $sibling) { + if ($sibling['name'] === $filename) { + return true; + } + } + return false; + } + $this->getFileInfo($path); + return true; + } catch (\OCP\Files\NotFoundException $e) { + return false; + } catch (\OCP\Files\ForbiddenException $e) { + return false; + } catch (ConnectException $e) { + throw new StorageNotAvailableException($e->getMessage(), (int)$e->getCode(), $e); + } + } + + public function isReadable(string $path): bool { + try { + $info = $this->getFileInfo($path); + return $this->showHidden || !$info->isHidden(); + } catch (\OCP\Files\NotFoundException $e) { + return false; + } catch (\OCP\Files\ForbiddenException $e) { + return false; + } + } + + public function isUpdatable(string $path): bool { + try { + $info = $this->getFileInfo($path); + // following windows behaviour for read-only folders: they can be written into + // (https://support.microsoft.com/en-us/kb/326549 - "cause" section) + return ($this->showHidden || !$info->isHidden()) && (!$info->isReadOnly() || $info->isDirectory()); + } catch (\OCP\Files\NotFoundException $e) { + return false; + } catch (\OCP\Files\ForbiddenException $e) { + return false; + } + } + + public function isDeletable(string $path): bool { + try { + $info = $this->getFileInfo($path); + return ($this->showHidden || !$info->isHidden()) && !$info->isReadOnly(); + } catch (\OCP\Files\NotFoundException $e) { + return false; + } catch (\OCP\Files\ForbiddenException $e) { + return false; + } + } + + /** + * check if smbclient is installed + */ + public static function checkDependencies(): array|bool { + $system = \OCP\Server::get(SystemBridge::class); + return Server::available($system) || NativeServer::available($system) ?: ['smbclient']; + } + + public function test(): bool { + try { + return parent::test(); + } catch (StorageAuthException $e) { + return false; + } catch (ForbiddenException $e) { + return false; + } catch (Exception $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + return false; + } + } + + public function listen(string $path, callable $callback): void { + $this->notify($path)->listen(function (IChange $change) use ($callback) { + if ($change instanceof IRenameChange) { + return $callback($change->getType(), $change->getPath(), $change->getTargetPath()); + } else { + return $callback($change->getType(), $change->getPath()); + } + }); + } + + public function notify(string $path): SMBNotifyHandler { + $path = '/' . ltrim($path, '/'); + $shareNotifyHandler = $this->share->notify($this->buildPath($path)); + return new SMBNotifyHandler($shareNotifyHandler, $this->root); + } +} diff --git a/apps/files_external/lib/Lib/Storage/StreamWrapper.php b/apps/files_external/lib/Lib/Storage/StreamWrapper.php new file mode 100644 index 00000000000..1272b9d4d8a --- /dev/null +++ b/apps/files_external/lib/Lib/Storage/StreamWrapper.php @@ -0,0 +1,99 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2020-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Files_External\Lib\Storage; + +use OC\Files\Storage\Common; + +abstract class StreamWrapper extends Common { + + abstract public function constructUrl(string $path): ?string; + + public function mkdir(string $path): bool { + return mkdir($this->constructUrl($path)); + } + + public function rmdir(string $path): bool { + if ($this->is_dir($path) && $this->isDeletable($path)) { + $dh = $this->opendir($path); + if (!is_resource($dh)) { + return false; + } + while (($file = readdir($dh)) !== false) { + if ($this->is_dir($path . '/' . $file)) { + $this->rmdir($path . '/' . $file); + } else { + $this->unlink($path . '/' . $file); + } + } + $url = $this->constructUrl($path); + $success = rmdir($url); + clearstatcache(false, $url); + return $success; + } else { + return false; + } + } + + public function opendir(string $path) { + return opendir($this->constructUrl($path)); + } + + public function filetype(string $path): string|false { + return @filetype($this->constructUrl($path)); + } + + public function file_exists(string $path): bool { + return file_exists($this->constructUrl($path)); + } + + public function unlink(string $path): bool { + $url = $this->constructUrl($path); + $success = unlink($url); + // normally unlink() is supposed to do this implicitly, + // but doing it anyway just to be sure + clearstatcache(false, $url); + return $success; + } + + public function fopen(string $path, string $mode) { + return fopen($this->constructUrl($path), $mode); + } + + public function touch(string $path, ?int $mtime = null): bool { + if ($this->file_exists($path)) { + if (is_null($mtime)) { + $fh = $this->fopen($path, 'a'); + fwrite($fh, ''); + fclose($fh); + + return true; + } else { + return false; //not supported + } + } else { + $this->file_put_contents($path, ''); + return true; + } + } + + public function getFile(string $path, string $target): bool { + return copy($this->constructUrl($path), $target); + } + + public function uploadFile(string $path, string $target): bool { + return copy($path, $this->constructUrl($target)); + } + + public function rename(string $source, string $target): bool { + return rename($this->constructUrl($source), $this->constructUrl($target)); + } + + public function stat(string $path): array|false { + return stat($this->constructUrl($path)); + } +} diff --git a/apps/files_external/lib/Lib/Storage/Swift.php b/apps/files_external/lib/Lib/Storage/Swift.php new file mode 100644 index 00000000000..e80570f14ba --- /dev/null +++ b/apps/files_external/lib/Lib/Storage/Swift.php @@ -0,0 +1,593 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2017-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Files_External\Lib\Storage; + +use GuzzleHttp\Psr7\Uri; +use Icewind\Streams\CallbackWrapper; +use Icewind\Streams\IteratorDirectory; +use OC\Files\Filesystem; +use OC\Files\ObjectStore\SwiftFactory; +use OC\Files\Storage\Common; +use OCP\Cache\CappedMemoryCache; +use OCP\Files\IMimeTypeDetector; +use OCP\Files\StorageAuthException; +use OCP\Files\StorageBadConfigException; +use OCP\Files\StorageNotAvailableException; +use OCP\ICache; +use OCP\ICacheFactory; +use OCP\ITempManager; +use OCP\Server; +use OpenStack\Common\Error\BadResponseError; +use OpenStack\ObjectStore\v1\Models\Container; +use OpenStack\ObjectStore\v1\Models\StorageObject; +use Psr\Log\LoggerInterface; + +class Swift extends Common { + /** @var SwiftFactory */ + private $connectionFactory; + /** + * @var Container + */ + private $container; + /** + * @var string + */ + private $bucket; + /** + * Connection parameters + * + * @var array + */ + private $params; + + /** @var string */ + private $id; + + /** @var \OC\Files\ObjectStore\Swift */ + private $objectStore; + + /** @var IMimeTypeDetector */ + private $mimeDetector; + + /** + * Key value cache mapping path to data object. Maps path to + * \OpenCloud\OpenStack\ObjectStorage\Resource\DataObject for existing + * paths and path to false for not existing paths. + * + * @var ICache + */ + private $objectCache; + + private function normalizePath(string $path): string { + $path = trim($path, '/'); + + if (!$path) { + $path = '.'; + } + + $path = str_replace('#', '%23', $path); + + return $path; + } + + public const SUBCONTAINER_FILE = '.subcontainers'; + + /** + * Fetches an object from the API. + * If the object is cached already or a + * failed "doesn't exist" response was cached, + * that one will be returned. + * + * @return StorageObject|false object + * or false if the object did not exist + * @throws StorageAuthException + * @throws StorageNotAvailableException + */ + private function fetchObject(string $path): StorageObject|false { + $cached = $this->objectCache->get($path); + if ($cached !== null) { + // might be "false" if object did not exist from last check + return $cached; + } + try { + $object = $this->getContainer()->getObject($path); + $object->retrieve(); + $this->objectCache->set($path, $object); + return $object; + } catch (BadResponseError $e) { + // Expected response is "404 Not Found", so only log if it isn't + if ($e->getResponse()->getStatusCode() !== 404) { + Server::get(LoggerInterface::class)->error($e->getMessage(), [ + 'exception' => $e, + 'app' => 'files_external', + ]); + } + $this->objectCache->set($path, false); + return false; + } + } + + /** + * Returns whether the given path exists. + * + * @return bool true if the object exist, false otherwise + * @throws StorageAuthException + * @throws StorageNotAvailableException + */ + private function doesObjectExist(string $path): bool { + return $this->fetchObject($path) !== false; + } + + public function __construct(array $parameters) { + if ((empty($parameters['key']) and empty($parameters['password'])) + or (empty($parameters['user']) && empty($parameters['userid'])) or empty($parameters['bucket']) + or empty($parameters['region']) + ) { + throw new StorageBadConfigException('API Key or password, Login, Bucket and Region have to be configured.'); + } + + $user = $parameters['user']; + $this->id = 'swift::' . $user . md5($parameters['bucket']); + + $bucketUrl = new Uri($parameters['bucket']); + if ($bucketUrl->getHost()) { + $parameters['bucket'] = basename($bucketUrl->getPath()); + $parameters['endpoint_url'] = (string)$bucketUrl->withPath(dirname($bucketUrl->getPath())); + } + + if (empty($parameters['url'])) { + $parameters['url'] = 'https://identity.api.rackspacecloud.com/v2.0/'; + } + + if (empty($parameters['service_name'])) { + $parameters['service_name'] = 'cloudFiles'; + } + + $parameters['autocreate'] = true; + + if (isset($parameters['domain'])) { + $parameters['user'] = [ + 'name' => $parameters['user'], + 'password' => $parameters['password'], + 'domain' => [ + 'name' => $parameters['domain'], + ] + ]; + } + + $this->params = $parameters; + // FIXME: private class... + $this->objectCache = new CappedMemoryCache(); + $this->connectionFactory = new SwiftFactory( + Server::get(ICacheFactory::class)->createDistributed('swift/'), + $this->params, + Server::get(LoggerInterface::class) + ); + $this->objectStore = new \OC\Files\ObjectStore\Swift($this->params, $this->connectionFactory); + $this->bucket = $parameters['bucket']; + $this->mimeDetector = Server::get(IMimeTypeDetector::class); + } + + public function mkdir(string $path): bool { + $path = $this->normalizePath($path); + + if ($this->is_dir($path)) { + return false; + } + + if ($path !== '.') { + $path .= '/'; + } + + try { + $this->getContainer()->createObject([ + 'name' => $path, + 'content' => '', + 'headers' => ['content-type' => 'httpd/unix-directory'] + ]); + // invalidate so that the next access gets the real object + // with all properties + $this->objectCache->remove($path); + } catch (BadResponseError $e) { + Server::get(LoggerInterface::class)->error($e->getMessage(), [ + 'exception' => $e, + 'app' => 'files_external', + ]); + return false; + } + + return true; + } + + public function file_exists(string $path): bool { + $path = $this->normalizePath($path); + + if ($path !== '.' && $this->is_dir($path)) { + $path .= '/'; + } + + return $this->doesObjectExist($path); + } + + public function rmdir(string $path): bool { + $path = $this->normalizePath($path); + + if (!$this->is_dir($path) || !$this->isDeletable($path)) { + return false; + } + + $dh = $this->opendir($path); + while (($file = readdir($dh)) !== false) { + if (Filesystem::isIgnoredDir($file)) { + continue; + } + + if ($this->is_dir($path . '/' . $file)) { + $this->rmdir($path . '/' . $file); + } else { + $this->unlink($path . '/' . $file); + } + } + + try { + $this->objectStore->deleteObject($path . '/'); + $this->objectCache->remove($path . '/'); + } catch (BadResponseError $e) { + Server::get(LoggerInterface::class)->error($e->getMessage(), [ + 'exception' => $e, + 'app' => 'files_external', + ]); + return false; + } + + return true; + } + + public function opendir(string $path) { + $path = $this->normalizePath($path); + + if ($path === '.') { + $path = ''; + } else { + $path .= '/'; + } + + // $path = str_replace('%23', '#', $path); // the prefix is sent as a query param, so revert the encoding of # + + try { + $files = []; + $objects = $this->getContainer()->listObjects([ + 'prefix' => $path, + 'delimiter' => '/' + ]); + + /** @var StorageObject $object */ + foreach ($objects as $object) { + $file = basename($object->name); + if ($file !== basename($path) && $file !== '.') { + $files[] = $file; + } + } + + return IteratorDirectory::wrap($files); + } catch (\Exception $e) { + Server::get(LoggerInterface::class)->error($e->getMessage(), [ + 'exception' => $e, + 'app' => 'files_external', + ]); + return false; + } + } + + public function stat(string $path): array|false { + $path = $this->normalizePath($path); + if ($path === '.') { + $path = ''; + } elseif ($this->is_dir($path)) { + $path .= '/'; + } + + try { + $object = $this->fetchObject($path); + if (!$object) { + return false; + } + } catch (BadResponseError $e) { + Server::get(LoggerInterface::class)->error($e->getMessage(), [ + 'exception' => $e, + 'app' => 'files_external', + ]); + return false; + } + + $mtime = null; + if (!empty($object->lastModified)) { + $dateTime = \DateTime::createFromFormat(\DateTime::RFC1123, $object->lastModified); + if ($dateTime !== false) { + $mtime = $dateTime->getTimestamp(); + } + } + + if (is_numeric($object->getMetadata()['timestamp'] ?? null)) { + $mtime = (float)$object->getMetadata()['timestamp']; + } + + return [ + 'size' => (int)$object->contentLength, + 'mtime' => isset($mtime) ? (int)floor($mtime) : null, + 'atime' => time(), + ]; + } + + public function filetype(string $path) { + $path = $this->normalizePath($path); + + if ($path !== '.' && $this->doesObjectExist($path)) { + return 'file'; + } + + if ($path !== '.') { + $path .= '/'; + } + + if ($this->doesObjectExist($path)) { + return 'dir'; + } + } + + public function unlink(string $path): bool { + $path = $this->normalizePath($path); + + if ($this->is_dir($path)) { + return $this->rmdir($path); + } + + try { + $this->objectStore->deleteObject($path); + $this->objectCache->remove($path); + $this->objectCache->remove($path . '/'); + } catch (BadResponseError $e) { + if ($e->getResponse()->getStatusCode() !== 404) { + Server::get(LoggerInterface::class)->error($e->getMessage(), [ + 'exception' => $e, + 'app' => 'files_external', + ]); + throw $e; + } + } + + return true; + } + + public function fopen(string $path, string $mode) { + $path = $this->normalizePath($path); + + switch ($mode) { + case 'a': + case 'ab': + case 'a+': + return false; + case 'r': + case 'rb': + try { + return $this->objectStore->readObject($path); + } catch (BadResponseError $e) { + Server::get(LoggerInterface::class)->error($e->getMessage(), [ + 'exception' => $e, + 'app' => 'files_external', + ]); + return false; + } + case 'w': + case 'wb': + case 'r+': + case 'w+': + case 'wb+': + case 'x': + case 'x+': + case 'c': + case 'c+': + if (strrpos($path, '.') !== false) { + $ext = substr($path, strrpos($path, '.')); + } else { + $ext = ''; + } + $tmpFile = Server::get(ITempManager::class)->getTemporaryFile($ext); + // Fetch existing file if required + if ($mode[0] !== 'w' && $this->file_exists($path)) { + if ($mode[0] === 'x') { + // File cannot already exist + return false; + } + $source = $this->fopen($path, 'r'); + file_put_contents($tmpFile, $source); + } + $handle = fopen($tmpFile, $mode); + return CallbackWrapper::wrap($handle, null, null, function () use ($path, $tmpFile): void { + $this->writeBack($tmpFile, $path); + }); + } + } + + public function touch(string $path, ?int $mtime = null): bool { + $path = $this->normalizePath($path); + if (is_null($mtime)) { + $mtime = time(); + } + $metadata = ['timestamp' => (string)$mtime]; + if ($this->file_exists($path)) { + if ($this->is_dir($path) && $path !== '.') { + $path .= '/'; + } + + $object = $this->fetchObject($path); + if ($object->mergeMetadata($metadata)) { + // invalidate target object to force repopulation on fetch + $this->objectCache->remove($path); + } + return true; + } else { + $mimeType = $this->mimeDetector->detectPath($path); + $this->getContainer()->createObject([ + 'name' => $path, + 'content' => '', + 'headers' => ['content-type' => 'httpd/unix-directory'] + ]); + // invalidate target object to force repopulation on fetch + $this->objectCache->remove($path); + return true; + } + } + + public function copy(string $source, string $target): bool { + $source = $this->normalizePath($source); + $target = $this->normalizePath($target); + + $fileType = $this->filetype($source); + if ($fileType) { + // make way + $this->unlink($target); + } + + if ($fileType === 'file') { + try { + $sourceObject = $this->fetchObject($source); + $sourceObject->copy([ + 'destination' => $this->bucket . '/' . $target + ]); + // invalidate target object to force repopulation on fetch + $this->objectCache->remove($target); + $this->objectCache->remove($target . '/'); + } catch (BadResponseError $e) { + Server::get(LoggerInterface::class)->error($e->getMessage(), [ + 'exception' => $e, + 'app' => 'files_external', + ]); + return false; + } + } elseif ($fileType === 'dir') { + try { + $sourceObject = $this->fetchObject($source . '/'); + $sourceObject->copy([ + 'destination' => $this->bucket . '/' . $target . '/' + ]); + // invalidate target object to force repopulation on fetch + $this->objectCache->remove($target); + $this->objectCache->remove($target . '/'); + } catch (BadResponseError $e) { + Server::get(LoggerInterface::class)->error($e->getMessage(), [ + 'exception' => $e, + 'app' => 'files_external', + ]); + return false; + } + + $dh = $this->opendir($source); + while (($file = readdir($dh)) !== false) { + if (Filesystem::isIgnoredDir($file)) { + continue; + } + + $source = $source . '/' . $file; + $target = $target . '/' . $file; + $this->copy($source, $target); + } + } else { + //file does not exist + return false; + } + + return true; + } + + public function rename(string $source, string $target): bool { + $source = $this->normalizePath($source); + $target = $this->normalizePath($target); + + $fileType = $this->filetype($source); + + if ($fileType === 'dir' || $fileType === 'file') { + // copy + if ($this->copy($source, $target) === false) { + return false; + } + + // cleanup + if ($this->unlink($source) === false) { + throw new \Exception('failed to remove original'); + $this->unlink($target); + return false; + } + + return true; + } + + return false; + } + + public function getId(): string { + return $this->id; + } + + /** + * Returns the initialized object store container. + * + * @return Container + * @throws StorageAuthException + * @throws StorageNotAvailableException + */ + public function getContainer(): Container { + if (is_null($this->container)) { + $this->container = $this->connectionFactory->getContainer(); + + if (!$this->file_exists('.')) { + $this->mkdir('.'); + } + } + return $this->container; + } + + public function writeBack(string $tmpFile, string $path): void { + $fileData = fopen($tmpFile, 'r'); + $this->objectStore->writeObject($path, $fileData, $this->mimeDetector->detectPath($path)); + // invalidate target object to force repopulation on fetch + $this->objectCache->remove($path); + unlink($tmpFile); + } + + public function hasUpdated(string $path, int $time): bool { + if ($this->is_file($path)) { + return parent::hasUpdated($path, $time); + } + $path = $this->normalizePath($path); + $dh = $this->opendir($path); + $content = []; + while (($file = readdir($dh)) !== false) { + $content[] = $file; + } + if ($path === '.') { + $path = ''; + } + $cachedContent = $this->getCache()->getFolderContents($path); + $cachedNames = array_map(function ($content) { + return $content['name']; + }, $cachedContent); + sort($cachedNames); + sort($content); + return $cachedNames !== $content; + } + + /** + * check if curl is installed + */ + public static function checkDependencies(): bool { + return true; + } +} diff --git a/apps/files_external/lib/Lib/Storage/SystemBridge.php b/apps/files_external/lib/Lib/Storage/SystemBridge.php new file mode 100644 index 00000000000..80449b2744b --- /dev/null +++ b/apps/files_external/lib/Lib/Storage/SystemBridge.php @@ -0,0 +1,27 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2024 Robin Appelman <robin@icewind.nl> + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Files_External\Lib\Storage; + +use Icewind\SMB\System; +use OCP\IBinaryFinder; + +/** + * Bridge the NC and SMB binary finding logic + */ +class SystemBridge extends System { + public function __construct( + private IBinaryFinder $binaryFinder, + ) { + } + + protected function getBinaryPath(string $binary): ?string { + $path = $this->binaryFinder->findBinaryPath($binary); + return $path !== false ? $path : null; + } +} diff --git a/apps/files_external/lib/Lib/StorageConfig.php b/apps/files_external/lib/Lib/StorageConfig.php new file mode 100644 index 00000000000..2cb82d3790a --- /dev/null +++ b/apps/files_external/lib/Lib/StorageConfig.php @@ -0,0 +1,438 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2019-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Files_External\Lib; + +use OC\Files\Filesystem; +use OCA\Files_External\Lib\Auth\AuthMechanism; +use OCA\Files_External\Lib\Auth\IUserProvided; +use OCA\Files_External\Lib\Backend\Backend; +use OCA\Files_External\ResponseDefinitions; + +/** + * External storage configuration + * + * @psalm-import-type Files_ExternalStorageConfig from ResponseDefinitions + */ +class StorageConfig implements \JsonSerializable { + public const MOUNT_TYPE_ADMIN = 1; + public const MOUNT_TYPE_PERSONAL = 2; + /** @deprecated use MOUNT_TYPE_PERSONAL (full uppercase) instead */ + public const MOUNT_TYPE_PERSONAl = 2; + + /** + * Storage config id + * + * @var int + */ + private $id; + + /** + * Backend + * + * @var Backend + */ + private $backend; + + /** + * Authentication mechanism + * + * @var AuthMechanism + */ + private $authMechanism; + + /** + * Backend options + * + * @var array<string, mixed> + */ + private $backendOptions = []; + + /** + * Mount point path, relative to the user's "files" folder + * + * @var string + */ + private $mountPoint; + + /** + * Storage status + * + * @var int + */ + private $status; + + /** + * Status message + * + * @var string + */ + private $statusMessage; + + /** + * Priority + * + * @var int + */ + private $priority; + + /** + * List of users who have access to this storage + * + * @var list<string> + */ + private $applicableUsers = []; + + /** + * List of groups that have access to this storage + * + * @var list<string> + */ + private $applicableGroups = []; + + /** + * Mount-specific options + * + * @var array<string, mixed> + */ + private $mountOptions = []; + + /** + * Whether it's a personal or admin mount + * + * @var int + */ + private $type; + + /** + * Creates a storage config + * + * @param int|string $id config id or null for a new config + */ + public function __construct($id = null) { + $this->id = $id ?? -1; + $this->mountOptions['enable_sharing'] = false; + } + + /** + * Returns the configuration id + * + * @return int + */ + public function getId() { + return $this->id; + } + + /** + * Sets the configuration id + * + * @param int $id configuration id + */ + public function setId(int $id): void { + $this->id = $id; + } + + /** + * Returns mount point path relative to the user's + * "files" folder. + * + * @return string path + */ + public function getMountPoint() { + return $this->mountPoint; + } + + /** + * Sets mount point path relative to the user's + * "files" folder. + * The path will be normalized. + * + * @param string $mountPoint path + */ + public function setMountPoint($mountPoint) { + $this->mountPoint = Filesystem::normalizePath($mountPoint); + } + + /** + * @return Backend + */ + public function getBackend() { + return $this->backend; + } + + /** + * @param Backend $backend + */ + public function setBackend(Backend $backend) { + $this->backend = $backend; + } + + /** + * @return AuthMechanism + */ + public function getAuthMechanism() { + return $this->authMechanism; + } + + /** + * @param AuthMechanism $authMechanism + */ + public function setAuthMechanism(AuthMechanism $authMechanism) { + $this->authMechanism = $authMechanism; + } + + /** + * Returns the external storage backend-specific options + * + * @return array backend options + */ + public function getBackendOptions() { + return $this->backendOptions; + } + + /** + * Sets the external storage backend-specific options + * + * @param array $backendOptions backend options + */ + public function setBackendOptions($backendOptions) { + if ($this->getBackend() instanceof Backend) { + $parameters = $this->getBackend()->getParameters(); + foreach ($backendOptions as $key => $value) { + if (isset($parameters[$key])) { + switch ($parameters[$key]->getType()) { + case DefinitionParameter::VALUE_BOOLEAN: + $value = (bool)$value; + break; + } + $backendOptions[$key] = $value; + } + } + } + + $this->backendOptions = $backendOptions; + } + + /** + * @param string $key + * @return mixed + */ + public function getBackendOption($key) { + if (isset($this->backendOptions[$key])) { + return $this->backendOptions[$key]; + } + return null; + } + + /** + * @param string $key + * @param mixed $value + */ + public function setBackendOption($key, $value) { + $this->backendOptions[$key] = $value; + } + + /** + * Returns the mount priority + * + * @return int priority + */ + public function getPriority() { + return $this->priority; + } + + /** + * Sets the mount priority + * + * @param int $priority priority + */ + public function setPriority($priority) { + $this->priority = $priority; + } + + /** + * Returns the users for which to mount this storage + * + * @return list<string> applicable users + */ + public function getApplicableUsers() { + return $this->applicableUsers; + } + + /** + * Sets the users for which to mount this storage + * + * @param list<string>|null $applicableUsers applicable users + */ + public function setApplicableUsers($applicableUsers) { + if (is_null($applicableUsers)) { + $applicableUsers = []; + } + $this->applicableUsers = $applicableUsers; + } + + /** + * Returns the groups for which to mount this storage + * + * @return list<string> applicable groups + */ + public function getApplicableGroups() { + return $this->applicableGroups; + } + + /** + * Sets the groups for which to mount this storage + * + * @param list<string>|null $applicableGroups applicable groups + */ + public function setApplicableGroups($applicableGroups) { + if (is_null($applicableGroups)) { + $applicableGroups = []; + } + $this->applicableGroups = $applicableGroups; + } + + /** + * Returns the mount-specific options + * + * @return array mount specific options + */ + public function getMountOptions() { + return $this->mountOptions; + } + + /** + * Sets the mount-specific options + * + * @param array $mountOptions applicable groups + */ + public function setMountOptions($mountOptions) { + if (is_null($mountOptions)) { + $mountOptions = []; + } + $this->mountOptions = $mountOptions; + } + + /** + * @param string $key + * @return mixed + */ + public function getMountOption($key) { + if (isset($this->mountOptions[$key])) { + return $this->mountOptions[$key]; + } + return null; + } + + /** + * @param string $key + * @param mixed $value + */ + public function setMountOption($key, $value) { + $this->mountOptions[$key] = $value; + } + + /** + * Gets the storage status, whether the config worked last time + * + * @return int $status status + */ + public function getStatus() { + return $this->status; + } + + /** + * Gets the message describing the storage status + * + * @return string|null + */ + public function getStatusMessage() { + return $this->statusMessage; + } + + /** + * Sets the storage status, whether the config worked last time + * + * @param int $status status + * @param string|null $message optional message + */ + public function setStatus($status, $message = null) { + $this->status = $status; + $this->statusMessage = $message; + } + + /** + * @return int self::MOUNT_TYPE_ADMIN or self::MOUNT_TYPE_PERSONAL + */ + public function getType() { + return $this->type; + } + + /** + * @param int $type self::MOUNT_TYPE_ADMIN or self::MOUNT_TYPE_PERSONAL + */ + public function setType($type) { + $this->type = $type; + } + + /** + * Serialize config to JSON + * @return Files_ExternalStorageConfig + */ + public function jsonSerialize(bool $obfuscate = false): array { + $result = []; + if (!is_null($this->id)) { + $result['id'] = $this->id; + } + + // obfuscate sensitive data if requested + if ($obfuscate) { + $this->formatStorageForUI(); + } + + $result['mountPoint'] = $this->mountPoint; + $result['backend'] = $this->backend->getIdentifier(); + $result['authMechanism'] = $this->authMechanism->getIdentifier(); + $result['backendOptions'] = $this->backendOptions; + if (!is_null($this->priority)) { + $result['priority'] = $this->priority; + } + if (!empty($this->applicableUsers)) { + $result['applicableUsers'] = $this->applicableUsers; + } + if (!empty($this->applicableGroups)) { + $result['applicableGroups'] = $this->applicableGroups; + } + if (!empty($this->mountOptions)) { + $result['mountOptions'] = $this->mountOptions; + } + if (!is_null($this->status)) { + $result['status'] = $this->status; + } + if (!is_null($this->statusMessage)) { + $result['statusMessage'] = $this->statusMessage; + } + $result['userProvided'] = $this->authMechanism instanceof IUserProvided; + $result['type'] = ($this->getType() === self::MOUNT_TYPE_PERSONAL) ? 'personal': 'system'; + return $result; + } + + protected function formatStorageForUI(): void { + /** @var DefinitionParameter[] $parameters */ + $parameters = array_merge($this->getBackend()->getParameters(), $this->getAuthMechanism()->getParameters()); + + $options = $this->getBackendOptions(); + foreach ($options as $key => $value) { + foreach ($parameters as $parameter) { + if ($parameter->getName() === $key && $parameter->getType() === DefinitionParameter::VALUE_PASSWORD) { + $this->setBackendOption($key, DefinitionParameter::UNMODIFIED_PLACEHOLDER); + break; + } + } + } + } +} diff --git a/apps/files_external/lib/Lib/StorageModifierTrait.php b/apps/files_external/lib/Lib/StorageModifierTrait.php new file mode 100644 index 00000000000..4062ff1635e --- /dev/null +++ b/apps/files_external/lib/Lib/StorageModifierTrait.php @@ -0,0 +1,50 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2018-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Files_External\Lib; + +use OCP\Files\Storage\IStorage; +use OCP\Files\StorageNotAvailableException; +use OCP\IUser; + +/** + * Trait for objects that can modify StorageConfigs and wrap Storages + * + * When a storage implementation is being prepared for use, the StorageConfig + * is passed through manipulateStorageConfig() to update any parameters as + * necessary. After the storage implementation has been constructed, it is + * passed through wrapStorage(), potentially replacing the implementation with + * a wrapped storage that changes its behaviour. + * + * Certain configuration options need to be set before the implementation is + * constructed, while others are retrieved directly from the storage + * implementation and so need a wrapper to be modified. + */ +trait StorageModifierTrait { + + /** + * Modify a StorageConfig parameters + * + * @param StorageConfig &$storage + * @param ?IUser $user User the storage is being used as + * @return void + * @throws InsufficientDataForMeaningfulAnswerException + * @throws StorageNotAvailableException + */ + public function manipulateStorageConfig(StorageConfig &$storage, ?IUser $user = null) { + } + + /** + * Wrap a storage if necessary + * + * @throws InsufficientDataForMeaningfulAnswerException + * @throws StorageNotAvailableException + */ + public function wrapStorage(IStorage $storage): IStorage { + return $storage; + } +} diff --git a/apps/files_external/lib/Lib/VisibilityTrait.php b/apps/files_external/lib/Lib/VisibilityTrait.php new file mode 100644 index 00000000000..62b26f3edb1 --- /dev/null +++ b/apps/files_external/lib/Lib/VisibilityTrait.php @@ -0,0 +1,121 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2019-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Files_External\Lib; + +use OCA\Files_External\Service\BackendService; + +/** + * Trait to implement visibility mechanics for a configuration class + * + * The standard visibility defines which users/groups can use or see the + * object. The allowed visibility defines the maximum visibility allowed to be + * set on the object. The standard visibility is often set dynamically by + * stored configuration parameters that can be modified by the administrator, + * while the allowed visibility is set directly by the object and cannot be + * modified by the administrator. + */ +trait VisibilityTrait { + + /** @var int visibility */ + protected $visibility = BackendService::VISIBILITY_DEFAULT; + + /** @var int allowed visibilities */ + protected $allowedVisibility = BackendService::VISIBILITY_DEFAULT; + + /** + * @return int + */ + public function getVisibility() { + return $this->visibility; + } + + /** + * Check if the backend is visible for a user type + * + * @param int $visibility + * @return bool + */ + public function isVisibleFor($visibility) { + if ($this->visibility & $visibility) { + return true; + } + return false; + } + + /** + * @param int $visibility + * @return self + */ + public function setVisibility($visibility) { + $this->visibility = $visibility; + $this->allowedVisibility |= $visibility; + return $this; + } + + /** + * @param int $visibility + * @return self + */ + public function addVisibility($visibility) { + return $this->setVisibility($this->visibility | $visibility); + } + + /** + * @param int $visibility + * @return self + */ + public function removeVisibility($visibility) { + return $this->setVisibility($this->visibility & ~$visibility); + } + + /** + * @return int + */ + public function getAllowedVisibility() { + return $this->allowedVisibility; + } + + /** + * Check if the backend is allowed to be visible for a user type + * + * @param int $allowedVisibility + * @return bool + */ + public function isAllowedVisibleFor($allowedVisibility) { + if ($this->allowedVisibility & $allowedVisibility) { + return true; + } + return false; + } + + /** + * @param int $allowedVisibility + * @return self + */ + public function setAllowedVisibility($allowedVisibility) { + $this->allowedVisibility = $allowedVisibility; + $this->visibility &= $allowedVisibility; + return $this; + } + + /** + * @param int $allowedVisibility + * @return self + */ + public function addAllowedVisibility($allowedVisibility) { + return $this->setAllowedVisibility($this->allowedVisibility | $allowedVisibility); + } + + /** + * @param int $allowedVisibility + * @return self + */ + public function removeAllowedVisibility($allowedVisibility) { + return $this->setAllowedVisibility($this->allowedVisibility & ~$allowedVisibility); + } +} |