Merge pull request #54436 from nextcloud/s3-signed-url

perf(s3): Provide direct pre-signed download link
pull/48131/merge
Andy Scherzinger 2025-12-09 12:38:20 +07:00 committed by GitHub
commit 71c2e94123
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 162 additions and 25 deletions

@ -18,6 +18,7 @@ use OCA\DAV\Connector\Sabre\Exception\FileLocked;
use OCA\DAV\Connector\Sabre\Exception\Forbidden as DAVForbiddenException; use OCA\DAV\Connector\Sabre\Exception\Forbidden as DAVForbiddenException;
use OCA\DAV\Connector\Sabre\Exception\UnsupportedMediaType; use OCA\DAV\Connector\Sabre\Exception\UnsupportedMediaType;
use OCP\App\IAppManager; use OCP\App\IAppManager;
use OCP\Constants;
use OCP\Encryption\Exceptions\GenericEncryptionException; use OCP\Encryption\Exceptions\GenericEncryptionException;
use OCP\Files; use OCP\Files;
use OCP\Files\EntityTooLargeException; use OCP\Files\EntityTooLargeException;
@ -539,18 +540,24 @@ class File extends Node implements IFile {
} }
/** /**
* @return array|bool * @throws NotFoundException
* @throws NotPermittedException
*/ */
public function getDirectDownload() { public function getDirectDownload(): array|false {
if (Server::get(IAppManager::class)->isEnabledForUser('encryption')) { if (Server::get(IAppManager::class)->isEnabledForUser('encryption')) {
return []; return false;
} }
[$storage, $internalPath] = $this->fileView->resolvePath($this->path); $node = $this->getNode();
if (is_null($storage)) { $storage = $node->getStorage();
return []; if (!$storage) {
return false;
}
if (!($node->getPermissions() & Constants::PERMISSION_READ)) {
return false;
} }
return $storage->getDirectDownload($internalPath); return $storage->getDirectDownloadById((string)$node->getId());
} }
/** /**

@ -50,6 +50,7 @@ class FilesPlugin extends ServerPlugin {
public const OCM_SHARE_PERMISSIONS_PROPERTYNAME = '{http://open-cloud-mesh.org/ns}share-permissions'; public const OCM_SHARE_PERMISSIONS_PROPERTYNAME = '{http://open-cloud-mesh.org/ns}share-permissions';
public const SHARE_ATTRIBUTES_PROPERTYNAME = '{http://nextcloud.org/ns}share-attributes'; public const SHARE_ATTRIBUTES_PROPERTYNAME = '{http://nextcloud.org/ns}share-attributes';
public const DOWNLOADURL_PROPERTYNAME = '{http://owncloud.org/ns}downloadURL'; public const DOWNLOADURL_PROPERTYNAME = '{http://owncloud.org/ns}downloadURL';
public const DOWNLOADURL_EXPIRATION_PROPERTYNAME = '{http://nextcloud.org/ns}download-url-expiration';
public const SIZE_PROPERTYNAME = '{http://owncloud.org/ns}size'; public const SIZE_PROPERTYNAME = '{http://owncloud.org/ns}size';
public const GETETAG_PROPERTYNAME = '{DAV:}getetag'; public const GETETAG_PROPERTYNAME = '{DAV:}getetag';
public const LASTMODIFIED_PROPERTYNAME = '{DAV:}lastmodified'; public const LASTMODIFIED_PROPERTYNAME = '{DAV:}lastmodified';
@ -120,6 +121,7 @@ class FilesPlugin extends ServerPlugin {
$server->protectedProperties[] = self::SHARE_ATTRIBUTES_PROPERTYNAME; $server->protectedProperties[] = self::SHARE_ATTRIBUTES_PROPERTYNAME;
$server->protectedProperties[] = self::SIZE_PROPERTYNAME; $server->protectedProperties[] = self::SIZE_PROPERTYNAME;
$server->protectedProperties[] = self::DOWNLOADURL_PROPERTYNAME; $server->protectedProperties[] = self::DOWNLOADURL_PROPERTYNAME;
$server->protectedProperties[] = self::DOWNLOADURL_EXPIRATION_PROPERTYNAME;
$server->protectedProperties[] = self::OWNER_ID_PROPERTYNAME; $server->protectedProperties[] = self::OWNER_ID_PROPERTYNAME;
$server->protectedProperties[] = self::OWNER_DISPLAY_NAME_PROPERTYNAME; $server->protectedProperties[] = self::OWNER_DISPLAY_NAME_PROPERTYNAME;
$server->protectedProperties[] = self::CHECKSUMS_PROPERTYNAME; $server->protectedProperties[] = self::CHECKSUMS_PROPERTYNAME;
@ -471,19 +473,30 @@ class FilesPlugin extends ServerPlugin {
} }
if ($node instanceof File) { if ($node instanceof File) {
$propFind->handle(self::DOWNLOADURL_PROPERTYNAME, function () use ($node) { $requestProperties = $propFind->getRequestedProperties();
if (in_array(self::DOWNLOADURL_PROPERTYNAME, $requestProperties, true)
|| in_array(self::DOWNLOADURL_EXPIRATION_PROPERTYNAME, $requestProperties, true)) {
try { try {
$directDownloadUrl = $node->getDirectDownload(); $directDownloadUrl = $node->getDirectDownload();
if (isset($directDownloadUrl['url'])) { } catch (StorageNotAvailableException|ForbiddenException) {
$directDownloadUrl = null;
}
$propFind->handle(self::DOWNLOADURL_PROPERTYNAME, function () use ($node, $directDownloadUrl) {
if ($directDownloadUrl && isset($directDownloadUrl['url'])) {
return $directDownloadUrl['url']; return $directDownloadUrl['url'];
} }
} catch (StorageNotAvailableException $e) {
return false;
} catch (ForbiddenException $e) {
return false; return false;
});
$propFind->handle(self::DOWNLOADURL_EXPIRATION_PROPERTYNAME, function () use ($node, $directDownloadUrl) {
if ($directDownloadUrl && isset($directDownloadUrl['expiration'])) {
return $directDownloadUrl['expiration'];
} }
return false; return false;
}); });
}
$propFind->handle(self::CHECKSUMS_PROPERTYNAME, function () use ($node) { $propFind->handle(self::CHECKSUMS_PROPERTYNAME, function () use ($node) {
$checksum = $node->getChecksum(); $checksum = $node->getChecksum();

@ -40,6 +40,7 @@ use OCP\Lock\ILockingProvider;
use OCP\Server; use OCP\Server;
use OCP\Share\IShare; use OCP\Share\IShare;
use OCP\Util; use OCP\Util;
use Override;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
/** /**
@ -558,8 +559,15 @@ class SharedStorage extends Jail implements LegacyISharedStorage, ISharedStorage
return parent::getUnjailedPath($path); return parent::getUnjailedPath($path);
} }
#[Override]
public function getDirectDownload(string $path): array|false { public function getDirectDownload(string $path): array|false {
// disable direct download for shares // disable direct download for shares
return []; return false;
}
#[Override]
public function getDirectDownloadById(string $fileId): array|false {
// disable direct download for shares
return false;
} }
} }

@ -117,4 +117,8 @@ class Azure implements IObjectStore {
public function copyObject($from, $to) { public function copyObject($from, $to) {
$this->getBlobClient()->copyBlob($this->containerName, $to, $this->containerName, $from); $this->getBlobClient()->copyBlob($this->containerName, $to, $this->containerName, $from);
} }
public function preSignedUrl(string $urn, \DateTimeInterface $expiration): ?string {
return null;
}
} }

@ -29,6 +29,7 @@ use OCP\Files\Storage\IChunkedFileWrite;
use OCP\Files\Storage\IStorage; use OCP\Files\Storage\IStorage;
use OCP\IDBConnection; use OCP\IDBConnection;
use OCP\Server; use OCP\Server;
use Override;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
class ObjectStoreStorage extends \OC\Files\Storage\Common implements IChunkedFileWrite { class ObjectStoreStorage extends \OC\Files\Storage\Common implements IChunkedFileWrite {
@ -844,4 +845,22 @@ class ObjectStoreStorage extends \OC\Files\Storage\Common implements IChunkedFil
return $available; return $available;
} }
#[Override]
public function getDirectDownloadById(string $fileId): array|false {
$expiration = new \DateTimeImmutable('+60 minutes');
$url = $this->objectStore->preSignedUrl($this->getURN((int)$fileId), $expiration);
return $url ? ['url' => $url, 'expiration' => $expiration->getTimestamp()] : false;
}
#[Override]
public function getDirectDownload(string $path): array|false {
$path = $this->normalizePath($path);
$cacheEntry = $this->getCache()->get($path);
if (!$cacheEntry || $cacheEntry->getMimeType() === FileInfo::MIMETYPE_FOLDER) {
return false;
}
return $this->getDirectDownloadById((string)$cacheEntry->getId());
}
} }

@ -31,8 +31,8 @@ trait S3ConnectionTrait {
protected bool $test; protected bool $test;
protected ?S3Client $connection = null; protected ?S3Client $connection = null;
private ?ICache $existingBucketsCache = null; private ?ICache $existingBucketsCache = null;
private bool $usePresignedUrl = false;
protected function parseParams($params) { protected function parseParams($params) {
if (empty($params['bucket'])) { if (empty($params['bucket'])) {
@ -109,12 +109,15 @@ trait S3ConnectionTrait {
) )
); );
$this->usePresignedUrl = $this->params['use_presigned_url'] ?? false;
$options = [ $options = [
'version' => $this->params['version'] ?? 'latest', 'version' => $this->params['version'] ?? 'latest',
'credentials' => $provider, 'credentials' => $provider,
'endpoint' => $base_url, 'endpoint' => $base_url,
'region' => $this->params['region'], 'region' => $this->params['region'],
'use_path_style_endpoint' => isset($this->params['use_path_style']) ? $this->params['use_path_style'] : false, 'use_path_style_endpoint' => isset($this->params['use_path_style']) ? $this->params['use_path_style'] : false,
'proxy' => isset($this->params['proxy']) ? $this->params['proxy'] : false,
'signature_provider' => \Aws\or_chain([self::class, 'legacySignatureProvider'], ClientResolver::_default_signature_provider()), 'signature_provider' => \Aws\or_chain([self::class, 'legacySignatureProvider'], ClientResolver::_default_signature_provider()),
'csm' => false, 'csm' => false,
'use_arn_region' => false, 'use_arn_region' => false,
@ -291,4 +294,8 @@ trait S3ConnectionTrait {
'SSECustomerKeyMD5' => md5($rawKey, true) 'SSECustomerKeyMD5' => md5($rawKey, true)
]; ];
} }
public function isUsePresignedUrl(): bool {
return $this->usePresignedUrl;
}
} }

@ -7,6 +7,7 @@
namespace OC\Files\ObjectStore; namespace OC\Files\ObjectStore;
use Aws\Command; use Aws\Command;
use Aws\Exception\AwsException;
use Aws\Exception\MultipartUploadException; use Aws\Exception\MultipartUploadException;
use Aws\S3\Exception\S3MultipartUploadException; use Aws\S3\Exception\S3MultipartUploadException;
use Aws\S3\MultipartCopy; use Aws\S3\MultipartCopy;
@ -295,4 +296,23 @@ trait S3ObjectTrait {
], $options)); ], $options));
} }
} }
public function preSignedUrl(string $urn, \DateTimeInterface $expiration): ?string {
$command = $this->getConnection()->getCommand('GetObject', [
'Bucket' => $this->getBucket(),
'Key' => $urn,
]);
if (!$this->isUsePresignedUrl()) {
return null;
}
try {
return (string)$this->getConnection()->createPresignedRequest($command, $expiration, [
'signPayload' => true,
])->getUri();
} catch (AwsException) {
return null;
}
}
} }

@ -74,4 +74,8 @@ class StorageObjectStore implements IObjectStore {
public function copyObject($from, $to) { public function copyObject($from, $to) {
$this->storage->copy($from, $to); $this->storage->copy($from, $to);
} }
public function preSignedUrl(string $urn, \DateTimeInterface $expiration): ?string {
return null;
}
} }

@ -134,4 +134,7 @@ class Swift implements IObjectStore {
'destination' => $this->getContainer()->name . '/' . $to 'destination' => $this->getContainer()->name . '/' . $to
]); ]);
} }
public function preSignedUrl(string $urn, \DateTimeInterface $expiration): ?string {
return null;
}
} }

@ -38,6 +38,7 @@ use OCP\IConfig;
use OCP\Lock\ILockingProvider; use OCP\Lock\ILockingProvider;
use OCP\Lock\LockedException; use OCP\Lock\LockedException;
use OCP\Server; use OCP\Server;
use Override;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
/** /**
@ -445,13 +446,14 @@ abstract class Common implements Storage, ILockingStorage, IWriteStreamStorage,
return is_a($this, $class); return is_a($this, $class);
} }
/** #[Override]
* A custom storage implementation can return an url for direct download of a give file.
*
* For now the returned array can hold the parameter url - in future more attributes might follow.
*/
public function getDirectDownload(string $path): array|false { public function getDirectDownload(string $path): array|false {
return []; return false;
}
#[Override]
public function getDirectDownloadById(string $fileId): array|false {
return false;
} }
public function verifyPath(string $path, string $fileName): void { public function verifyPath(string $path, string $fileName): void {

@ -154,6 +154,10 @@ class FailedStorage extends Common {
throw new StorageNotAvailableException($this->e->getMessage(), $this->e->getCode(), $this->e); throw new StorageNotAvailableException($this->e->getMessage(), $this->e->getCode(), $this->e);
} }
public function getDirectDownloadById(string $fileId): never {
throw new StorageNotAvailableException($this->e->getMessage(), $this->e->getCode(), $this->e);
}
public function verifyPath(string $path, string $fileName): void { public function verifyPath(string $path, string $fileName): void {
} }

@ -228,6 +228,10 @@ class Availability extends Wrapper {
return $this->handleAvailability('getDirectDownload', $path); return $this->handleAvailability('getDirectDownload', $path);
} }
public function getDirectDownloadById(string $fileId): array|false {
return $this->handleAvailability('getDirectDownloadById', $fileId);
}
public function copyFromStorage(IStorage $sourceStorage, string $sourceInternalPath, string $targetInternalPath): bool { public function copyFromStorage(IStorage $sourceStorage, string $sourceInternalPath, string $targetInternalPath): bool {
return $this->handleAvailability('copyFromStorage', $sourceStorage, $sourceInternalPath, $targetInternalPath); return $this->handleAvailability('copyFromStorage', $sourceStorage, $sourceInternalPath, $targetInternalPath);
} }

