diff --git a/appinfo/routes.php b/appinfo/routes.php index 33cfebf2482..1519b9c8ec5 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -29,6 +29,10 @@ 'routes' => [ ['name' => 'Image#insertImageLink', 'url' => '/image/link', 'verb' => 'POST'], ['name' => 'Image#uploadImage', 'url' => '/image/upload', 'verb' => 'POST'], + ['name' => 'Image#getImage', 'url' => '/image', 'verb' => 'GET'], + ['name' => 'Image#insertImageLinkPublic', 'url' => '/public/image/link', 'verb' => 'POST'], + ['name' => 'Image#uploadImagePublic', 'url' => '/public/image/upload', 'verb' => 'POST'], + ['name' => 'Image#getImagePublic', 'url' => '/public/image', 'verb' => 'GET'], ['name' => 'Session#create', 'url' => '/session/create', 'verb' => 'PUT'], ['name' => 'Session#fetch', 'url' => '/session/fetch', 'verb' => 'POST'], diff --git a/lib/Controller/ImageController.php b/lib/Controller/ImageController.php index 94296741578..ee66e7272ee 100644 --- a/lib/Controller/ImageController.php +++ b/lib/Controller/ImageController.php @@ -26,14 +26,29 @@ namespace OCA\Text\Controller; use Exception; +use OCA\Text\AppInfo\Application; use OCP\AppFramework\Http; use OCA\Text\Service\ImageService; use OCP\AppFramework\Controller; +use OCP\AppFramework\Http\DataDisplayResponse; use OCP\AppFramework\Http\DataResponse; use OCP\IRequest; +use Psr\Log\LoggerInterface; class ImageController extends Controller { + public const IMAGE_MIME_TYPES = [ + 'image/png', + 'image/jpeg', + 'image/jpg', + 'image/gif', + 'image/x-xbitmap', + 'image/x-ms-bmp', + 'image/bmp', + 'image/svg+xml', + 'image/webp', + ]; + /** * @var string|null */ @@ -42,45 +57,166 @@ class ImageController extends Controller { * @var ImageService */ private $imageService; + /** + * @var LoggerInterface + */ + private $logger; public function __construct(string $appName, IRequest $request, + LoggerInterface $logger, ImageService $imageService, ?string $userId) { parent::__construct($appName, $request); $this->userId = $userId; $this->imageService = $imageService; $this->request = $request; + $this->logger = $logger; } /** * @NoAdminRequired + * + * @param int $textFileId + * @param string $link + * @return DataResponse */ - public function insertImageLink(string $link): DataResponse { - $downloadResult = $this->imageService->insertImageLink($link, $this->userId); - if (isset($downloadResult['error'])) { - return new DataResponse($downloadResult, Http::STATUS_BAD_REQUEST); - } else { - return new DataResponse($downloadResult); + public function insertImageLink(int $textFileId, string $link): DataResponse { + try { + $downloadResult = $this->imageService->insertImageLink($textFileId, $link, $this->userId); + if (isset($downloadResult['error'])) { + return new DataResponse($downloadResult, Http::STATUS_BAD_REQUEST); + } else { + return new DataResponse($downloadResult); + } + } catch (Exception $e) { + return new DataResponse(['error' => 'Link insertion error: ' . $e->getMessage()], Http::STATUS_BAD_REQUEST); + } + } + + /** + * @NoAdminRequired + * @PublicPage + * + * @param int|null $textFileId can be null with public file share + * @param string $link + * @param string $shareToken + * @return DataResponse + */ + public function insertImageLinkPublic(?int $textFileId, string $link, string $shareToken): DataResponse { + try { + $downloadResult = $this->imageService->insertImageLinkPublic($textFileId, $link, $shareToken); + if (isset($downloadResult['error'])) { + return new DataResponse($downloadResult, Http::STATUS_BAD_REQUEST); + } else { + return new DataResponse($downloadResult); + } + } catch (Exception $e) { + return new DataResponse(['error' => 'Link insertion error: ' . $e->getMessage()], Http::STATUS_BAD_REQUEST); } } /** * @NoAdminRequired + * + * @param int $textFileId + * @return DataResponse */ - public function uploadImage(string $textFilePath): DataResponse { + public function uploadImage(int $textFileId): DataResponse { try { $file = $this->request->getUploadedFile('image'); - if ($file !== null && isset($file['tmp_name'], $file['name'])) { + if ($file !== null && isset($file['tmp_name'], $file['name'], $file['type'])) { + if (!in_array($file['type'], self::IMAGE_MIME_TYPES)) { + return new DataResponse(['error' => 'Image type not supported'], Http::STATUS_BAD_REQUEST); + } $newFileContent = file_get_contents($file['tmp_name']); $newFileName = $file['name']; - $uploadResult = $this->imageService->uploadImage($textFilePath, $newFileName, $newFileContent, $this->userId); - return new DataResponse($uploadResult); + $uploadResult = $this->imageService->uploadImage($textFileId, $newFileName, $newFileContent, $this->userId); + if (isset($uploadResult['error'])) { + return new DataResponse($uploadResult, Http::STATUS_BAD_REQUEST); + } else { + return new DataResponse($uploadResult); + } } else { return new DataResponse(['error' => 'No uploaded file'], Http::STATUS_BAD_REQUEST); } } catch (Exception $e) { + $this->logger->error('Upload error: ' . $e->getMessage(), ['app' => Application::APP_NAME]); return new DataResponse(['error' => 'Upload error'], Http::STATUS_BAD_REQUEST); } } + + /** + * @NoAdminRequired + * @PublicPage + * + * @param int|null $textFileId can be null with public file share + * @param string $shareToken + * @return DataResponse + */ + public function uploadImagePublic(?int $textFileId, string $shareToken): DataResponse { + try { + $file = $this->request->getUploadedFile('image'); + if ($file !== null && isset($file['tmp_name'], $file['name'], $file['type'])) { + if (!in_array($file['type'], self::IMAGE_MIME_TYPES)) { + return new DataResponse(['error' => 'Image type not supported'], Http::STATUS_BAD_REQUEST); + } + $newFileContent = file_get_contents($file['tmp_name']); + $newFileName = $file['name']; + $uploadResult = $this->imageService->uploadImagePublic($textFileId, $newFileName, $newFileContent, $shareToken); + if (isset($uploadResult['error'])) { + return new DataResponse($uploadResult, Http::STATUS_BAD_REQUEST); + } else { + return new DataResponse($uploadResult); + } + } else { + return new DataResponse(['error' => 'No uploaded file'], Http::STATUS_BAD_REQUEST); + } + } catch (Exception $e) { + $this->logger->error('Upload error: ' . $e->getMessage(), ['app' => Application::APP_NAME]); + return new DataResponse(['error' => 'Upload error'], Http::STATUS_BAD_REQUEST); + } + } + + /** + * @NoAdminRequired + * @NoCSRFRequired + * + * Serve the images in the editor + * @param int $textFileId + * @param string $imageFileName + * @return DataDisplayResponse + * @throws \OCP\Files\InvalidPathException + * @throws \OCP\Files\NotFoundException + * @throws \OCP\Files\NotPermittedException + * @throws \OCP\Lock\LockedException + * @throws \OC\User\NoUserException + */ + public function getImage(int $textFileId, string $imageFileName): DataDisplayResponse { + $imageFile = $this->imageService->getImage($textFileId, $imageFileName, $this->userId); + if ($imageFile !== null) { + return new DataDisplayResponse($imageFile->getContent(), Http::STATUS_OK, ['Content-Type' => $imageFile->getMimeType()]); + } else { + return new DataDisplayResponse('', Http::STATUS_NOT_FOUND); + } + } + + /** + * @NoAdminRequired + * @NoCSRFRequired + * @PublicPage + * + * @param int $textFileId + * @param string $imageFileName + * @param string $shareToken + * @return DataDisplayResponse + */ + public function getImagePublic(int $textFileId, string $imageFileName, string $shareToken): DataDisplayResponse { + $imageFile = $this->imageService->getImagePublic($textFileId, $imageFileName, $shareToken); + if ($imageFile !== null) { + return new DataDisplayResponse($imageFile->getContent(), Http::STATUS_OK, ['Content-Type' => $imageFile->getMimeType()]); + } else { + return new DataDisplayResponse('', Http::STATUS_NOT_FOUND); + } + } } diff --git a/lib/Service/ImageService.php b/lib/Service/ImageService.php index f0a4a6fe2fc..309963b9214 100644 --- a/lib/Service/ImageService.php +++ b/lib/Service/ImageService.php @@ -27,7 +27,15 @@ namespace OCA\Text\Service; use Exception; +use OCA\Text\Controller\ImageController; +use OCP\Constants; use OCP\Files\Folder; +use OCP\Files\File; +use OCP\Files\NotFoundException; +use OCP\Files\SimpleFS\ISimpleFile; +use OCP\IPreview; +use OCP\Share\Exceptions\ShareNotFound; +use OCP\Share\IShare; use Throwable; use GuzzleHttp\Exception\ClientException; use GuzzleHttp\Exception\ConnectException; @@ -58,65 +66,252 @@ class ImageService { * @var LoggerInterface */ private $logger; + /** + * @var IPreview + */ + private $previewManager; public function __construct(IRootFolder $rootFolder, LoggerInterface $logger, ShareManager $shareManager, + IPreview $previewManager, IClientService $clientService) { $this->rootFolder = $rootFolder; $this->shareManager = $shareManager; $this->clientService = $clientService; $this->logger = $logger; + $this->previewManager = $previewManager; } /** - * @param string $textFilePath + * Get image content from file name + * + * @param int $textFileId + * @param string $imageFileName + * @param string $userId + * @return File|null + * @throws NotFoundException + * @throws \OCP\Files\InvalidPathException + * @throws \OCP\Files\NotPermittedException + * @throws \OCP\Lock\LockedException + * @throws \OC\User\NoUserException + */ + public function getImage(int $textFileId, string $imageFileName, string $userId): ?ISimpleFile { + $textFile = $this->getTextFile($textFileId, $userId); + $attachmentFolder = $this->getOrCreateAttachmentDirectoryForFile($textFile); + if ($attachmentFolder !== null) { + try { + $imageFile = $attachmentFolder->get($imageFileName); + } catch (NotFoundException $e) { + return null; + } + if ($imageFile instanceof File) { +// return $imageFile; + return $this->previewManager->getPreview($imageFile, 1024, 1024); + } + } + return null; + } + + /** + * Get image content from file name in public context + * + * @param int $textFileId + * @param string $imageFileName + * @param string $shareToken + * @return File|null + * @throws NotFoundException + * @throws \OCP\Files\InvalidPathException + * @throws \OCP\Files\NotPermittedException + * @throws \OCP\Lock\LockedException + * @throws \OC\User\NoUserException + */ + public function getImagePublic(int $textFileId, string $imageFileName, string $shareToken): ?ISimpleFile { + $textFile = $this->getTextFilePublic($textFileId, $shareToken); + $attachmentFolder = $this->getOrCreateAttachmentDirectoryForFile($textFile); + if ($attachmentFolder !== null) { + try { + $imageFile = $attachmentFolder->get($imageFileName); + } catch (NotFoundException $e) { + return null; + } + if ($imageFile instanceof File) { +// return $imageFile; + return $this->previewManager->getPreview($imageFile, 1024, 1024); + } + } + return null; + } + + /** + * Save an uploaded image in the attachment folder + * + * @param int $textFileId * @param string $newFileName * @param string $newFileContent * @param string $userId * @return array + * @throws \OCP\Files\InvalidPathException + * @throws \OCP\Files\NotFoundException + * @throws \OCP\Files\NotPermittedException + * @throws \OC\User\NoUserException + */ + public function uploadImage(int $textFileId, string $newFileName, string $newFileContent, string $userId): array { + $textFile = $this->getTextFile($textFileId, $userId); + if (!$textFile->isUpdateable()) { + throw new Exception('No write permissions'); + } + $saveDir = $this->getOrCreateAttachmentDirectoryForFile($textFile); + if ($saveDir !== null) { + $fileName = (string) time() . '-' . $newFileName; + $savedFile = $saveDir->newFile($fileName, $newFileContent); + $path = preg_replace('/^files/', '', $savedFile->getInternalPath()); + return [ + 'name' => $fileName, + 'path' => $path, + 'id' => $savedFile->getId(), + 'textFileId' => $textFile->getId(), + ]; + } else { + return [ + 'error' => 'Impossible to get attachment directory', + ]; + } + } + + /** + * Save an uploaded image in the attachment folder in a public context + * + * @param int|null $textFileId + * @param string $newFileName + * @param string $newFileContent + * @param string $shareToken + * @return array|string[] + * @throws NotFoundException + * @throws \OCP\Files\InvalidPathException + * @throws \OCP\Files\NotPermittedException */ - public function uploadImage(string $textFilePath, string $newFileName, string $newFileContent, string $userId): array { - $fileName = (string) time() . '-' . $newFileName; - $saveDir = $this->getOrCreateTextDirectory($userId); + public function uploadImagePublic(?int $textFileId, string $newFileName, string $newFileContent, string $shareToken): array { + if (!$this->hasUpdatePermissions($shareToken)) { + throw new Exception('No write permissions'); + } + $textFile = $this->getTextFilePublic($textFileId, $shareToken); + $saveDir = $this->getOrCreateAttachmentDirectoryForFile($textFile); if ($saveDir !== null) { + $fileName = (string) time() . '-' . $newFileName; $savedFile = $saveDir->newFile($fileName, $newFileContent); $path = preg_replace('/^files/', '', $savedFile->getInternalPath()); return [ 'name' => $fileName, 'path' => $path, + 'id' => $savedFile->getId(), + 'textFileId' => $textFile->getId(), ]; } else { return [ - 'error' => 'Impossible to create /Text directory', + 'error' => 'Impossible to get attachment directory', ]; } } /** + * Download and save an image from a link in the attachment folder + * + * @param int $textFileId * @param string $link + * @param string $userId * @return array + * @throws NotFoundException + * @throws \OCP\Files\InvalidPathException + * @throws \OCP\Files\NotPermittedException + * @throws \OC\User\NoUserException */ - public function insertImageLink(string $link, string $userId): array { - $fileName = (string) time(); - $saveDir = $this->getOrCreateTextDirectory($userId); + public function insertImageLink(int $textFileId, string $link, string $userId): array { + $textFile = $this->getTextFile($textFileId, $userId); + if (!$textFile->isUpdateable()) { + throw new Exception('No write permissions'); + } + $saveDir = $this->getOrCreateAttachmentDirectoryForFile($textFile); if ($saveDir !== null) { - $savedFile = $saveDir->newFile($fileName); - $resource = $savedFile->fopen('w'); - $res = $this->simpleDownload($link, $resource); - if (is_resource($resource)) { - fclose($resource); - } - $savedFile->touch(); - if (isset($res['Content-Type'])) { - if (in_array($res['Content-Type'], ['image/jpg', 'image/jpeg'])) { + return $this->downloadLink($saveDir, $link, $textFile); + } else { + return [ + 'error' => 'Impossible to get attachment directory', + ]; + } + } + + /** + * Download and save an image from a link in the attachment folder in a public context + * + * @param int|null $textFileId + * @param string $link + * @param string $shareToken + * @return array|string[] + * @throws Exception + */ + public function insertImageLinkPublic(?int $textFileId, string $link, string $shareToken): array { + if (!$this->hasUpdatePermissions($shareToken)) { + throw new Exception('No write permissions'); + } + $textFile = $this->getTextFilePublic($textFileId, $shareToken); + $saveDir = $this->getOrCreateAttachmentDirectoryForFile($textFile); + if ($saveDir !== null) { + return $this->downloadLink($saveDir, $link, $textFile); + } else { + return [ + 'error' => 'Impossible to get attachment directory', + ]; + } + } + + /** + * 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 ($share->getShareType() === IShare::TYPE_LINK && $share->getPermissions() & Constants::PERMISSION_UPDATE); + } catch (ShareNotFound $e) { + return false; + } + } + + /** + * Download an image from a link and place it in a given folder + * + * @param Folder $saveDir + * @param string $link + * @param File $textFile + * @return array|string[] + * @throws NotFoundException + * @throws \OCP\Files\InvalidPathException + * @throws \OCP\Files\NotPermittedException + * @throws \OCP\Lock\LockedException + */ + private function downloadLink(Folder $saveDir, string $link, File $textFile): array { + $fileName = (string) time(); + $savedFile = $saveDir->newFile($fileName); + $resource = $savedFile->fopen('w'); + $res = $this->simpleDownload($link, $resource); + if (is_resource($resource)) { + fclose($resource); + } + $savedFile->touch(); + if (isset($res['Content-Type'])) { + if (in_array($res['Content-Type'], ImageController::IMAGE_MIME_TYPES)) { + if ($res['Content-Type'] === 'image/jpeg') { $fileName = $fileName . '.jpg'; - } elseif ($res['Content-Type'] === 'image/png') { - $fileName = $fileName . '.png'; + } elseif ($res['Content-Type'] === 'image/x-xbitmap' || $res['Content-Type'] === 'image/x-ms-bmp') { + $fileName = $fileName . '.bmp'; + } elseif ($res['Content-Type'] === 'image/svg+xml') { + $fileName = $fileName . '.svg'; } else { - return [ - 'error' => 'Unsupported file type', - ]; + $ext = preg_replace('/^image\//i', '', $res['Content-Type']); + $fileName = $fileName . '.' . $ext; } $targetPath = $saveDir->getPath() . '/' . $fileName; $savedFile->move($targetPath); @@ -125,22 +320,39 @@ public function insertImageLink(string $link, string $userId): array { return [ 'name' => $fileName, 'path' => $path, + 'id' => $savedFile->getId(), + 'textFileId' => $textFile->getId(), ]; } else { - return $res; + $savedFile->delete(); + return [ + 'error' => 'Unsupported file type', + ]; } + } elseif (isset($res['error'])) { + $savedFile->delete(); + return $res; } else { + $savedFile->delete(); return [ - 'error' => 'Impossible to create /Text directory', + 'error' => 'Link download error', ]; } } + /** + * Get or create the user-specific attachment folder + * + * @param string $userId + * @return Folder|null + * @throws NotFoundException + * @throws \OCP\Files\NotPermittedException + * @throws \OC\User\NoUserException + */ private function getOrCreateTextDirectory(string $userId): ?Folder { $userFolder = $this->rootFolder->getUserFolder($userId); if ($userFolder->nodeExists('/Text')) { $node = $userFolder->get('Text'); - //if ($node->getType() === FileInfo::TYPE_FOLDER) { if ($node instanceof Folder) { return $node; } else { @@ -151,6 +363,101 @@ private function getOrCreateTextDirectory(string $userId): ?Folder { } } + /** + * Get or create file-specific attachment folder + * + * @param File $textFile + * @return Folder|null + * @throws NotFoundException + * @throws \OCP\Files\InvalidPathException + * @throws \OCP\Files\NotPermittedException + * @throws \OC\User\NoUserException + */ + private function getOrCreateAttachmentDirectoryForFile(File $textFile): ?Folder { + $owner = $textFile->getOwner(); + $ownerId = $owner->getUID(); + $ownerUserFolder = $this->rootFolder->getUserFolder($ownerId); + $ownerTextFile = $ownerUserFolder->getById($textFile->getId()); + if (count($ownerTextFile) > 0) { + $ownerTextFile = $ownerTextFile[0]; + $ownerParentFolder = $ownerTextFile->getParent(); + $attachmentFolderName = '.' . $textFile->getId(); + if ($ownerParentFolder->nodeExists($attachmentFolderName)) { + $attachmentFolder = $ownerParentFolder->get($attachmentFolderName); + if ($attachmentFolder instanceof Folder) { + return $attachmentFolder; + } + } else { + return $ownerParentFolder->newFolder($attachmentFolderName); + } + } + return null; + } + + /** + * Get a user file from file ID + * + * @param int $textFileId + * @param string $userId + * @return Folder|null + * @throws \OCP\Files\InvalidPathException + * @throws \OCP\Files\NotFoundException + * @throws \OCP\Files\NotPermittedException + * @throws \OC\User\NoUserException + */ + private function getTextFile(int $textFileId, string $userId): ?File { + $userFolder = $this->rootFolder->getUserFolder($userId); + $textFile = $userFolder->getById($textFileId); + if (count($textFile) > 0 && $textFile[0] instanceof File) { + return $textFile[0]; + } + return null; + } + + /** + * Get file from share token + * + * @param int|null $textFileId + * @param string $shareToken + * @return File|null + * @throws NotFoundException + */ + private function getTextFilePublic(?int $textFileId, string $shareToken): ?File { + // is the file shared with this token? + try { + $share = $this->shareManager->getShareByToken($shareToken); + if ($share->getShareType() === IShare::TYPE_LINK) { + // shared file or folder? + if ($share->getNodeType() === 'file') { + $textFile = $share->getNode(); + if ($textFile instanceof File) { + return $textFile; + } + } elseif ($share->getNodeType() === 'folder' && $textFileId !== null) { + $folder = $share->getNode(); + if ($folder instanceof Folder) { + $textFile = $folder->getById($textFileId); + if (count($textFile) > 0 && $textFile[0] instanceof File) { + return $textFile[0]; + } + } + } + } + } catch (ShareNotFound $e) { + return null; + } + return null; + } + + /** + * Download a file and write it to a resource + * + * @param string $url + * @param $resource + * @param array $params + * @param string $method + * @return array|string[] + */ private function simpleDownload(string $url, $resource, array $params = [], string $method = 'GET'): array { $client = $this->clientService->newClient(); try { diff --git a/src/components/EditorWrapper.vue b/src/components/EditorWrapper.vue index 62890cd6d07..f3b10d214ce 100644 --- a/src/components/EditorWrapper.vue +++ b/src/components/EditorWrapper.vue @@ -39,6 +39,7 @@ ref="menubar" :editor="tiptap" :file-path="relativePath" + :file-id="fileId" :is-rich-editor="isRichEditor" :is-public="isPublic" :autohide="autohide"> diff --git a/src/components/MenuBar.vue b/src/components/MenuBar.vue index 62a9683de4b..a6befda061d 100644 --- a/src/components/MenuBar.vue +++ b/src/components/MenuBar.vue @@ -29,7 +29,7 @@ accept="image/*" aria-hidden="true" class="hidden-visually" - @change="onImageFilePicked"> + @change="onImageUploadFilePicked">