diff --git a/apps/dav/composer/composer/autoload_classmap.php b/apps/dav/composer/composer/autoload_classmap.php index d4305195d461e..671d9b43fe394 100644 --- a/apps/dav/composer/composer/autoload_classmap.php +++ b/apps/dav/composer/composer/autoload_classmap.php @@ -343,6 +343,7 @@ 'OCA\\DAV\\Upload\\FutureFile' => $baseDir . '/../lib/Upload/FutureFile.php', 'OCA\\DAV\\Upload\\PartFile' => $baseDir . '/../lib/Upload/PartFile.php', 'OCA\\DAV\\Upload\\RootCollection' => $baseDir . '/../lib/Upload/RootCollection.php', + 'OCA\\DAV\\Upload\\SymlinkPlugin' => $baseDir . '/../lib/Upload/SymlinkPlugin.php', 'OCA\\DAV\\Upload\\UploadFile' => $baseDir . '/../lib/Upload/UploadFile.php', 'OCA\\DAV\\Upload\\UploadFolder' => $baseDir . '/../lib/Upload/UploadFolder.php', 'OCA\\DAV\\Upload\\UploadHome' => $baseDir . '/../lib/Upload/UploadHome.php', diff --git a/apps/dav/composer/composer/autoload_static.php b/apps/dav/composer/composer/autoload_static.php index 9afd73635ffd1..8f219cd575769 100644 --- a/apps/dav/composer/composer/autoload_static.php +++ b/apps/dav/composer/composer/autoload_static.php @@ -358,6 +358,7 @@ class ComposerStaticInitDAV 'OCA\\DAV\\Upload\\FutureFile' => __DIR__ . '/..' . '/../lib/Upload/FutureFile.php', 'OCA\\DAV\\Upload\\PartFile' => __DIR__ . '/..' . '/../lib/Upload/PartFile.php', 'OCA\\DAV\\Upload\\RootCollection' => __DIR__ . '/..' . '/../lib/Upload/RootCollection.php', + 'OCA\\DAV\\Upload\\SymlinkPlugin' => __DIR__ . '/..' . '/../lib/Upload/SymlinkPlugin.php', 'OCA\\DAV\\Upload\\UploadFile' => __DIR__ . '/..' . '/../lib/Upload/UploadFile.php', 'OCA\\DAV\\Upload\\UploadFolder' => __DIR__ . '/..' . '/../lib/Upload/UploadFolder.php', 'OCA\\DAV\\Upload\\UploadHome' => __DIR__ . '/..' . '/../lib/Upload/UploadHome.php', diff --git a/apps/dav/lib/BulkUpload/BulkUploadPlugin.php b/apps/dav/lib/BulkUpload/BulkUploadPlugin.php index 4d838d255eb08..dc5ab5973a231 100644 --- a/apps/dav/lib/BulkUpload/BulkUploadPlugin.php +++ b/apps/dav/lib/BulkUpload/BulkUploadPlugin.php @@ -23,6 +23,7 @@ namespace OCA\DAV\BulkUpload; +use OC\Files\SymlinkManager; use OCA\DAV\Connector\Sabre\MtimeSanitizer; use OCP\AppFramework\Http; use OCP\Files\DavUtil; @@ -37,12 +38,18 @@ class BulkUploadPlugin extends ServerPlugin { private Folder $userFolder; private LoggerInterface $logger; + /** + * @var SymlinkManager + */ + private $symlinkManager; + public function __construct( Folder $userFolder, LoggerInterface $logger ) { $this->userFolder = $userFolder; $this->logger = $logger; + $this->symlinkManager = new SymlinkManager(); } /** @@ -93,6 +100,14 @@ public function httpPost(RequestInterface $request, ResponseInterface $response) $node->touch($mtime); $node = $this->userFolder->getById($node->getId())[0]; + if (isset($headers['oc-file-type']) && $headers['oc-file-type'] == 1) { + $this->symlinkManager->storeSymlink($node); + } elseif ($this->symlinkManager->isSymlink($node)) { + // uploaded file is not a symlink, but there was a symlink + // at the same location before + $this->symlinkManager->deleteSymlink($node); + } + $writtenFiles[$headers['x-file-path']] = [ "error" => false, "etag" => $node->getETag(), diff --git a/apps/dav/lib/Connector/Sabre/File.php b/apps/dav/lib/Connector/Sabre/File.php index f188490fd938f..606987762c1e9 100644 --- a/apps/dav/lib/Connector/Sabre/File.php +++ b/apps/dav/lib/Connector/Sabre/File.php @@ -125,7 +125,7 @@ public function __construct(View $view, FileInfo $info, IManager $shareManager = * different object on a subsequent GET you are strongly recommended to not * return an ETag, and just return null. * - * @param resource $data + * @param resource|string $data * * @throws Forbidden * @throws UnsupportedMediaType diff --git a/apps/dav/lib/Connector/Sabre/FilesPlugin.php b/apps/dav/lib/Connector/Sabre/FilesPlugin.php index f7904e8788331..8dd7fcb056ff7 100644 --- a/apps/dav/lib/Connector/Sabre/FilesPlugin.php +++ b/apps/dav/lib/Connector/Sabre/FilesPlugin.php @@ -35,6 +35,7 @@ namespace OCA\DAV\Connector\Sabre; use OC\AppFramework\Http\Request; +use OC\Files\SymlinkManager; use OCP\Constants; use OCP\Files\ForbiddenException; use OCP\Files\StorageNotAvailableException; @@ -73,6 +74,7 @@ class FilesPlugin extends ServerPlugin { public const LASTMODIFIED_PROPERTYNAME = '{DAV:}lastmodified'; public const CREATIONDATE_PROPERTYNAME = '{DAV:}creationdate'; public const DISPLAYNAME_PROPERTYNAME = '{DAV:}displayname'; + public const RESOURCETYPE_PROPERTYNAME = '{DAV:}resourcetype'; public const OWNER_ID_PROPERTYNAME = '{http://owncloud.org/ns}owner-id'; public const OWNER_DISPLAY_NAME_PROPERTYNAME = '{http://owncloud.org/ns}owner-display-name'; public const CHECKSUMS_PROPERTYNAME = '{http://owncloud.org/ns}checksums'; @@ -103,6 +105,7 @@ class FilesPlugin extends ServerPlugin { private IConfig $config; private IRequest $request; private IPreview $previewManager; + private SymlinkManager $symlinkManager; public function __construct(Tree $tree, IConfig $config, @@ -118,6 +121,7 @@ public function __construct(Tree $tree, $this->isPublic = $isPublic; $this->downloadAttachment = $downloadAttachment; $this->previewManager = $previewManager; + $this->symlinkManager = new SymlinkManager(); } /** @@ -159,7 +163,8 @@ public function initialize(Server $server) { $this->server->on('propPatch', [$this, 'handleUpdateProperties']); $this->server->on('afterBind', [$this, 'sendFileIdHeader']); $this->server->on('afterWriteContent', [$this, 'sendFileIdHeader']); - $this->server->on('afterMethod:GET', [$this,'httpGet']); + $this->server->on('method:GET', [$this,'httpGet']); + $this->server->on('afterMethod:GET', [$this,'afterHttpGet']); $this->server->on('afterMethod:GET', [$this, 'handleDownloadToken']); $this->server->on('afterResponse', function ($request, ResponseInterface $response) { $body = $response->getBody(); @@ -224,13 +229,35 @@ public function handleDownloadToken(RequestInterface $request, ResponseInterface } } + public function httpGet(RequestInterface $request, ResponseInterface $response): bool { + // only handle symlinks + $node = $this->tree->getNodeForPath($request->getPath()); + if (!($node instanceof \OCA\DAV\Connector\Sabre\File + && $this->symlinkManager->isSymlink($node->getFileInfo()))) { + return true; + } + + $date = new \DateTime(); + $date->setTimestamp($node->getLastModified()); + $date = $date->setTimezone(new \DateTimeZone('UTC')); + $response->setHeader('Last-Modified', $date->format('D, d M Y H:i:s').' GMT'); + $response->setHeader('OC-File-Type', '1'); + $response->setHeader('OC-ETag', $node->getEtag()); + $response->setHeader('ETag', $node->getEtag()); + $response->setBody($node->get()); + $response->setStatus(200); + // do not continue processing this request + return false; + } + + /** * Add headers to file download * * @param RequestInterface $request * @param ResponseInterface $response */ - public function httpGet(RequestInterface $request, ResponseInterface $response) { + public function afterHttpGet(RequestInterface $request, ResponseInterface $response): void { // Only handle valid files $node = $this->tree->getNodeForPath($request->getPath()); if (!($node instanceof IFile)) { @@ -431,6 +458,14 @@ public function handleGetProperties(PropFind $propFind, \Sabre\DAV\INode $node) $propFind->handle(self::UPLOAD_TIME_PROPERTYNAME, function () use ($node) { return $node->getFileInfo()->getUploadTime(); }); + + $propFind->handle(self::RESOURCETYPE_PROPERTYNAME, function() use ($node) { + $info = $node->getFileInfo(); + if ($this->symlinkManager->isSymlink($info)) { + return new \Sabre\DAV\Xml\Property\ResourceType(['{DAV:}symlink']); + } + return null; + }); } if ($node instanceof Directory) { diff --git a/apps/dav/lib/Server.php b/apps/dav/lib/Server.php index dedb959c1cd6a..d5361dac0703f 100644 --- a/apps/dav/lib/Server.php +++ b/apps/dav/lib/Server.php @@ -74,6 +74,7 @@ use OCA\DAV\SystemTag\SystemTagPlugin; use OCA\DAV\Upload\ChunkingPlugin; use OCA\DAV\Upload\ChunkingV2Plugin; +use OCA\DAV\Upload\SymlinkPlugin; use OCP\AppFramework\Http\Response; use OCP\Diagnostics\IEventLogger; use OCP\EventDispatcher\IEventDispatcher; @@ -222,6 +223,7 @@ public function __construct(IRequest $request, string $baseUri) { $this->server->addPlugin(new RequestIdHeaderPlugin(\OC::$server->get(IRequest::class))); $this->server->addPlugin(new ChunkingV2Plugin(\OCP\Server::get(ICacheFactory::class))); $this->server->addPlugin(new ChunkingPlugin()); + $this->server->addPlugin(new SymlinkPlugin($logger)); // allow setup of additional plugins $dispatcher->dispatch('OCA\DAV\Connector\Sabre::addPlugin', $event); diff --git a/apps/dav/lib/Upload/SymlinkPlugin.php b/apps/dav/lib/Upload/SymlinkPlugin.php new file mode 100644 index 0000000000000..b4123e9ff0a58 --- /dev/null +++ b/apps/dav/lib/Upload/SymlinkPlugin.php @@ -0,0 +1,169 @@ + + * + * @author Tamino Bauknecht + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OCA\DAV\Upload; + +use OC\Files\SymlinkManager; +use Psr\Log\LoggerInterface; +use Sabre\DAV\Server; +use Sabre\DAV\ServerPlugin; +use Sabre\HTTP\RequestInterface; +use Sabre\HTTP\ResponseInterface; + +class SymlinkPlugin extends ServerPlugin { + /** + * @var Server + * + * @psalm-suppress PropertyNotSetInConstructor + */ + private $server; + /** @var SymlinkManager */ + private $symlinkManager; + /** @var LoggerInterface */ + private $logger; + + public function __construct(LoggerInterface $logger) { + $this->symlinkManager = new SymlinkManager(); + $this->logger = $logger; + } + + /** + * @inheritdoc + */ + public function initialize(Server $server): void { + $server->on('method:PUT', [$this, 'httpPut']); + $server->on('method:DELETE', [$this, 'httpDelete']); + $server->on('afterMove', [$this, 'afterMove']); + $server->on('afterCopy', [$this, 'afterCopy']); + + $this->server = $server; + } + + public function httpPut(RequestInterface $request, ResponseInterface $response): bool { + if ($request->hasHeader('OC-File-Type') && $request->getHeader('OC-File-Type') == 1) { + $symlinkPath = $request->getPath(); + $symlinkName = basename($symlinkPath); + $symlinkTarget = $request->getBodyAsString(); + $parentPath = dirname($symlinkPath); + $parentNode = $this->server->tree->getNodeForPath($parentPath); + if (!$parentNode instanceof \Sabre\DAV\ICollection) { + throw new \Sabre\DAV\Exception\Forbidden("Directory does not allow creation of files - failed to upload '$symlinkName'"); + } + $etag = $parentNode->createFile($symlinkName); + $symlinkNode = $parentNode->getChild($symlinkName); + if (!$symlinkNode instanceof \OCA\DAV\Connector\Sabre\File) { + throw new \Sabre\DAV\Exception\NotFound("Failed to get newly created file '$symlinkName'"); + } + $symlinkNode->put($symlinkTarget); + $this->symlinkManager->storeSymlink($symlinkNode->getFileInfo()); + + if ($etag) { + $response->setHeader('OC-ETag', $etag); + } + $response->setStatus(201); + return false; // this request was handled already + } elseif ($this->server->tree->nodeExists($request->getPath())) { + $node = $this->server->tree->getNodeForPath($request->getPath()); + if (!$node instanceof \OCA\DAV\Connector\Sabre\File) { + // cannot check if file was symlink before - let's hope it's not + $this->logger->warning('Unable to check if there was a symlink + before at the same location'); + return true; + } + // if the newly uploaded file is not a symlink, + // but there was a symlink at the same path before + if ($this->symlinkManager->isSymlink($node->getFileInfo())) { + $this->symlinkManager->deleteSymlink($node->getFileInfo()); + } + } + return true; // continue handling this request + } + + public function httpDelete(RequestInterface $request, ResponseInterface $response): bool { + $path = $request->getPath(); + $node = $this->server->tree->getNodeForPath($path); + if (!$node instanceof \OCA\DAV\Connector\Sabre\Node) { + return true; + } + $info = $node->getFileInfo(); + if ($this->symlinkManager->isSymlink($info)) { + if (!$this->symlinkManager->deleteSymlink($info)) { + $symlinkName = $info->getName(); + throw new \Sabre\DAV\Exception\NotFound("Unable to delete symlink '$symlinkName'!"); + } + } + // always propagate to trigger deletion of regular file representing symlink in filesystem + return true; + } + + public function afterMove(string $source, string $destination): void { + // source node does not exist anymore, thus use still existing parent + $sourceParentNode = dirname($source); + $sourceParentNode = $this->server->tree->getNodeForPath($sourceParentNode); + if (!$sourceParentNode instanceof \OCA\DAV\Connector\Sabre\Node) { + throw new \Sabre\DAV\Exception\NotImplemented('Unable to check if moved file is a symlink!'); + } + $destinationNode = $this->server->tree->getNodeForPath($destination); + if (!$destinationNode instanceof \OCA\DAV\Connector\Sabre\Node) { + throw new \Sabre\DAV\Exception\NotImplemented('Unable to set symlink information on move destination!'); + } + + $sourceInfo = new \OC\Files\FileInfo( + $source, + $sourceParentNode->getFileInfo()->getStorage(), + $sourceParentNode->getInternalPath() . '/' . basename($source), + [], + $sourceParentNode->getFileInfo()->getMountPoint()); + $destinationInfo = $destinationNode->getFileInfo(); + + if ($this->symlinkManager->isSymlink($sourceInfo)) { + $this->symlinkManager->deleteSymlink($sourceInfo); + $this->symlinkManager->storeSymlink($destinationInfo); + } elseif ($this->symlinkManager->isSymlink($destinationInfo)) { + // source was not a symlink, but destination was a symlink before + $this->symlinkManager->deleteSymlink($destinationInfo); + } + } + + public function afterCopy(string $source, string $destination): void { + $sourceNode = $this->server->tree->getNodeForPath($source); + if (!$sourceNode instanceof \OCA\DAV\Connector\Sabre\Node) { + throw new \Sabre\DAV\Exception\NotImplemented( + 'Unable to check if copied file is a symlink!'); + } + $destinationNode = $this->server->tree->getNodeForPath($destination); + if (!$destinationNode instanceof \OCA\DAV\Connector\Sabre\Node) { + throw new \Sabre\DAV\Exception\NotImplemented( + 'Unable to set symlink information on copy destination!'); + } + + $sourceInfo = $sourceNode->getFileInfo(); + $destinationInfo = $destinationNode->getFileInfo(); + + if ($this->symlinkManager->isSymlink($sourceInfo)) { + $this->symlinkManager->storeSymlink($destinationInfo); + } + } +} diff --git a/core/Controller/PreviewController.php b/core/Controller/PreviewController.php index 7adec03814c7d..e57b642b0a916 100644 --- a/core/Controller/PreviewController.php +++ b/core/Controller/PreviewController.php @@ -27,6 +27,7 @@ */ namespace OC\Core\Controller; +use OC\Files\SymlinkManager; use OCA\Files_Sharing\SharedStorage; use OCP\AppFramework\Controller; use OCP\AppFramework\Http; @@ -42,6 +43,12 @@ use OCP\Preview\IMimeIconProvider; class PreviewController extends Controller { + private const SYMLINK_PREVIEW_ICON_PATH = '/core/img/filetypes/link.svg'; + /** + * @var SymlinkManager + */ + private $symlinkManager; + public function __construct( string $appName, IRequest $request, @@ -51,6 +58,8 @@ public function __construct( private IMimeIconProvider $mimeIconProvider, ) { parent::__construct($appName, $request); + + $this->symlinkManager = new SymlinkManager(); } /** @@ -169,6 +178,14 @@ private function fetchPreview( } } + if ($this->symlinkManager->isSymlink($node)) { + if ($url = \OC::$server->get(\OCP\IURLGenerator::class)->getAbsoluteURL( + self::SYMLINK_PREVIEW_ICON_PATH + )) { + return new RedirectResponse($url); + } + } + try { $f = $this->preview->getPreview($node, $x, $y, !$a, $mode); $response = new FileDisplayResponse($f, Http::STATUS_OK, [ diff --git a/core/Migrations/Version29000Date20231123170742.php b/core/Migrations/Version29000Date20231123170742.php new file mode 100644 index 0000000000000..f96f7ebd3da49 --- /dev/null +++ b/core/Migrations/Version29000Date20231123170742.php @@ -0,0 +1,78 @@ + + * + * @author Tamino Bauknecht + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OC\Core\Migrations; + +use Closure; +use OCP\DB\ISchemaWrapper; +use OCP\DB\Types; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +/** + * Introduce symlinks table + */ +class Version29000Date20231123170742 extends SimpleMigrationStep { + /** + * @param IOutput $output + * @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper` + * @param array $options + * @return null|ISchemaWrapper + */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + /** @var ISchemaWrapper $schema */ + $schema = $schemaClosure(); + + if (!$schema->hasTable('symlinks')) { + $table = $schema->createTable('symlinks'); + + $table->addColumn('id', Types::BIGINT, [ + 'notnull' => true, + 'length' => 64, + 'autoincrement' => true, + ]); + $table->addColumn('storage', Types::BIGINT, [ + 'notnull' => true, + 'length' => 20, + ]); + $table->addColumn('path', Types::STRING, [ + 'notnull' => true, + 'length' => 4000, + ]); + $table->addColumn('name', Types::STRING, [ + 'notnull' => false, + 'length' => 255, + ]); + + $table->setPrimaryKey(['id'], 'symlinks_id_index'); + $table->addUniqueIndex(['storage', 'path'], 'symlinks_storage_path_index'); + + return $schema; + } + + return null; + } +} diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index f1de00a49bf89..6a0406a00bb8f 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -1243,6 +1243,7 @@ 'OC\\Core\\Migrations\\Version28000Date20231103104802' => $baseDir . '/core/Migrations/Version28000Date20231103104802.php', 'OC\\Core\\Migrations\\Version29000Date20231126110901' => $baseDir . '/core/Migrations/Version29000Date20231126110901.php', 'OC\\Core\\Migrations\\Version29000Date20231213104850' => $baseDir . '/core/Migrations/Version29000Date20231213104850.php', + 'OC\\Core\\Migrations\\Version29000Date20231123170742' => $baseDir . '/core/Migrations/Version29000Date20231123170742.php', 'OC\\Core\\Notification\\CoreNotifier' => $baseDir . '/core/Notification/CoreNotifier.php', 'OC\\Core\\Service\\LoginFlowV2Service' => $baseDir . '/core/Service/LoginFlowV2Service.php', 'OC\\DB\\Adapter' => $baseDir . '/lib/private/DB/Adapter.php', @@ -1440,6 +1441,7 @@ 'OC\\Files\\Stream\\HashWrapper' => $baseDir . '/lib/private/Files/Stream/HashWrapper.php', 'OC\\Files\\Stream\\Quota' => $baseDir . '/lib/private/Files/Stream/Quota.php', 'OC\\Files\\Stream\\SeekableHttpStream' => $baseDir . '/lib/private/Files/Stream/SeekableHttpStream.php', + 'OC\\Files\\SymlinkManager' => $baseDir . '/lib/private/Files/SymlinkManager.php', 'OC\\Files\\Template\\TemplateManager' => $baseDir . '/lib/private/Files/Template/TemplateManager.php', 'OC\\Files\\Type\\Detection' => $baseDir . '/lib/private/Files/Type/Detection.php', 'OC\\Files\\Type\\Loader' => $baseDir . '/lib/private/Files/Type/Loader.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index 017918b3f44b8..252baac53f768 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -1276,6 +1276,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OC\\Core\\Migrations\\Version28000Date20231103104802' => __DIR__ . '/../../..' . '/core/Migrations/Version28000Date20231103104802.php', 'OC\\Core\\Migrations\\Version29000Date20231126110901' => __DIR__ . '/../../..' . '/core/Migrations/Version29000Date20231126110901.php', 'OC\\Core\\Migrations\\Version29000Date20231213104850' => __DIR__ . '/../../..' . '/core/Migrations/Version29000Date20231213104850.php', + 'OC\\Core\\Migrations\\Version29000Date20231123170742' => __DIR__ . '/../../..' . '/core/Migrations/Version29000Date20231123170742.php', 'OC\\Core\\Notification\\CoreNotifier' => __DIR__ . '/../../..' . '/core/Notification/CoreNotifier.php', 'OC\\Core\\Service\\LoginFlowV2Service' => __DIR__ . '/../../..' . '/core/Service/LoginFlowV2Service.php', 'OC\\DB\\Adapter' => __DIR__ . '/../../..' . '/lib/private/DB/Adapter.php', @@ -1473,6 +1474,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OC\\Files\\Stream\\HashWrapper' => __DIR__ . '/../../..' . '/lib/private/Files/Stream/HashWrapper.php', 'OC\\Files\\Stream\\Quota' => __DIR__ . '/../../..' . '/lib/private/Files/Stream/Quota.php', 'OC\\Files\\Stream\\SeekableHttpStream' => __DIR__ . '/../../..' . '/lib/private/Files/Stream/SeekableHttpStream.php', + 'OC\\Files\\SymlinkManager' => __DIR__ . '/../../..' . '/lib/private/Files/SymlinkManager.php', 'OC\\Files\\Template\\TemplateManager' => __DIR__ . '/../../..' . '/lib/private/Files/Template/TemplateManager.php', 'OC\\Files\\Type\\Detection' => __DIR__ . '/../../..' . '/lib/private/Files/Type/Detection.php', 'OC\\Files\\Type\\Loader' => __DIR__ . '/../../..' . '/lib/private/Files/Type/Loader.php', diff --git a/lib/private/Files/SymlinkManager.php b/lib/private/Files/SymlinkManager.php new file mode 100644 index 0000000000000..a1c6937cb4efe --- /dev/null +++ b/lib/private/Files/SymlinkManager.php @@ -0,0 +1,216 @@ + + * + * @author Tamino Bauknecht + * + * @license AGPL-3.0 + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License, version 3, + * along with this program. If not, see + * + */ + +namespace OC\Files; + +use OCP\Files\Storage\IStorage; +use OCP\IDBConnection; + +/** + * Class to manage symlink representation on server + */ +class SymlinkManager { + /** + * @var \OCP\IDBConnection + */ + protected $connection; + + /** + * @var string + */ + protected const TABLE_NAME = 'symlinks'; + + /** + * @param IStorage $storage + */ + public function __construct() { + $this->connection = \OC::$server->get(IDBConnection::class); + } + + /** + * Check if given node is a symlink + * + * @param \OCP\Files\FileInfo $node + * + * @return bool + */ + public function isSymlink($node) { + return $this->getId($node) !== false; + } + + /** + * Store given node in database + * + * @param \OCP\Files\FileInfo $node + */ + public function storeSymlink($node) { + if ($this->isSymlink($node)) { + $this->updateSymlink($node); + } else { + $this->insertSymlink($node); + } + } + + /** + * Delete given node from database + * + * @param \OCP\Files\FileInfo $node + * + * @return bool + */ + public function deleteSymlink($node) { + $id = $this->getId($node); + if ($id === false) { + return false; + } + + return $this->deleteSymlinkById($id); + } + + /** + * Delete all symlinks that have no file representation in filesystem. + * Optionally, a path can be given to only purge symlinks that are recursively located in the path. + * + * @param string $path + */ + public function purgeSymlink($path = '/') { + $path = rtrim($path, '/'); + $query = $this->connection->getQueryBuilder(); + $query->select('*') + ->from(self::TABLE_NAME) + ->where($query->expr()->like('path', $query->createNamedParameter($this->connection->escapeLikeParameter($path) . '/%'))); + $result = $query->executeQuery(); + + while ($row = $result->fetch()) { + if (!\OC\Files\Filesystem::file_exists($row['path'])) { + $this->deleteSymlinkById($row['id']); + } + } + } + + /** + * @param \OCP\Files\FileInfo $node + * + * @return int|false + */ + private function getId($node) { + $name = $this->getNameFromNode($node); + $storageId = $this->getStorageIdFromNode($node); + $path = $this->getPathFromNode($node); + + $query = $this->connection->getQueryBuilder(); + $query->select('id') + ->from(self::TABLE_NAME) + ->where($query->expr()->eq('storage', $query->createNamedParameter($storageId))) + ->andWhere($query->expr()->eq('path', $query->createNamedParameter($path))) + ->andWhere($query->expr()->eq('name', $query->createNamedParameter($name))); + $result = $query->executeQuery(); + + if ($result->rowCount() > 1) { + throw new \OCP\DB\Exception("Node ('$name', '$storageId', '$path') is not unique in database!"); + } + + return $result->fetchOne(); + } + + /** + * @param \OCP\Files\Node $node + */ + private function updateSymlink($node) { + $name = $this->getNameFromNode($node); + $storageId = $this->getStorageIdFromNode($node); + $path = $this->getPathFromNode($node); + + $query = $this->connection->getQueryBuilder(); + $query->update(self::TABLE_NAME) + ->set('name', $query->createNamedParameter($name)) + ->set('storage', $query->createNamedParameter($storageId)) + ->set('path', $query->createNamedParameter($path)); + if ($query->executeStatement() != 1) { + throw new \OCP\DB\Exception('Invalid number of rows changed while updating symlink!'); + } + } + + /** + * @param \OCP\Files\FileInfo $node + */ + private function insertSymlink($node) { + $name = $this->getNameFromNode($node); + $storageId = $this->getStorageIdFromNode($node); + $path = $this->getPathFromNode($node); + + $query = $this->connection->getQueryBuilder(); + $query->insert(self::TABLE_NAME) + ->setValue('name', $query->createNamedParameter($name)) + ->setValue('storage', $query->createNamedParameter($storageId)) + ->setValue('path', $query->createNamedParameter($path)); + if ($query->executeStatement() != 1) { + throw new \OCP\DB\Exception('Invalid number of rows changed while inserting symlink!'); + } + } + + /** + * @param \OCP\Files\FileInfo $node + * + * @return bool + */ + private function deleteSymlinkById($id) { + $query = $this->connection->getQueryBuilder(); + $query->delete(self::TABLE_NAME) + ->where($query->expr()->eq('id', $query->createNamedParameter($id))); + $rowsChanged = $query->executeStatement(); + if ($rowsChanged > 1) { + throw new \OCP\DB\Exception('Too many symlink rows deleted!'); + } + return $rowsChanged == 1; + } + + /** + * @param \OCP\Files\FileInfo $node + * + * @return string + */ + private function getNameFromNode($node) { + return $node->getName(); + } + + /** + * @param \OCP\Files\FileInfo $node + * + * @return int + */ + private function getStorageIdFromNode($node) { + $storageId = $node->getStorage()->getId(); + if ($numericStorageId = \OC\Files\Cache\Storage::getNumericStorageId($storageId)) { + return $numericStorageId; + } else { + throw new \OCP\Files\StorageNotAvailableException("Unable to find storage '$storageId'!"); + } + } + + /** + * @param \OCP\Files\FileInfo $node + */ + private function getPathFromNode($node) { + return $node->getInternalPath(); + } +}