1: <?php
2: /**
3: * This code is licensed under AGPLv3 license or Afterlogic Software License
4: * if commercial version of the product was purchased.
5: * For full statements of the licenses see LICENSE-AFTERLOGIC and LICENSE-AGPL3 files.
6: */
7:
8: namespace Aurora\Modules\S3Filestorage;
9:
10: use Afterlogic\DAV\Constants;
11: use Afterlogic\DAV\FS\Shared\File as SharedFile;
12: use Afterlogic\DAV\FS\Shared\Directory as SharedDirectory;
13: use Aurora\Api;
14: use Aws\S3\S3Client;
15: use Aurora\System\Exceptions\ApiException;
16: use Aurora\Modules\PersonalFiles\Module as PersonalFiles;
17:
18: /**
19: * Adds ability to work with S3 file storage inside Aurora Files module.
20: *
21: * @license https://www.gnu.org/licenses/agpl-3.0.html AGPL-3.0
22: * @license https://afterlogic.com/products/common-licensing Afterlogic Software License
23: * @copyright Copyright (c) 2023, Afterlogic Corp.
24: *
25: * @package Modules
26: */
27: class Module extends PersonalFiles
28: {
29: protected $aRequireModules = ['PersonalFiles'];
30:
31: protected $oClient = null;
32: protected $sUserPublicId = null;
33:
34: protected $sBucketPrefix;
35: protected $sBucket;
36: protected $sRegion;
37: protected $sHost;
38: protected $sAccessKey;
39: protected $sSecretKey;
40:
41: protected $aSettings = null;
42:
43: protected $oTenantForDelete = null;
44: protected $oUserForDelete = null;
45:
46: protected $sTenantName;
47:
48: /***** private functions *****/
49: /**
50: * Initializes Module.
51: *
52: * @ignore
53: */
54: public function init()
55: {
56: $personalFiles = PersonalFiles::getInstance();
57: if ($personalFiles && !$this->getConfig('Disabled', false)) {
58: $personalFiles->setConfig('Disabled', true);
59: }
60:
61: parent::init();
62:
63: $this->subscribeEvent('Core::DeleteTenant::before', array($this, 'onBeforeDeleteTenant'));
64: $this->subscribeEvent('Core::DeleteTenant::after', array($this, 'onAfterDeleteTenant'));
65:
66: $this->subscribeEvent('Core::DeleteUser::before', array($this, 'onBeforeDeleteUser'));
67: $this->subscribeEvent('Core::DeleteUser::after', array($this, 'onAfterDeleteUser'));
68:
69: $this->subscribeEvent('AddToContentSecurityPolicyDefault', array($this, 'onAddToContentSecurityPolicyDefault'));
70:
71: $this->denyMethodsCallByWebApi([
72: 'DeleteUserFolder',
73: 'GetUserByUUID'
74: ]);
75:
76: $this->sBucketPrefix = $this->getConfig('BucketPrefix');
77: $this->sBucket = \strtolower($this->sBucketPrefix . \str_replace([' ', '.'], '-', $this->getTenantName()));
78: $this->sHost = $this->getConfig('Host');
79: $this->sRegion = $this->getConfig('Region');
80: $this->sAccessKey = $this->getConfig('AccessKey');
81: $this->sSecretKey = $this->getConfig('SecretKey');
82: }
83:
84: public function onAddToContentSecurityPolicyDefault($aArgs, &$aAddDefault)
85: {
86: $aAddDefault[] = "https://" . $this->sHost;
87: $aAddDefault[] = "https://" . $this->sBucket . "." . $this->sHost;
88: }
89:
90: /**
91: * Obtains list of module settings for authenticated user.
92: *
93: * @return array
94: */
95: public function GetSettings($TenantId = null)
96: {
97: \Aurora\System\Api::checkUserRoleIsAtLeast(\Aurora\System\Enums\UserRole::TenantAdmin);
98:
99: if (!isset($this->aSettings)) {
100: $oSettings = $this->GetModuleSettings();
101: if (!empty($TenantId)) {
102: \Aurora\System\Api::checkUserRoleIsAtLeast(\Aurora\System\Enums\UserRole::TenantAdmin);
103: $oTenant = \Aurora\System\Api::getTenantById($TenantId);
104:
105: if ($oTenant) {
106: $this->aSettings = [
107: 'Region' => $oSettings->GetTenantValue($oTenant->Name, 'Region', ''),
108: 'Host' => $oSettings->GetTenantValue($oTenant->Name, 'Host', ''),
109: ];
110: }
111: } else {
112: $this->aSettings = [
113: 'AccessKey' => $oSettings->GetValue('AccessKey', ''),
114: 'SecretKey' => $oSettings->GetValue('SecretKey', ''),
115: 'Region' => $oSettings->GetValue('Region', ''),
116: 'Host' => $oSettings->GetValue('Host', ''),
117: 'BucketPrefix' => $oSettings->GetValue('BucketPrefix', ''),
118: ];
119: }
120: }
121:
122: return $this->aSettings;
123: }
124:
125: /**
126: * Updates module's settings - saves them to config.json file.
127: * @param string $AccessKey
128: * @param string $SecretKey
129: * @param string $Region
130: * @param string $Host
131: * @param string $BucketPrefix
132: * @return boolean
133: */
134: public function UpdateS3Settings($AccessKey, $SecretKey, $Region, $Host, $BucketPrefix, $TenantId = null)
135: {
136: $oSettings = $this->GetModuleSettings();
137:
138: if (!empty($TenantId)) {
139: \Aurora\System\Api::checkUserRoleIsAtLeast(\Aurora\System\Enums\UserRole::TenantAdmin);
140: $oTenant = \Aurora\System\Api::getTenantById($TenantId);
141:
142: if ($oTenant) {
143: $oSettings->SetTenantValue($oTenant->Name, 'Region', $Region);
144: $oSettings->SetTenantValue($oTenant->Name, 'Host', $Host);
145: return $oSettings->SaveTenantSettings($oTenant->Name);
146: }
147: } else {
148: \Aurora\System\Api::checkUserRoleIsAtLeast(\Aurora\System\Enums\UserRole::SuperAdmin);
149:
150: $oSettings->SetValue('AccessKey', $AccessKey);
151: $oSettings->SetValue('SecretKey', $SecretKey);
152: $oSettings->SetValue('Region', $Region);
153: $oSettings->SetValue('Host', $Host);
154: $oSettings->SetValue('BucketPrefix', $BucketPrefix);
155: return $oSettings->Save();
156: }
157:
158: return false;
159: }
160:
161: public function GetUsersFolders($iTenantId)
162: {
163: $this->sBucket = $this->getBucketForTenant($iTenantId);
164:
165: $results = $this->getClient(true)->listObjectsV2([
166: 'Bucket' => $this->getBucketForTenant($iTenantId),
167: 'Prefix' => '',
168: 'Delimiter' => '/'
169: ]);
170:
171: $aUsersFolders = [];
172: if (is_array($results['CommonPrefixes']) && count($results['CommonPrefixes']) > 0) {
173: foreach ($results['CommonPrefixes'] as $aPrefix) {
174: if (substr($aPrefix['Prefix'], -1) === '/') {
175: $aUsersFolders[] = \rtrim($aPrefix['Prefix'], '/');
176: }
177: }
178: }
179:
180: return $aUsersFolders;
181: }
182:
183:
184: protected function getS3Client()
185: {
186: $options = [
187: 'region' => $this->sRegion,
188: 'version' => 'latest',
189: 'credentials' => [
190: 'key' => $this->sAccessKey,
191: 'secret' => $this->sSecretKey,
192: ]
193: ];
194: if (!empty($this->sHost)) {
195: $options['endpoint'] = 'https://' . $this->sHost;
196: }
197: return new S3Client($options);
198: }
199:
200: /**
201: * Obtains DropBox client if passed $sType is DropBox account type.
202: *
203: * @param string $sType Service type.
204: * @return \Dropbox\Client
205: */
206: protected function getClient($bRenew = false)
207: {
208: if ($this->oClient === null || $bRenew) {
209: \Aurora\System\Api::checkUserRoleIsAtLeast(\Aurora\System\Enums\UserRole::Anonymous);
210:
211: $this->oClient = $this->getS3Client();
212:
213: if (!$this->oClient->doesBucketExist($this->sBucket)) {
214: $this->oClient->createBucket([
215: 'Bucket' => $this->sBucket
216: ]);
217:
218: $res = $this->oClient->putBucketCors([
219: 'Bucket' => $this->sBucket,
220: 'CORSConfiguration' => [
221: 'CORSRules' => [
222: [
223: 'AllowedHeaders' => [
224: '*',
225: ],
226: 'AllowedMethods' => [
227: 'GET',
228: 'PUT',
229: 'POST',
230: 'DELETE',
231: 'HEAD'
232: ],
233: 'AllowedOrigins' => [
234: (\Aurora\System\Api::isHttps() ? "https" : "http") . "://" . $_SERVER['HTTP_HOST']
235: ],
236: 'MaxAgeSeconds' => 0,
237: ],
238: ],
239: ],
240: 'ContentMD5' => '',
241: ]);
242: }
243: }
244:
245: return $this->oClient;
246: }
247:
248: protected function getUserPublicId()
249: {
250: if (!isset($this->sUserPublicId)) {
251: $oUser = \Aurora\System\Api::getAuthenticatedUser();
252: $this->sUserPublicId = $oUser->PublicId;
253: }
254:
255: return $this->sUserPublicId;
256: }
257:
258: /**
259: * getTenantName
260: *
261: * @return string
262: */
263: protected function getTenantName()
264: {
265: if (!isset($this->sTenantName)) {
266: $this->sTenantName = \Aurora\System\Api::getTenantName();
267: }
268:
269: return $this->sTenantName;
270: }
271:
272: protected function getBucketForTenant($iIdTenant)
273: {
274: $mResult = false;
275: $oTenant = \Aurora\Modules\Core\Module::getInstance()->GetTenantUnchecked($iIdTenant);
276: if ($oTenant instanceof \Aurora\Modules\Core\Models\Tenant) {
277: $mResult = \strtolower($this->sBucketPrefix . \str_replace([' ', '.'], '-', $oTenant->Name));
278: }
279:
280: return $mResult;
281: }
282:
283: protected function copyObject($sFromPath, $sToPath, $sOldName, $sNewName, $bIsFolder = false, $bMove = false)
284: {
285: $mResult = false;
286:
287: $sUserPublicId = $this->getUserPublicId();
288:
289: $sSuffix = $bIsFolder ? '/' : '';
290:
291: $sFullFromPath = $this->sBucket . '/' . $sUserPublicId . $sFromPath . '/' . $sOldName . $sSuffix;
292: $sFullToPath = $sUserPublicId . $sToPath.'/'.$sNewName . $sSuffix;
293:
294: $oClient = $this->getClient();
295:
296: $oObject = $oClient->HeadObject([
297: 'Bucket' => $this->sBucket,
298: 'Key' => urldecode($sUserPublicId . $sFromPath . '/' . $sOldName . $sSuffix)
299: ]);
300:
301: $aMetadata = [];
302: $sMetadataDirective = 'COPY';
303: if ($oObject) {
304: $aMetadata = $oObject->get('Metadata');
305: $aMetadata['filename'] = $sNewName;
306: $sMetadataDirective = 'REPLACE';
307: }
308:
309: $res = $oClient->copyObject([
310: 'Bucket' => $this->sBucket,
311: 'Key' => $sFullToPath,
312: 'CopySource' => $sFullFromPath,
313: 'Metadata' => $aMetadata,
314: 'MetadataDirective' => $sMetadataDirective
315: ]);
316:
317: if ($res) {
318: if ($bMove) {
319: $res = $oClient->deleteObject([
320: 'Bucket' => $this->sBucket,
321: 'Key' => $sUserPublicId . $sFromPath.'/'.$sOldName . $sSuffix
322: ]);
323: }
324: $mResult = true;
325: }
326:
327: return $mResult;
328: }
329:
330: protected function getDirectory($sUserPublicId, $sType, $sPath = '')
331: {
332: $oDirectory = null;
333:
334: if ($sUserPublicId) {
335: $oDirectory = \Afterlogic\DAV\Server::getNodeForPath('files/' . $sType . '/' . \trim($sPath, '/'), $sUserPublicId);
336: }
337:
338: return $oDirectory;
339: }
340:
341: protected function copy($UserId, $FromType, $FromPath, $FromName, $ToType, $ToPath, $ToName, $IsMove = false)
342: {
343: $sUserPublicId = \Aurora\Api::getUserPublicIdById($UserId);
344:
345: $sPath = 'files/' . $FromType . $FromPath . '/' . $FromName;
346: $oItem = \Afterlogic\DAV\Server::getNodeForPath($sPath, $sUserPublicId);
347:
348: $oToDirectory = $this->getDirectory($sUserPublicId, $ToType, $ToPath);
349: $bIsSharedFile = ($oItem instanceof SharedFile || $oItem instanceof SharedDirectory);
350: $bIsSharedToDirectory = ($oToDirectory instanceof SharedDirectory);
351: $iNotPossibleToMoveSharedFileToSharedFolder = 0;
352: if (class_exists('\Aurora\Modules\SharedFiles\Enums\ErrorCodes')) {
353: $iNotPossibleToMoveSharedFileToSharedFolder = \Aurora\Modules\SharedFiles\Enums\ErrorCodes::NotPossibleToMoveSharedFileToSharedFolder;
354: }
355: if ($IsMove && $bIsSharedFile && $bIsSharedToDirectory) {
356: throw new ApiException($iNotPossibleToMoveSharedFileToSharedFolder);
357: }
358:
359: if (($oItem instanceof SharedFile || $oItem instanceof SharedDirectory) && !$oItem->isInherited()) {
360: $oPdo = new \Afterlogic\DAV\FS\Backend\PDO();
361: $oPdo->updateSharedFileSharePath(Constants::PRINCIPALS_PREFIX . $sUserPublicId, $oItem->getName(), $FromPath, $ToPath, $oItem->getGroupId());
362:
363: $oItem = $oItem->getNode();
364: } else {
365: $ToName = $this->getManager()->getNonExistentFileName(
366: $sUserPublicId,
367: $ToType,
368: $ToPath,
369: $ToName
370: );
371: if (!$bIsSharedToDirectory) {
372: $oItem->copyObjectTo($ToType, $ToPath, $ToName);
373: } else {
374: $oToDirectory->createFile($ToName, $oItem->get(false));
375: }
376: $oPdo = new \Afterlogic\DAV\FS\Backend\PDO();
377: $oPdo->updateShare(Constants::PRINCIPALS_PREFIX . $sUserPublicId, $FromType, $FromPath . '/' . $FromName, $ToType, $ToPath . '/' . $ToName);
378: if ($IsMove) {
379: $oItem->delete();
380: }
381: }
382: }
383:
384: // /**
385: // * Moves file if $aData['Type'] is DropBox account type.
386: // *
387: // * @ignore
388: // * @param array $aData
389: // */
390: // public function onAfterMove(&$aArgs, &$mResult)
391: // {
392: // \Aurora\System\Api::checkUserRoleIsAtLeast(\Aurora\System\Enums\UserRole::NormalUser);
393:
394: // if ($this->checkStorageType($aArgs['FromType']))
395: // {
396: // $mResult = false;
397:
398: // $UserId = $aArgs['UserId'];
399: // Api::CheckAccess($UserId);
400:
401: // // if ($aArgs['ToType'] === $aArgs['FromType'])
402: // // {
403: // foreach ($aArgs['Files'] as $aFile)
404: // {
405: // $ToName = isset($aFile['NewName']) ? $aFile['NewName'] : $aFile['Name'];
406: // $this->copy($UserId, $aArgs['FromType'], $aFile['FromPath'], $aFile['Name'], $aArgs['ToType'], $aArgs['ToPath'], $ToName, true);
407: // }
408: // $mResult = true;
409: // // }
410: // }
411:
412: // }
413:
414: // /**
415: // * Copies file if $aData['Type'] is DropBox account type.
416: // *
417: // * @ignore
418: // * @param array $aData
419: // */
420: // public function onAfterCopy(&$aArgs, &$mResult)
421: // {
422: // \Aurora\System\Api::checkUserRoleIsAtLeast(\Aurora\System\Enums\UserRole::NormalUser);
423:
424: // if ($this->checkStorageType($aArgs['FromType']))
425: // {
426: // $mResult = false;
427:
428: // $UserId = $aArgs['UserId'];
429: // Api::CheckAccess($UserId);
430:
431: // // if ($aArgs['ToType'] === $aArgs['FromType'])
432: // // {
433: // foreach ($aArgs['Files'] as $aFile)
434: // {
435: // $ToName = isset($aFile['NewName']) ? $aFile['NewName'] : $aFile['Name'];
436: // $this->copy($UserId, $aArgs['FromType'], $aFile['FromPath'], $aFile['Name'], $aArgs['ToType'], $aArgs['ToPath'], $ToName, false);
437: // }
438: // $mResult = true;
439: // // }
440: // }
441: // }
442:
443: /**
444: * @ignore
445: * @param array $aArgs Arguments of event.
446: * @param mixed $mResult Is passed by reference.
447: */
448: public function onAfterGetQuota($aArgs, &$mResult)
449: {
450: if ($this->checkStorageType($aArgs['Type'])) {
451: $aQuota = [0, 0];
452: $oNode = \Afterlogic\DAV\Server::getNodeForPath('files/' . static::$sStorageType);
453:
454: if (is_a($oNode, 'Afterlogic\\DAV\\FS\\S3\\' . ucfirst(static::$sStorageType) . '\\Root')) {
455: $aQuota = $oNode->getQuotaInfo();
456: }
457: $iSpaceLimitMb = $aQuota[1];
458:
459: $aArgs = [
460: 'UserId' => \Aurora\System\Api::getAuthenticatedUserId()
461: ];
462: $this->broadcastEvent(
463: 'GetUserSpaceLimitMb',
464: $aArgs,
465: $iSpaceLimitMb
466: );
467:
468: $mResult = [
469: 'Used' => $aQuota[0],
470: 'Limit' => $iSpaceLimitMb
471: ];
472: }
473: }
474:
475: /**
476: * @ignore
477: * @param array $aArgs Arguments of event.
478: * @param mixed $mResult Is passed by reference.
479: */
480: public function onAfterGetSubModules($aArgs, &$mResult)
481: {
482: array_unshift($mResult, 's3.' . static::$sStorageType);
483: }
484:
485:
486: protected function isNeedToReturnBody()
487: {
488: $sMethod = $this->oHttp->GetPost('Method', null);
489:
490: return ((string) \Aurora\System\Router::getItemByIndex(2, '') === 'thumb' ||
491: $sMethod === 'SaveFilesAsTempFiles' ||
492: $sMethod === 'GetFilesForUpload'
493: );
494: }
495:
496: protected function isNeedToReturnWithContectDisposition()
497: {
498: $sAction = (string) \Aurora\System\Router::getItemByIndex(2, 'download');
499: return $sAction === 'download';
500: }
501:
502: /**
503: * Puts file content to $mResult.
504: * @ignore
505: * @param array $aArgs Arguments of event.
506: * @param mixed $mResult Is passed by reference.
507: */
508: public function onGetFile($aArgs, &$mResult)
509: {
510: if ($this->checkStorageType($aArgs['Type'])) {
511: $UserId = $aArgs['UserId'];
512: Api::CheckAccess($UserId);
513:
514: $sUserPiblicId = \Aurora\Api::getUserPublicIdById($UserId);
515:
516: try {
517: $sPath = 'files/' . $aArgs['Type'] . $aArgs['Path'] . '/' . $aArgs['Name'];
518: $oNode = \Afterlogic\DAV\Server::getNodeForPath($sPath, $sUserPiblicId);
519:
520: $sExt = \pathinfo($aArgs['Name'], PATHINFO_EXTENSION);
521:
522: $bNoRedirect = (isset($aArgs['NoRedirect']) && $aArgs['NoRedirect']) ? true : false;
523:
524: if ($oNode instanceof \Afterlogic\DAV\FS\File) {
525: if ($this->isNeedToReturnBody() || \strtolower($sExt) === 'url' || $bNoRedirect) {
526: $mResult = $oNode->get(false);
527: } elseif ($this->isNeedToReturnWithContectDisposition()) {
528: $oNode->getWithContentDisposition();
529: } else {
530: $oNode->get(true);
531: }
532: }
533: } catch (\Sabre\DAV\Exception\NotFound $oEx) {
534: $mResult = false;
535: // echo(\Aurora\System\Managers\Response::GetJsonFromObject('Json', \Aurora\System\Managers\Response::FalseResponse(__METHOD__, 404, 'Not Found')));
536: $this->oHttp->StatusHeader(404);
537: exit;
538: }
539:
540: return true;
541: }
542: }
543:
544: public function onBeforeDeleteTenant($aArgs, &$mResult)
545: {
546: $this->oTenantForDelete = \Aurora\Modules\Core\Module::Decorator()->GetTenantUnchecked($aArgs['TenantId']);
547: }
548:
549: public function onAfterDeleteTenant($aArgs, &$mResult)
550: {
551: if ($this->oTenantForDelete instanceof \Aurora\Modules\Core\Models\Tenant) {
552: try {
553: $oS3Client = $this->getS3Client();
554: $oS3Client->deleteBucket([
555: 'Bucket' => \strtolower($this->sBucketPrefix . \str_replace([' ', '.'], '-', \Afterlogic\DAV\Server::getTenantName($this->oTenantForDelete->Name)))
556: ]);
557: $this->oTenantForDelete = null;
558: } catch(\Exception $oEx) {
559: }
560: }
561: }
562:
563: public function onBeforeDeleteUser($aArgs, &$mResult)
564: {
565: if (isset($aArgs['UserId'])) {
566: $this->oUserForDelete = \Aurora\System\Api::getUserById($aArgs['UserId']);
567: }
568: }
569:
570: public function onAfterDeleteUser($aArgs, $mResult)
571: {
572: if ($this->oUserForDelete instanceof \Aurora\Modules\Core\Models\User) {
573: if ($this->DeleteUserFolder($this->oUserForDelete->IdTenant, $this->oUserForDelete->PublicId)) {
574: $this->oUserForDelete = null;
575: }
576: }
577: }
578:
579: public function DeleteUserFolder($IdTenant, $PublicId)
580: {
581: $bResult = false;
582: try {
583: $oS3Client = $this->getS3Client();
584: $res = $oS3Client->deleteMatchingObjects(
585: $this->getBucketForTenant($IdTenant),
586: $PublicId . '/'
587: );
588: $bResult = true;
589: } catch(\Exception $oEx) {
590: $bResult = false;
591: }
592:
593: return $bResult;
594: }
595:
596: public function TestConnection($Region, $Host, $AccessKey = null, $SecretKey = null, $TenantId = null)
597: {
598: $mResult = true;
599:
600: if (isset($TenantId)) {
601: \Aurora\System\Api::checkUserRoleIsAtLeast(\Aurora\System\Enums\UserRole::TenantAdmin);
602:
603: $oTenant = \Aurora\System\Api::getTenantById($TenantId);
604:
605: if ($oTenant) {
606: $AccessKey = $this->getConfig('AccessKey');
607: $SecretKey = $this->getConfig('SecretKey');
608: }
609: } else {
610: \Aurora\System\Api::checkUserRoleIsAtLeast(\Aurora\System\Enums\UserRole::SuperAdmin);
611: }
612: try {
613: $options = [
614: 'region' => $Region,
615: 'version' => 'latest',
616: 'credentials' => [
617: 'key' => $AccessKey,
618: 'secret' => $SecretKey,
619: ]
620: ];
621: if (!empty($Host)) {
622: $options['endpoint'] = 'https://' . $Host;
623: }
624: $s3Client = new S3Client($options);
625:
626: $buckets = $s3Client->listBuckets();
627: } catch(\Exception $e) {
628: $mResult = false;
629: Api::LogException($e);
630: }
631: return $mResult;
632: }
633: }
634: