diff --git a/lib/Service/AttachmentService.php b/lib/Service/AttachmentService.php index 6764252b5..c0471cc92 100644 --- a/lib/Service/AttachmentService.php +++ b/lib/Service/AttachmentService.php @@ -62,8 +62,6 @@ class AttachmentService { private $activityManager; /** @var ChangeHelper */ private $changeHelper; - /** @var DeckShareProvider */ - private $shareProvider; public function __construct(AttachmentMapper $attachmentMapper, CardMapper $cardMapper, ChangeHelper $changeHelper, PermissionService $permissionService, Application $application, ICacheFactory $cacheFactory, $userId, IL10N $l10n, ActivityManager $activityManager, DeckShareProvider $shareProvider) { $this->attachmentMapper = $attachmentMapper; @@ -75,11 +73,11 @@ class AttachmentService { $this->l10n = $l10n; $this->activityManager = $activityManager; $this->changeHelper = $changeHelper; - $this->shareProvider = $shareProvider; // Register shipped attachment services // TODO: move this to a plugin based approach once we have different types of attachments $this->registerAttachmentService('deck_file', FileService::class); + $this->registerAttachmentService('file', FilesAppService::class); } /** @@ -120,6 +118,15 @@ class AttachmentService { if ($withDeleted) { $attachments = array_merge($attachments, $this->attachmentMapper->findToDelete($cardId, false)); } + + foreach (array_keys($this->services) as $attachmentType) { + /** @var IAttachmentService $service */ + $service = $this->getService($attachmentType); + if ($service instanceof ICustomAttachmentService) { + $attachments = array_merge($attachments, $service->listAttachments($cardId)); + } + } + foreach ($attachments as &$attachment) { try { $service = $this->getService($attachment->getType()); @@ -129,37 +136,7 @@ class AttachmentService { } } - return array_merge($attachments, $this->getFilesAppAttachments($cardId)); - } - - private function getFilesAppAttachments($cardId) { - /** @var IPreview $previewManager */ - $previewManager = \OC::$server->get(IPreview::class); - $userFolder = \OC::$server->getRootFolder()->getUserFolder($this->userId); - $shares = $this->shareProvider->getSharedWithByType($cardId, IShare::TYPE_DECK, -1, 0); - return array_map(function (IShare $share) use ($cardId, $userFolder, $previewManager) { - $file = $share->getNode(); - $nodes = $userFolder->getById($file->getId()); - $userNode = array_shift($nodes); - return [ - // general attachment attributes - 'cardId' => $cardId, - 'type' => 'file', - 'data' => $file->getName(), - 'lastModified' => $file->getMTime(), - 'createdAt' => $file->getMTime(), - 'deletedAt' => 0, - // file type attributes - 'fileid' => $file->getId(), - 'path' => $userFolder->getRelativePath($userNode->getPath()), - 'extendedData' => [ - 'filesize' => $file->getSize(), - 'mimetype' => $file->getMimeType(), - 'info' => pathinfo($file->getName()), - 'hasPreview' => $previewManager->isAvailable($file), - ] - ]; - }, $shares); + return $attachments; } /** @@ -178,22 +155,7 @@ class AttachmentService { $this->cache->set('card-' . $cardId, $count); } - /** @var IDBConnection $qb */ - $db = \OC::$server->getDatabaseConnection(); - $qb = $db->getQueryBuilder(); - $qb->select($qb->createFunction('count(s.id)')) - ->from('share', 's') - ->andWhere($qb->expr()->eq('s.share_type', $qb->createNamedParameter(IShare::TYPE_DECK))) - ->andWhere($qb->expr()->eq('s.share_with', $qb->createNamedParameter($cardId))) - ->andWhere($qb->expr()->isNull('s.parent')) - ->andWhere($qb->expr()->orX( - $qb->expr()->eq('s.item_type', $qb->createNamedParameter('file')), - $qb->expr()->eq('s.item_type', $qb->createNamedParameter('folder')) - )); - $cursor = $qb->execute(); - $count += $cursor->fetchColumn(0); - $cursor->closeCursor(); return $count; } @@ -234,21 +196,21 @@ class AttachmentService { try { $service = $this->getService($attachment->getType()); $service->create($attachment); - } catch (InvalidAttachmentType $e) { - // just store the data - } - if ($attachment->getData() === null) { - throw new StatusException($this->l10n->t('No data was provided to create an attachment.')); - } - $attachment = $this->attachmentMapper->insert($attachment); - // extend data so the frontend can use it properly after creating - try { - $service = $this->getService($attachment->getType()); + if (!$service instanceof ICustomAttachmentService) { + if ($attachment->getData() === null) { + throw new StatusException($this->l10n->t('No data was provided to create an attachment.')); + } + + $attachment = $this->attachmentMapper->insert($attachment); + } + $service->extendData($attachment); + } catch (InvalidAttachmentType $e) { // just store the data } + $this->changeHelper->cardChanged($attachment->getCardId()); $this->activityManager->triggerEvent(ActivityManager::DECK_OBJECT_CARD, $attachment, ActivityManager::SUBJECT_ATTACHMENT_CREATE); return $attachment; @@ -260,42 +222,48 @@ class AttachmentService { * * @param $attachmentId * @return Response - * @throws BadRequestException * @throws NoPermissionException * @throws NotFoundException - * @throws \OCP\AppFramework\Db\DoesNotExistException - * @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException */ public function display($attachmentId) { - if (is_numeric($attachmentId) === false) { - throw new BadRequestException('attachment id must be a number'); + if (is_numeric($attachmentId)) { + try { + $attachment = $this->attachmentMapper->find($attachmentId); + } catch (\Exception $e) { + throw new NoPermissionException('Permission denied'); + } + $this->permissionService->checkPermission($this->cardMapper, $attachment->getCardId(), Acl::PERMISSION_READ); + + try { + $service = $this->getService($attachment->getType()); + return $service->display($attachment); + } catch (InvalidAttachmentType $e) { + throw new NotFoundException(); + } } - try { - $attachment = $this->attachmentMapper->find($attachmentId); - } catch (\Exception $e) { - throw new NoPermissionException('Permission denied'); - } - $this->permissionService->checkPermission($this->cardMapper, $attachment->getCardId(), Acl::PERMISSION_READ); + [$type, $attachmentId] = explode(':', $attachmentId); try { - $service = $this->getService($attachment->getType()); + $attachment = new Attachment(); + $attachment->setId($attachmentId); + $attachment->setType($type); + $service = $this->getService($type); return $service->display($attachment); - } catch (InvalidAttachmentType $e) { + } catch (\Exception $e) { throw new NotFoundException(); } + } /** * Update an attachment with custom data * * @param $attachmentId - * @param $request + * @param $data * @return mixed - * @throws \OCA\Deck\NoPermissionException - * @throws \OCP\AppFramework\Db\DoesNotExistException - * @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException * @throws BadRequestException + * @throws NoPermissionException */ public function update($attachmentId, $data) { if (is_numeric($attachmentId) === false) { diff --git a/lib/Service/ConfigService.php b/lib/Service/ConfigService.php index 0dde5487d..2d657c038 100644 --- a/lib/Service/ConfigService.php +++ b/lib/Service/ConfigService.php @@ -148,4 +148,8 @@ class ConfigService { }, $groups); return array_filter($groups); } + + public function getAttachmentFolder(): string { + return $this->config->getUserValue($this->userId, 'deck', 'attachment_folder', '/Deck'); + } } diff --git a/lib/Service/FileService.php b/lib/Service/FileService.php index 2112a30f1..c6dc0dc8d 100644 --- a/lib/Service/FileService.php +++ b/lib/Service/FileService.php @@ -40,6 +40,7 @@ use OCP\IConfig; use OCP\IL10N; use OCP\ILogger; use OCP\IRequest; +use OCP\Share\IManager; class FileService implements IAttachmentService { private $l10n; @@ -57,7 +58,8 @@ class FileService implements IAttachmentService { ILogger $logger, IRootFolder $rootFolder, IConfig $config, - AttachmentMapper $attachmentMapper + AttachmentMapper $attachmentMapper, + IManager $shareManager ) { $this->l10n = $l10n; $this->appData = $appData; @@ -66,6 +68,7 @@ class FileService implements IAttachmentService { $this->rootFolder = $rootFolder; $this->config = $config; $this->attachmentMapper = $attachmentMapper; + $this->shareManager = $shareManager; } /** diff --git a/lib/Service/FilesAppService.php b/lib/Service/FilesAppService.php new file mode 100644 index 000000000..10e90c690 --- /dev/null +++ b/lib/Service/FilesAppService.php @@ -0,0 +1,260 @@ + + * + * @author Julius Härtl + * + * @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\Deck\Service; + +use OCA\Deck\Db\Attachment; +use OCA\Deck\Db\AttachmentMapper; +use OCA\Deck\Sharing\DeckShareProvider; +use OCA\Deck\StatusException; +use OCA\Deck\Exceptions\ConflictException; +use OCP\AppFramework\Http\ContentSecurityPolicy; +use OCP\AppFramework\Http\FileDisplayResponse; +use OCP\AppFramework\Http\Response; +use OCP\AppFramework\Http\StreamResponse; +use OCP\Constants; +use OCP\Files\IAppData; +use OCP\Files\IRootFolder; +use OCP\Files\NotFoundException; +use OCP\Files\NotPermittedException; +use OCP\Files\SimpleFS\ISimpleFile; +use OCP\Files\SimpleFS\ISimpleFolder; +use OCP\IConfig; +use OCP\IDBConnection; +use OCP\IL10N; +use OCP\ILogger; +use OCP\IPreview; +use OCP\IRequest; +use OCP\Share; +use OCP\Share\IManager; +use OCP\Share\IShare; + +class FilesAppService implements IAttachmentService, ICustomAttachmentService { + + private $request; + private $rootFolder; + private $shareProvider; + private $shareManager; + private $userId; + private $configService; + private $l10n; + private $preview; + + public function __construct( + IRequest $request, + IL10N $l10n, + IRootFolder $rootFolder, + IManager $shareManager, + ConfigService $configService, + DeckShareProvider $shareProvider, + IPreview $preview, + string $userId = null + ) { + $this->request = $request; + $this->l10n = $l10n; + $this->rootFolder = $rootFolder; + $this->configService = $configService; + $this->shareProvider = $shareProvider; + $this->shareManager = $shareManager; + $this->userId = $userId; + $this->preview = $preview; + } + + public function listAttachments(int $cardId): array { + $userFolder = $this->rootFolder->getUserFolder($this->userId); + $shares = $this->shareProvider->getSharedWithByType($cardId, IShare::TYPE_DECK, -1, 0); + return array_map(function (IShare $share) use ($cardId, $userFolder) { + $file = $share->getNode(); + $nodes = $userFolder->getById($file->getId()); + $file = array_shift($nodes); + $attachment = new Attachment(); + $attachment->setType('file'); + $attachment->setId($file->getId()); + $attachment->setCardId($cardId); + $attachment->setCreatedBy($share->getSharedBy()); + $attachment->setData($file->getName()); + $attachment->setLastModified($file->getMTime()); + $attachment->setCreatedAt($share->getShareTime()->getTimestamp()); + $attachment->setDeletedAt(0); + return $attachment; + }, $shares); + } + + function getAttachmentCount(int $cardId): int { + /** @var IDBConnection $qb */ + $db = \OC::$server->getDatabaseConnection(); + $qb = $db->getQueryBuilder(); + $qb->select($qb->createFunction('count(s.id)')) + ->from('share', 's') + ->andWhere($qb->expr()->eq('s.share_type', $qb->createNamedParameter(IShare::TYPE_DECK))) + ->andWhere($qb->expr()->eq('s.share_with', $qb->createNamedParameter($cardId))) + ->andWhere($qb->expr()->isNull('s.parent')) + ->andWhere($qb->expr()->orX( + $qb->expr()->eq('s.item_type', $qb->createNamedParameter('file')), + $qb->expr()->eq('s.item_type', $qb->createNamedParameter('folder')) + )); + + $cursor = $qb->execute(); + $count = $cursor->fetchColumn(0); + $cursor->closeCursor(); + return $count; + } + + public function extendData(Attachment $attachment) { + $userFolder = $this->rootFolder->getUserFolder($this->userId); + $nodes = $userFolder->getById($attachment->getId()); + $file = array_shift($nodes); + $attachment->setExtendedData([ + 'path' => $userFolder->getRelativePath($file->getPath()), + 'fileid' => $file->getId(), + 'data' => $file->getName(), + 'filesize' => $file->getSize(), + 'mimetype' => $file->getMimeType(), + 'info' => pathinfo($file->getName()), + 'hasPreview' => $this->preview->isAvailable($file), + ]); + return $attachment; + } + + public function display(Attachment $attachment) { + $userFolder = $this->rootFolder->getUserFolder($this->userId); + $nodes = $userFolder->getById($attachment->getId()); + $file = array_shift($nodes); + if ($file === null) { + throw new NotFoundException('File not found'); + } + if (method_exists($file, 'fopen')) { + $response = new StreamResponse($file->fopen('r')); + $response->addHeader('Content-Disposition', 'inline; filename="' . rawurldecode($file->getName()) . '"'); + } else { + $response = new FileDisplayResponse($file); + } + // We need those since otherwise chrome won't show the PDF file with CSP rule object-src 'none' + // https://bugs.chromium.org/p/chromium/issues/detail?id=271452 + $policy = new ContentSecurityPolicy(); + $policy->addAllowedObjectDomain('\'self\''); + $policy->addAllowedObjectDomain('blob:'); + $policy->addAllowedMediaDomain('\'self\''); + $policy->addAllowedMediaDomain('blob:'); + $response->setContentSecurityPolicy($policy); + + $response->addHeader('Content-Type', $file->getMimeType()); + return $response; + } + + public function create(Attachment $attachment) { + $file = $this->getUploadedFile(); + $fileName = $file['name']; + + $userFolder = $this->rootFolder->getUserFolder($this->userId); + $folder = $userFolder->get($this->configService->getAttachmentFolder()); + + // FIXME: Add to docs that conflict handling is different here, no ConflictException will be thrown + $fileName = $folder->getNonExistingName($fileName); + $target = $folder->newFile($fileName); + $content = fopen($file['tmp_name'], 'rb'); + if ($content === false) { + throw new StatusException('Could not read file'); + } + $target->putContent($content); + if (is_resource($content)) { + fclose($content); + } + + $share = $this->shareManager->newShare(); + $share->setNode($target); + $share->setShareType(Share::SHARE_TYPE_DECK); + $share->setSharedWith((string)$attachment->getCardId()); + $share->setPermissions(Constants::PERMISSION_READ); + $share->setSharedBy($this->userId); + $this->shareManager->createShare($share); + $attachment->setId($target->getId()); + $attachment->setData($target->getName()); + } + + /** + * @return array + * @throws StatusException + */ + private function getUploadedFile() { + $file = $this->request->getUploadedFile('file'); + $error = null; + $phpFileUploadErrors = [ + UPLOAD_ERR_OK => $this->l10n->t('The file was uploaded'), + UPLOAD_ERR_INI_SIZE => $this->l10n->t('The uploaded file exceeds the upload_max_filesize directive in php.ini'), + UPLOAD_ERR_FORM_SIZE => $this->l10n->t('The uploaded file exceeds the MAX_FILE_SIZE directive that was specified in the HTML form'), + UPLOAD_ERR_PARTIAL => $this->l10n->t('The file was only partially uploaded'), + UPLOAD_ERR_NO_FILE => $this->l10n->t('No file was uploaded'), + UPLOAD_ERR_NO_TMP_DIR => $this->l10n->t('Missing a temporary folder'), + UPLOAD_ERR_CANT_WRITE => $this->l10n->t('Could not write file to disk'), + UPLOAD_ERR_EXTENSION => $this->l10n->t('A PHP extension stopped the file upload'), + ]; + + if (empty($file)) { + $error = $this->l10n->t('No file uploaded or file size exceeds maximum of %s', [\OCP\Util::humanFileSize(\OCP\Util::uploadLimit())]); + } + if (!empty($file) && array_key_exists('error', $file) && $file['error'] !== UPLOAD_ERR_OK) { + $error = $phpFileUploadErrors[$file['error']]; + } + if ($error !== null) { + throw new StatusException($error); + } + return $file; + } + + public function update(Attachment $attachment) { + // TODO: Implement update() method. + } + + public function delete(Attachment $attachment) { + $userFolder = $this->rootFolder->getUserFolder($this->userId); + $nodes = $userFolder->getById($attachment->getId()); + $file = array_shift($nodes); + if ($file === null) { + throw new NotFoundException('File not found'); + } + + if ($file->getOwner() !== null && $file->getOwner()->getUID() === $this->userId) { + $file->delete(); + return; + } + + // FIXME: only with manage permissions + $shares = $this->shareProvider->getSharedWithByType($attachment->getCardId(), IShare::TYPE_DECK, -1, 0); + foreach ($shares as $share) { + if ($share->getNode()->getId() === $attachment->getId()) { + $this->shareManager->deleteShare($share); + return; + } + } + + } + + public function allowUndo() { + return false; + } + + public function markAsDeleted(Attachment $attachment) { + throw new \Exception('Not implemented'); + } +} diff --git a/lib/Service/ICustomAttachmentService.php b/lib/Service/ICustomAttachmentService.php new file mode 100644 index 000000000..429a11cfd --- /dev/null +++ b/lib/Service/ICustomAttachmentService.php @@ -0,0 +1,42 @@ + + * + * @author Julius Härtl + * + * @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 . + * + */ + +declare(strict_types=1); + + +namespace OCA\Deck\Service; + + +/** + * Interface to implement in case attachments are handled by a different backend than + * then oc_deck_attachments table, e.g. for file sharing. When this interface is used + * for implementing an attachment handler no backlink will be stored in the deck attachments + * table and it is up to the implementation to track attachment to card relation. + */ +interface ICustomAttachmentService { + + public function listAttachments(int $cardId): array; + + public function getAttachmentCount(int $cardId): int; + +} diff --git a/lib/Sharing/Listener.php b/lib/Sharing/Listener.php index 7772a0ac0..3c0c00ee8 100644 --- a/lib/Sharing/Listener.php +++ b/lib/Sharing/Listener.php @@ -28,6 +28,7 @@ namespace OCA\Deck\Sharing; use OC\Files\Filesystem; +use OCA\Deck\Service\ConfigService; use OCP\EventDispatcher\IEventDispatcher; use OCP\Share\Events\VerifyMountPointEvent; use OCP\Share\IShare; @@ -35,7 +36,13 @@ use Symfony\Component\EventDispatcher\GenericEvent; class Listener { - public function __construct($userId) { + /** @var ConfigService */ + private $configService; + /** @var string|null */ + private $userId; + + public function __construct(ConfigService $configService, $userId) { + $this->configService = $configService; $this->userId = $userId; } @@ -99,15 +106,11 @@ class Listener { } } - $parent = $this->getAttachmentFolder(); + $parent = $this->configService->getAttachmentFolder(); $event->setParent($parent); if (!$event->getView()->is_dir($parent)) { $event->getView()->mkdir($parent); } } } - - private function getAttachmentFolder() { - return \OC::$server->getConfig()->getUserValue($this->userId, 'deck', 'attachment_folder', '/Deck'); - } } diff --git a/src/components/card/AttachmentList.vue b/src/components/card/AttachmentList.vue index 048bffd73..92f24c3ab 100644 --- a/src/components/card/AttachmentList.vue +++ b/src/components/card/AttachmentList.vue @@ -69,17 +69,17 @@ - + {{ t('deck', 'Show in files') }} - + {{ t('deck', 'Unshare file') }} - + {{ t('deck', 'Delete Attachment') }} - + {{ t('deck', 'Restore Attachment') }} @@ -89,11 +89,12 @@