1<?php
2
3declare(strict_types=1);
4
5/**
6 * @copyright Copyright (c) 2018 Robin Appelman <robin@icewind.nl>
7 *
8 * @author Adrian Brzezinski <adrian.brzezinski@eo.pl>
9 * @author Christoph Wurst <christoph@winzerhof-wurst.at>
10 * @author Julien Lutran <julien.lutran@corp.ovh.com>
11 * @author Morris Jobke <hey@morrisjobke.de>
12 * @author Robin Appelman <robin@icewind.nl>
13 * @author Roeland Jago Douma <roeland@famdouma.nl>
14 * @author Volker <skydiablo@gmx.net>
15 * @author William Pain <pain.william@gmail.com>
16 *
17 * @license GNU AGPL version 3 or any later version
18 *
19 * This program is free software: you can redistribute it and/or modify
20 * it under the terms of the GNU Affero General Public License as
21 * published by the Free Software Foundation, either version 3 of the
22 * License, or (at your option) any later version.
23 *
24 * This program is distributed in the hope that it will be useful,
25 * but WITHOUT ANY WARRANTY; without even the implied warranty of
26 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
27 * GNU Affero General Public License for more details.
28 *
29 * You should have received a copy of the GNU Affero General Public License
30 * along with this program. If not, see <http://www.gnu.org/licenses/>.
31 *
32 */
33namespace OC\Files\ObjectStore;
34
35use GuzzleHttp\Client;
36use GuzzleHttp\Exception\ClientException;
37use GuzzleHttp\Exception\ConnectException;
38use GuzzleHttp\Exception\RequestException;
39use GuzzleHttp\HandlerStack;
40use OCP\Files\StorageAuthException;
41use OCP\Files\StorageNotAvailableException;
42use OCP\ICache;
43use OCP\ILogger;
44use OpenStack\Common\Auth\Token;
45use OpenStack\Common\Error\BadResponseError;
46use OpenStack\Common\Transport\Utils as TransportUtils;
47use OpenStack\Identity\v2\Models\Catalog;
48use OpenStack\Identity\v2\Service as IdentityV2Service;
49use OpenStack\Identity\v3\Service as IdentityV3Service;
50use OpenStack\ObjectStore\v1\Models\Container;
51use OpenStack\OpenStack;
52use Psr\Http\Message\RequestInterface;
53
54class SwiftFactory {
55	private $cache;
56	private $params;
57	/** @var Container|null */
58	private $container = null;
59	private $logger;
60
61	public const DEFAULT_OPTIONS = [
62		'autocreate' => false,
63		'urlType' => 'publicURL',
64		'catalogName' => 'swift',
65		'catalogType' => 'object-store'
66	];
67
68	public function __construct(ICache $cache, array $params, ILogger $logger) {
69		$this->cache = $cache;
70		$this->params = $params;
71		$this->logger = $logger;
72	}
73
74	/**
75	 * Gets currently cached token id
76	 *
77	 * @return string
78	 * @throws StorageAuthException
79	 */
80	public function getCachedTokenId() {
81		if (!isset($this->params['cachedToken'])) {
82			throw new StorageAuthException('Unauthenticated ObjectStore connection');
83		}
84
85		// Is it V2 token?
86		if (isset($this->params['cachedToken']['token'])) {
87			return $this->params['cachedToken']['token']['id'];
88		}
89
90		return $this->params['cachedToken']['id'];
91	}
92
93	private function getCachedToken(string $cacheKey) {
94		$cachedTokenString = $this->cache->get($cacheKey . '/token');
95		if ($cachedTokenString) {
96			return json_decode($cachedTokenString, true);
97		} else {
98			return null;
99		}
100	}
101
102	private function cacheToken(Token $token, string $serviceUrl, string $cacheKey) {
103		if ($token instanceof \OpenStack\Identity\v3\Models\Token) {
104			// for v3 the catalog is cached as part of the token, so no need to cache $serviceUrl separately
105			$value = $token->export();
106		} else {
107			/** @var \OpenStack\Identity\v2\Models\Token $token */
108			$value = [
109				'serviceUrl' => $serviceUrl,
110				'token' => [
111					'issued_at' => $token->issuedAt->format('c'),
112					'expires' => $token->expires->format('c'),
113					'id' => $token->id,
114					'tenant' => $token->tenant
115				]
116			];
117		}
118
119		$this->params['cachedToken'] = $value;
120		$this->cache->set($cacheKey . '/token', json_encode($value));
121	}
122
123	/**
124	 * @return OpenStack
125	 * @throws StorageAuthException
126	 */
127	private function getClient() {
128		if (isset($this->params['bucket'])) {
129			$this->params['container'] = $this->params['bucket'];
130		}
131		if (!isset($this->params['container'])) {
132			$this->params['container'] = 'nextcloud';
133		}
134		if (isset($this->params['user']) && is_array($this->params['user'])) {
135			$userName = $this->params['user']['name'];
136		} else {
137			if (!isset($this->params['username']) && isset($this->params['user'])) {
138				$this->params['username'] = $this->params['user'];
139			}
140			$userName = $this->params['username'];
141		}
142		if (!isset($this->params['tenantName']) && isset($this->params['tenant'])) {
143			$this->params['tenantName'] = $this->params['tenant'];
144		}
145		if (isset($this->params['domain'])) {
146			$this->params['scope']['project']['name'] = $this->params['tenant'];
147			$this->params['scope']['project']['domain']['name'] = $this->params['domain'];
148		}
149		$this->params = array_merge(self::DEFAULT_OPTIONS, $this->params);
150
151		$cacheKey = $userName . '@' . $this->params['url'] . '/' . $this->params['container'];
152		$token = $this->getCachedToken($cacheKey);
153		$this->params['cachedToken'] = $token;
154
155		$httpClient = new Client([
156			'base_uri' => TransportUtils::normalizeUrl($this->params['url']),
157			'handler' => HandlerStack::create()
158		]);
159
160		if (isset($this->params['user']) && is_array($this->params['user']) && isset($this->params['user']['name'])) {
161			if (!isset($this->params['scope'])) {
162				throw new StorageAuthException('Scope has to be defined for V3 requests');
163			}
164
165			return $this->auth(IdentityV3Service::factory($httpClient), $cacheKey);
166		} else {
167			return $this->auth(SwiftV2CachingAuthService::factory($httpClient), $cacheKey);
168		}
169	}
170
171	/**
172	 * @param IdentityV2Service|IdentityV3Service $authService
173	 * @param string $cacheKey
174	 * @return OpenStack
175	 * @throws StorageAuthException
176	 */
177	private function auth($authService, string $cacheKey) {
178		$this->params['identityService'] = $authService;
179		$this->params['authUrl'] = $this->params['url'];
180
181		$cachedToken = $this->params['cachedToken'];
182		$hasValidCachedToken = false;
183		if (\is_array($cachedToken)) {
184			if ($authService instanceof IdentityV3Service) {
185				$token = $authService->generateTokenFromCache($cachedToken);
186				if (\is_null($token->catalog)) {
187					$this->logger->warning('Invalid cached token for swift, no catalog set: ' . json_encode($cachedToken));
188				} elseif ($token->hasExpired()) {
189					$this->logger->debug('Cached token for swift expired');
190				} else {
191					$hasValidCachedToken = true;
192				}
193			} else {
194				try {
195					/** @var \OpenStack\Identity\v2\Models\Token $token */
196					$token = $authService->model(\OpenStack\Identity\v2\Models\Token::class, $cachedToken['token']);
197					$now = new \DateTimeImmutable("now");
198					if ($token->expires > $now) {
199						$hasValidCachedToken = true;
200						$this->params['v2cachedToken'] = $token;
201						$this->params['v2serviceUrl'] = $cachedToken['serviceUrl'];
202					} else {
203						$this->logger->debug('Cached token for swift expired');
204					}
205				} catch (\Exception $e) {
206					$this->logger->logException($e);
207				}
208			}
209		}
210
211		if (!$hasValidCachedToken) {
212			unset($this->params['cachedToken']);
213			try {
214				[$token, $serviceUrl] = $authService->authenticate($this->params);
215				$this->cacheToken($token, $serviceUrl, $cacheKey);
216			} catch (ConnectException $e) {
217				throw new StorageAuthException('Failed to connect to keystone, verify the keystone url', $e);
218			} catch (ClientException $e) {
219				$statusCode = $e->getResponse()->getStatusCode();
220				if ($statusCode === 404) {
221					throw new StorageAuthException('Keystone not found, verify the keystone url', $e);
222				} elseif ($statusCode === 412) {
223					throw new StorageAuthException('Precondition failed, verify the keystone url', $e);
224				} elseif ($statusCode === 401) {
225					throw new StorageAuthException('Authentication failed, verify the username, password and possibly tenant', $e);
226				} else {
227					throw new StorageAuthException('Unknown error', $e);
228				}
229			} catch (RequestException $e) {
230				throw new StorageAuthException('Connection reset while connecting to keystone, verify the keystone url', $e);
231			}
232		}
233
234
235		$client = new OpenStack($this->params);
236
237		return $client;
238	}
239
240	/**
241	 * @return \OpenStack\ObjectStore\v1\Models\Container
242	 * @throws StorageAuthException
243	 * @throws StorageNotAvailableException
244	 */
245	public function getContainer() {
246		if (is_null($this->container)) {
247			$this->container = $this->createContainer();
248		}
249
250		return $this->container;
251	}
252
253	/**
254	 * @return \OpenStack\ObjectStore\v1\Models\Container
255	 * @throws StorageAuthException
256	 * @throws StorageNotAvailableException
257	 */
258	private function createContainer() {
259		$client = $this->getClient();
260		$objectStoreService = $client->objectStoreV1();
261
262		$autoCreate = isset($this->params['autocreate']) && $this->params['autocreate'] === true;
263		try {
264			$container = $objectStoreService->getContainer($this->params['container']);
265			if ($autoCreate) {
266				$container->getMetadata();
267			}
268			return $container;
269		} catch (BadResponseError $ex) {
270			// if the container does not exist and autocreate is true try to create the container on the fly
271			if ($ex->getResponse()->getStatusCode() === 404 && $autoCreate) {
272				return $objectStoreService->createContainer([
273					'name' => $this->params['container']
274				]);
275			} else {
276				throw new StorageNotAvailableException('Invalid response while trying to get container info', StorageNotAvailableException::STATUS_ERROR, $ex);
277			}
278		} catch (ConnectException $e) {
279			/** @var RequestInterface $request */
280			$request = $e->getRequest();
281			$host = $request->getUri()->getHost() . ':' . $request->getUri()->getPort();
282			\OC::$server->getLogger()->error("Can't connect to object storage server at $host");
283			throw new StorageNotAvailableException("Can't connect to object storage server at $host", StorageNotAvailableException::STATUS_ERROR, $e);
284		}
285	}
286}
287