@ -20,6 +20,7 @@ use OCP\Files\Storage\IStorage;
use OCP\Files\Storage\IWriteStreamStorage; use OCP\Files\Storage\IWriteStreamStorage;
use OCP\Lock\ILockingProvider; use OCP\Lock\ILockingProvider;
use OCP\Server; use OCP\Server;
use Override;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
class Wrapper implements \OC\Files\Storage\Storage, ILockingStorage, IWriteStreamStorage { class Wrapper implements \OC\Files\Storage\Storage, ILockingStorage, IWriteStreamStorage {
@ -258,10 +259,16 @@ class Wrapper implements \OC\Files\Storage\Storage, ILockingStorage, IWriteStrea
return call_user_func_array([$this->getWrapperStorage(), $method], $args); return call_user_func_array([$this->getWrapperStorage(), $method], $args);
} }
#[Override]
public function getDirectDownload(string $path): array|false { public function getDirectDownload(string $path): array|false {
return $this->getWrapperStorage()->getDirectDownload($path); return $this->getWrapperStorage()->getDirectDownload($path);
} }
#[Override]
public function getDirectDownloadById(string $fileId): array|false {
return $this->getWrapperStorage()->getDirectDownloadById($fileId);
}
public function getAvailability(): array { public function getAvailability(): array {
return $this->getWrapperStorage()->getAvailability(); return $this->getWrapperStorage()->getAvailability();
} }

