%PDF- %PDF-
Direktori : /www/varak.net/nextcloud.varak.net/apps_old/apps/text/lib/Service/ |
Current File : //www/varak.net/nextcloud.varak.net/apps_old/apps/text/lib/Service/AttachmentService.php |
<?php declare(strict_types=1); /** * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Text\Service; use OC\User\NoUserException; use OCA\Files_Sharing\SharedStorage; use OCA\Text\Controller\AttachmentController; use OCA\Text\Db\Session; use OCP\Constants; use OCP\Files\File; use OCP\Files\Folder; use OCP\Files\IFilenameValidator; use OCP\Files\IMimeTypeDetector; use OCP\Files\InvalidPathException; use OCP\Files\IRootFolder; use OCP\Files\NotFoundException; use OCP\Files\NotPermittedException; use OCP\Files\SimpleFS\ISimpleFile; use OCP\IPreview; use OCP\IURLGenerator; use OCP\Lock\LockedException; use OCP\Share\Exceptions\ShareNotFound; use OCP\Share\IManager as ShareManager; use OCP\Share\IShare; use OCP\Util; class AttachmentService { public function __construct(private IRootFolder $rootFolder, private ShareManager $shareManager, private IPreview $previewManager, private IMimeTypeDetector $mimeTypeDetector, private IURLGenerator $urlGenerator, private IFilenameValidator $filenameValidator) { } /** * Get image content or preview from file name * * @throws InvalidPathException * @throws NoUserException * @throws NotFoundException * @throws NotPermittedException */ public function getImageFile(int $documentId, string $imageFileName, string $userId, bool $preferRawImage): File|ISimpleFile|null { $textFile = $this->getTextFile($documentId, $userId); return $this->getImageFileContent($imageFileName, $textFile, $preferRawImage); } /** * Get image content or preview from file id in public context * * @throws NotFoundException * @throws NotPermittedException * @throws InvalidPathException * @throws NoUserException */ public function getImageFilePublic(int $documentId, string $imageFileName, string $shareToken, bool $preferRawImage): File|ISimpleFile|null { $textFile = $this->getTextFilePublic($documentId, $shareToken); return $this->getImageFileContent($imageFileName, $textFile, $preferRawImage); } /** * @throws InvalidPathException * @throws NoUserException * @throws NotFoundException * @throws NotPermittedException */ private function getImageFileContent(string $imageFileName, File $textFile, bool $preferRawImage): File|ISimpleFile|null { $attachmentFolder = $this->getAttachmentDirectoryForFile($textFile, true); $imageFile = $attachmentFolder->get($imageFileName); if ($imageFile instanceof File && in_array($imageFile->getMimetype(), AttachmentController::IMAGE_MIME_TYPES, true)) { // previews of gifs are static images, always provide the real gif if ($imageFile->getMimetype() === 'image/gif') { return $imageFile; } // we might prefer the raw image if ($preferRawImage && in_array($imageFile->getMimetype(), AttachmentController::BROWSER_SUPPORTED_IMAGE_MIME_TYPES, true)) { return $imageFile; } if ($this->previewManager->isMimeSupported($imageFile->getMimeType())) { return $this->previewManager->getPreview($imageFile, 1024, 1024); } // fallback: raw image return $imageFile; } return null; } /** * Get media file from file name * * @throws NotFoundException * @throws InvalidPathException * @throws NotPermittedException * @throws NoUserException */ public function getMediaFile(int $documentId, string $mediaFileName, string $userId): File|null { $textFile = $this->getTextFile($documentId, $userId); return $this->getMediaFullFile($mediaFileName, $textFile); } /** * Get image content or preview from file id in public context * * @throws NotFoundException * @throws NotPermittedException * @throws InvalidPathException * @throws NoUserException */ public function getMediaFilePublic(int $documentId, string $mediaFileName, string $shareToken): File|null { $textFile = $this->getTextFilePublic($documentId, $shareToken); return $this->getMediaFullFile($mediaFileName, $textFile); } /** * @throws NotFoundException * @throws NotPermittedException * @throws InvalidPathException * @throws NoUserException */ private function getMediaFullFile(string $mediaFileName, File $textFile): ?File { $attachmentFolder = $this->getAttachmentDirectoryForFile($textFile, true); $mediaFile = $attachmentFolder->get($mediaFileName); if ($mediaFile instanceof File && !$this->isDownloadDisabled($mediaFile)) { return $mediaFile; } return null; } /** * @throws NotFoundException * @throws NotPermittedException * @throws InvalidPathException * @throws NoUserException */ public function getMediaFilePreview(int $documentId, string $mediaFileName, string $userId): ?array { $textFile = $this->getTextFile($documentId, $userId); return $this->getMediaFilePreviewFile($mediaFileName, $textFile); } /** * @throws NotFoundException * @throws NotPermittedException * @throws InvalidPathException * @throws NoUserException */ public function getMediaFilePreviewPublic(int $documentId, string $mediaFileName, string $shareToken): ?array { $textFile = $this->getTextFilePublic($documentId, $shareToken); return $this->getMediaFilePreviewFile($mediaFileName, $textFile); } /** * Get media preview or mimetype icon address * * @throws NotFoundException * @throws NotPermittedException * @throws InvalidPathException * @throws NoUserException */ private function getMediaFilePreviewFile(string $mediaFileName, File $textFile): ?array { $attachmentFolder = $this->getAttachmentDirectoryForFile($textFile, true); $mediaFile = $attachmentFolder->get($mediaFileName); if ($mediaFile instanceof File && !$this->isDownloadDisabled($mediaFile)) { if ($this->previewManager->isMimeSupported($mediaFile->getMimeType())) { try { return [ 'type' => 'file', 'file' => $this->previewManager->getPreview($mediaFile, 1024, 1024), ]; } catch (NotFoundException $e) { // the preview might not be found even if the mimetype is supported } } // fallback: mimetype icon URL return [ 'type' => 'icon', 'iconUrl' => $this->mimeTypeDetector->mimeTypeIcon($mediaFile->getMimeType()), ]; } return null; } /** * @throws InvalidPathException * @throws NoUserException * @throws NotFoundException * @throws NotPermittedException */ public function getAttachmentList(int $documentId, ?string $userId = null, ?Session $session = null, ?string $shareToken = null): array { if ($shareToken !== null) { $textFile = $this->getTextFilePublic($documentId, $shareToken); } elseif ($userId !== null) { $textFile = $this->getTextFile($documentId, $userId); } else { throw new NotPermittedException('Unable to read document'); } try { $attachmentDir = $this->getAttachmentDirectoryForFile($textFile); } catch (NotFoundException) { return []; } $shareTokenUrlString = $shareToken !== null ? '&shareToken=' . rawurlencode($shareToken) : ''; $urlParamsBase = $session ? '?documentId=' . $documentId . '&sessionId=' . $session->getId() . '&sessionToken=' . rawurlencode($session->getToken()) . $shareTokenUrlString : '?documentId=' . $documentId . $shareTokenUrlString; $attachments = []; $userFolder = $userId !== null ? $this->rootFolder->getUserFolder($userId) : null; foreach ($attachmentDir->getDirectoryListing() as $node) { if (!($node instanceof File)) { // Ignore anything but files continue; } $isImage = in_array($node->getMimetype(), AttachmentController::IMAGE_MIME_TYPES, true); $name = $node->getName(); $attachments[] = [ 'fileId' => $node->getId(), 'name' => $name, 'size' => Util::humanFileSize($node->getSize()), 'mimetype' => $node->getMimeType(), 'mtime' => $node->getMTime(), 'isImage' => $isImage, 'davPath' => $userFolder?->getRelativePath($node->getPath()), 'fullUrl' => $isImage ? $this->urlGenerator->linkToRouteAbsolute('text.Attachment.getImageFile') . $urlParamsBase . '&imageFileName=' . rawurlencode($name) . '&preferRawImage=1' : $this->urlGenerator->linkToRouteAbsolute('text.Attachment.getMediaFile') . $urlParamsBase . '&mediaFileName=' . rawurlencode($name), 'previewUrl' => $isImage ? $this->urlGenerator->linkToRouteAbsolute('text.Attachment.getImageFile') . $urlParamsBase . '&imageFileName=' . rawurlencode($name) : $this->urlGenerator->linkToRouteAbsolute('text.Attachment.getMediaFilePreview') . $urlParamsBase . '&mediaFileName=' . rawurlencode($name), ]; } return $attachments; } /** * Save an uploaded file in the attachment folder * * @param int $documentId * @param string $newFileName * @param resource $newFileResource * @param string $userId * * @return array * @throws InvalidPathException * @throws NoUserException * @throws NotFoundException * @throws NotPermittedException */ public function uploadAttachment(int $documentId, string $newFileName, $newFileResource, string $userId): array { $textFile = $this->getTextFile($documentId, $userId); if (!$textFile->isUpdateable()) { throw new NotPermittedException('No write permissions'); } $saveDir = $this->getAttachmentDirectoryForFile($textFile, true); $fileName = self::getUniqueFileName($saveDir, $newFileName); $this->filenameValidator->validateFilename($fileName); $savedFile = $saveDir->newFile($fileName, $newFileResource); return [ 'name' => $fileName, 'dirname' => $saveDir->getName(), 'id' => $savedFile->getId(), 'documentId' => $textFile->getId(), ]; } /** * Save an uploaded file in the attachment folder in a public context * * @param int|null $documentId * @param string $newFileName * @param resource $newFileResource * @param string $shareToken * * @return array * @throws NotFoundException * @throws NotPermittedException * @throws InvalidPathException * @throws NoUserException */ public function uploadAttachmentPublic(?int $documentId, string $newFileName, $newFileResource, string $shareToken): array { if (!$this->hasUpdatePermissions($shareToken)) { throw new NotPermittedException('No write permissions'); } $textFile = $this->getTextFilePublic($documentId, $shareToken); $saveDir = $this->getAttachmentDirectoryForFile($textFile, true); $fileName = self::getUniqueFileName($saveDir, $newFileName); $this->filenameValidator->validateFilename($fileName); $savedFile = $saveDir->newFile($fileName, $newFileResource); return [ 'name' => $fileName, 'dirname' => $saveDir->getName(), 'id' => $savedFile->getId(), 'documentId' => $textFile->getId(), ]; } /** * Copy a file from a user's storage in the attachment folder * * @param int $documentId * @param string $path * @param string $userId * * @return array * @throws NotFoundException * @throws NotPermittedException * @throws InvalidPathException * @throws NoUserException */ public function insertAttachmentFile(int $documentId, string $path, string $userId): array { $textFile = $this->getTextFile($documentId, $userId); if (!$textFile->isUpdateable()) { throw new NotPermittedException('No write permissions'); } $originalFile = $this->getFileFromPath($path, $userId); $saveDir = $this->getAttachmentDirectoryForFile($textFile, true); return $this->copyFile($originalFile, $saveDir, $textFile); } /** * @param File $originalFile * @param Folder $saveDir * @param File $textFile * * @return array * @throws NotFoundException * @throws InvalidPathException */ private function copyFile(File $originalFile, Folder $saveDir, File $textFile): array { $fileName = self::getUniqueFileName($saveDir, $originalFile->getName()); $targetPath = $saveDir->getPath() . '/' . $fileName; $targetFile = $originalFile->copy($targetPath); return [ 'name' => $fileName, 'dirname' => $saveDir->getName(), 'id' => $targetFile->getId(), 'documentId' => $textFile->getId(), 'mimetype' => $targetFile->getMimetype(), ]; } /** * Get unique file name in a directory. Add '(n)' suffix. * * @param Folder $dir * @param string $fileName * * @return string */ public static function getUniqueFileName(Folder $dir, string $fileName): string { $extension = pathinfo($fileName, PATHINFO_EXTENSION); $counter = 1; $uniqueFileName = $fileName; if ($extension !== '') { while ($dir->nodeExists($uniqueFileName)) { $counter++; $uniqueFileName = preg_replace('/\.' . $extension . '$/', ' (' . $counter . ').' . $extension, $fileName); } } else { while ($dir->nodeExists($uniqueFileName)) { $counter++; $uniqueFileName = preg_replace('/$/', ' (' . $counter . ')', $fileName); } } return $uniqueFileName; } /** * Check if the shared access has write permissions * * @param string $shareToken * * @return bool */ private function hasUpdatePermissions(string $shareToken): bool { try { $share = $this->shareManager->getShareByToken($shareToken); return ( in_array( $share->getShareType(), [IShare::TYPE_LINK, IShare::TYPE_EMAIL, IShare::TYPE_ROOM], true ) && $share->getPermissions() & Constants::PERMISSION_UPDATE); } catch (ShareNotFound $e) { return false; } } /** * Get or create file-specific attachment folder * * @param File $textFile * @param bool $create * * @return Folder * @throws NotFoundException * @throws NotPermittedException * @throws InvalidPathException * @throws NoUserException */ private function getAttachmentDirectoryForFile(File $textFile, bool $create = false): Folder { $owner = $textFile->getOwner(); if ($owner === null) { throw new NotFoundException('File has no owner'); } $ownerId = $owner->getUID(); $ownerUserFolder = $this->rootFolder->getUserFolder($ownerId); $ownerTextFile = $ownerUserFolder->getFirstNodeById($textFile->getId()); if ($ownerTextFile !== null) { $ownerParentFolder = $ownerTextFile->getParent(); $attachmentFolderName = '.attachments.' . $textFile->getId(); if ($ownerParentFolder->nodeExists($attachmentFolderName)) { $attachmentFolder = $ownerParentFolder->get($attachmentFolderName); if ($attachmentFolder instanceof Folder) { return $attachmentFolder; } } elseif ($create) { return $ownerParentFolder->newFolder($attachmentFolderName); } } throw new NotFoundException('Attachment dir for document ' . $textFile->getId() . ' was not found or could not be created.'); } /** * Get a user file from file ID * @throws NotFoundException * @throws NotPermittedException * @throws NoUserException */ private function getFileFromPath(string $filePath, string $userId): File { $userFolder = $this->rootFolder->getUserFolder($userId); if ($userFolder->nodeExists($filePath)) { $file = $userFolder->get($filePath); if ($file instanceof File && !$this->isDownloadDisabled($file)) { return $file; } } throw new NotFoundException(); } /** * @param File $file * * @return bool * @throws NotFoundException */ private function isDownloadDisabled(File $file): bool { $storage = $file->getStorage(); if ($storage->instanceOfStorage(SharedStorage::class)) { /** @var SharedStorage $storage */ $share = $storage->getShare(); $attributes = $share->getAttributes(); if ($attributes !== null && $attributes->getAttribute('permissions', 'download') === false) { return true; } } return false; } /** * Get a user file from file ID * * @param int $documentId * @param string $userId * * @return File * @throws NoUserException * @throws NotFoundException * @throws NotPermittedException */ private function getTextFile(int $documentId, string $userId): File { $userFolder = $this->rootFolder->getUserFolder($userId); $file = $userFolder->getFirstNodeById($documentId); if ($file instanceof File && !$this->isDownloadDisabled($file)) { return $file; } throw new NotFoundException('Text file with id=' . $documentId . ' was not found in storage of ' . $userId); } /** * Get file from share token * * @param int|null $documentId * @param string $shareToken * * @return File * @throws NotFoundException */ private function getTextFilePublic(?int $documentId, string $shareToken): File { // is the file shared with this token? try { $share = $this->shareManager->getShareByToken($shareToken); if (in_array($share->getShareType(), [IShare::TYPE_LINK, IShare::TYPE_EMAIL])) { // shared file or folder? if ($share->getNodeType() === 'file') { $textFile = $share->getNode(); if ($textFile instanceof File && !$this->isDownloadDisabled($textFile)) { return $textFile; } } elseif ($documentId !== null && $share->getNodeType() === 'folder') { $folder = $share->getNode(); if ($folder instanceof Folder) { $textFile = $folder->getFirstNodeById($documentId); if ($textFile instanceof File && !$this->isDownloadDisabled($textFile)) { return $textFile; } } } } } catch (ShareNotFound $e) { // same as below } throw new NotFoundException('Text file with id=' . $documentId . ' and shareToken ' . $shareToken . ' was not found.'); } /** * Actually delete attachment files which are not pointed in the markdown content * * @param int $fileId * * @return int The number of deleted files * @throws NotFoundException * @throws NotPermittedException * @throws InvalidPathException * @throws LockedException * @throws NoUserException */ public function cleanupAttachments(int $fileId): int { $textFile = $this->rootFolder->getFirstNodeById($fileId); if ($textFile instanceof File) { if ($textFile->getMimeType() === 'text/markdown') { // get IDs of the files inside the attachment dir try { $attachmentDir = $this->getAttachmentDirectoryForFile($textFile); } catch (NotFoundException $e) { // this only happens if the attachment dir was deleted by the user while editing the document return 0; } $attachmentsByName = []; foreach ($attachmentDir->getDirectoryListing() as $attNode) { $attachmentsByName[$attNode->getName()] = $attNode; } $contentAttachmentNames = self::getAttachmentNamesFromContent($textFile->getContent(), $fileId); $toDelete = array_diff(array_keys($attachmentsByName), $contentAttachmentNames); foreach ($toDelete as $name) { $attachmentsByName[$name]->delete(); } return count($toDelete); } } return 0; } /** * Get attachment file names listed in the markdown file content * * @param string $content * @param int $fileId * * @return array */ public static function getAttachmentNamesFromContent(string $content, int $fileId): array { $matches = []; // matches  and captures FILE_NAME preg_match_all( '/\!\[(?>[^\[\]]+|\[(?>[^\[\]]+|\[(?>[^\[\]]+|\[(?>[^\[\]]+|\[(?>[^\[\]]+|\[(?>[^\[\]]+|\[\])*\])*\])*\])*\])*\])*\]\(\.attachments\.' . $fileId . '\/([^)&]+)\)/', $content, $matches, PREG_SET_ORDER ); return array_map(static function (array $match) { return urldecode($match[1]); }, $matches); } /** * @param File $source * @param File $target * * @throws NotFoundException * @throws NotPermittedException * @throws InvalidPathException * @throws LockedException */ public function moveAttachments(File $source, File $target): void { // if the parent directory has changed if ($source->getParent()->getPath() !== $target->getParent()->getPath()) { try { $sourceAttachmentDir = $this->getAttachmentDirectoryForFile($source); } catch (NotFoundException $e) { // silently return if no attachment dir was found for source file return; } // it is in the same directory as the source file in its owner's storage // in other words, we move the attachment dir only if the .md file is moved by its owner if ($source->getParent()->getId() === $sourceAttachmentDir->getParent()->getId() ) { $sourceAttachmentDir->move($target->getParent()->getPath() . '/' . $sourceAttachmentDir->getName()); } } } /** * @param File $source * * @throws NotFoundException * @throws NotPermittedException * @throws InvalidPathException * @throws NoUserException */ public function deleteAttachments(File $source): void { // if there is an attachment dir for this file try { $sourceAttachmentDir = $this->getAttachmentDirectoryForFile($source); } catch (NotFoundException $e) { // silently return if no attachment dir was found return; } $sourceAttachmentDir->delete(); } /** * @param File $source * @param File $target * * @throws InvalidPathException * @throws NoUserException * @throws NotFoundException * @throws NotPermittedException * @throws LockedException */ public function copyAttachments(File $source, File $target): void { try { $sourceAttachmentDir = $this->getAttachmentDirectoryForFile($source); } catch (NotFoundException $e) { // silently return if no attachment dir was found for source file return; } // create a new attachment dir next to the new file $targetAttachmentDir = $this->getAttachmentDirectoryForFile($target, true); // copy the attachment files foreach ($sourceAttachmentDir->getDirectoryListing() as $sourceAttachment) { if ($sourceAttachment instanceof File) { $targetAttachmentDir->newFile($sourceAttachment->getName(), $sourceAttachment->getContent()); } } } }