@ -145,6 +145,10 @@ class NullStorage extends Common {
return false; return false;
} }
public function getDirectDownloadById(string $fileId): array|false {
return false;
}
public function copyFromStorage(IStorage $sourceStorage, string $sourceInternalPath, string $targetInternalPath, bool $preserveMtime = false): never { public function copyFromStorage(IStorage $sourceStorage, string $sourceInternalPath, string $targetInternalPath, bool $preserveMtime = false): never {
throw new \OC\ForbiddenException('This request is not allowed to access the filesystem'); throw new \OC\ForbiddenException('This request is not allowed to access the filesystem');
} }

@ -63,4 +63,10 @@ interface IObjectStore {
* @since 21.0.0 * @since 21.0.0
*/ */
public function copyObject($from, $to); public function copyObject($from, $to);
/**
* Get pre signed url for an object
* @since 33.0.0
*/
public function preSignedUrl(string $urn, \DateTimeInterface $expiration): ?string;
} }

@ -302,15 +302,28 @@ interface IStorage {
public function instanceOfStorage(string $class); public function instanceOfStorage(string $class);
/** /**
* A custom storage implementation can return an url for direct download of a give file. * A custom storage implementation can return a url for direct download of a give file.
* *
* For now the returned array can hold the parameter url - in future more attributes might follow. * For now the returned array can hold the parameter url and expiration - in future more attributes might follow.
* *
* @return array|false * @param string $path Either the path or the fileId
* @return array{url: ?string, expiration: ?int}|false
* @since 9.0.0 * @since 9.0.0
* @deprecated Use IStorage::getDirectDownloadById instead.
*/ */
public function getDirectDownload(string $path); public function getDirectDownload(string $path);
/**
* A custom storage implementation can return a url for direct download of a give file.
*
* For now the returned array can hold the parameter url and expiration - in future more attributes might follow.
*
* @param string $fileId The fileId of the file.
* @return array{url: ?string, expiration: ?int}|false
* @since 33.0.0
*/
public function getDirectDownloadById(string $fileId): array|false;
/** /**
* @return void * @return void
* @throws InvalidPathException * @throws InvalidPathException

@ -268,4 +268,8 @@ class FakeObjectStore implements IObjectStore {
public function copyObject($from, $to) { public function copyObject($from, $to) {
} }
public function preSignedUrl(string $urn, \DateTimeInterface $expiration): ?string {
return null;
}
} }

@ -39,4 +39,8 @@ class FailDeleteObjectStore implements IObjectStore {
public function copyObject($from, $to) { public function copyObject($from, $to) {
$this->objectStore->copyObject($from, $to); $this->objectStore->copyObject($from, $to);
} }
public function preSignedUrl(string $urn, \DateTimeInterface $expiration): ?string {
return null;
}
} }

@ -40,4 +40,8 @@ class FailWriteObjectStore implements IObjectStore {
public function copyObject($from, $to) { public function copyObject($from, $to) {
$this->objectStore->copyObject($from, $to); $this->objectStore->copyObject($from, $to);
} }
public function preSignedUrl(string $urn, \DateTimeInterface $expiration): ?string {
return null;
}
} }