From 3fec1f052dbb8cbe33172e779ac853eeb934ee62 Mon Sep 17 00:00:00 2001 From: Julien Veyssier Date: Wed, 8 Jun 2022 17:44:49 +0200 Subject: allow media file upload, handle display in ImageView Signed-off-by: Julien Veyssier --- appinfo/routes.php | 5 +- lib/Controller/ImageController.php | 130 ++++++++++++++++- lib/Service/ImageService.php | 257 ++++++++++++++++++++++++--------- src/components/Editor/MediaHandler.vue | 41 +++++- src/nodes/ImageView.vue | 72 +++++++-- src/services/ImageResolver.js | 88 +++++++++-- src/services/SyncService.js | 12 -- 7 files changed, 487 insertions(+), 118 deletions(-) diff --git a/appinfo/routes.php b/appinfo/routes.php index 9d1726566..99bd34973 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -29,7 +29,10 @@ return [ 'routes' => [ ['name' => 'Image#insertImageFile', 'url' => '/image/filepath', 'verb' => 'POST'], ['name' => 'Image#uploadImage', 'url' => '/image/upload', 'verb' => 'POST'], - ['name' => 'Image#getImage', 'url' => '/image', 'verb' => 'GET'], + ['name' => 'Image#getImageFile', 'url' => '/image', 'verb' => 'GET'], + ['name' => 'Image#getMediaFile', 'url' => '/media', 'verb' => 'GET'], + ['name' => 'Image#getMediaFilePreview', 'url' => '/mediaPreview', 'verb' => 'GET'], + ['name' => 'Image#getMediaFileMetadata', 'url' => '/mediaMetadata', '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 219fa7b68..36616e554 100644 --- a/lib/Controller/ImageController.php +++ b/lib/Controller/ImageController.php @@ -33,6 +33,7 @@ use OCA\Text\Service\ImageService; use OCP\AppFramework\Controller; use OCP\AppFramework\Http\DataDownloadResponse; use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\Http\RedirectResponse; use OCP\Files\IMimeTypeDetector; use OCP\IL10N; use OCP\IRequest; @@ -121,9 +122,11 @@ class ImageController extends Controller { try { $file = $this->getUploadedFile('image'); if (isset($file['tmp_name'], $file['name'], $file['type'])) { + /* if (!in_array($file['type'], self::IMAGE_MIME_TYPES, true)) { return new DataResponse(['error' => 'Image type not supported'], Http::STATUS_BAD_REQUEST); } + */ $newFileResource = fopen($file['tmp_name'], 'rb'); if ($newFileResource === false) { throw new Exception('Could not read file'); @@ -179,7 +182,7 @@ class ImageController extends Controller { * @NoCSRFRequired * @PublicPage * - * Serve the images in the editor + * Serve the image files in the editor * @param int $documentId * @param int $sessionId * @param string $sessionToken @@ -187,17 +190,17 @@ class ImageController extends Controller { * @param string|null $shareToken * @return DataDownloadResponse|DataResponse */ - public function getImage(int $documentId, int $sessionId, string $sessionToken, string $imageFileName, ?string $shareToken = null) { + public function getImageFile(int $documentId, int $sessionId, string $sessionToken, string $imageFileName, ?string $shareToken = null) { if (!$this->sessionService->isValidSession($documentId, $sessionId, $sessionToken)) { return new DataResponse('', Http::STATUS_FORBIDDEN); } try { if ($shareToken) { - $imageFile = $this->imageService->getImagePublic($documentId, $imageFileName, $shareToken); + $imageFile = $this->imageService->getImageFilePublic($documentId, $imageFileName, $shareToken); } else { $userId = $this->getUserIdFromSession($documentId, $sessionId, $sessionToken); - $imageFile = $this->imageService->getImage($documentId, $imageFileName, $userId); + $imageFile = $this->imageService->getImageFile($documentId, $imageFileName, $userId); } return $imageFile !== null ? new DataDownloadResponse( @@ -207,7 +210,124 @@ class ImageController extends Controller { ) : new DataResponse('', Http::STATUS_NOT_FOUND); } catch (Exception $e) { - $this->logger->error('getImage error', ['exception' => $e]); + $this->logger->error('getImageFile error', ['exception' => $e]); + return new DataResponse('', Http::STATUS_NOT_FOUND); + } + } + + /** + * @NoAdminRequired + * @NoCSRFRequired + * @PublicPage + * + * Serve the media files in the editor + * @param int $documentId + * @param int $sessionId + * @param string $sessionToken + * @param string $mediaFileName + * @param string|null $shareToken + * @return DataDownloadResponse|DataResponse + */ + public function getMediaFile(int $documentId, int $sessionId, string $sessionToken, string $mediaFileName, ?string $shareToken = null) { + if (!$this->sessionService->isValidSession($documentId, $sessionId, $sessionToken)) { + return new DataResponse('', Http::STATUS_FORBIDDEN); + } + + try { + if ($shareToken) { + $mediaFile = $this->imageService->getMediaFilePublic($documentId, $mediaFileName, $shareToken); + } else { + $userId = $this->getUserIdFromSession($documentId, $sessionId, $sessionToken); + $mediaFile = $this->imageService->getMediaFile($documentId, $mediaFileName, $userId); + } + return $mediaFile !== null + ? new DataDownloadResponse( + $mediaFile->getContent(), + (string) Http::STATUS_OK, + $this->getSecureMimeType($mediaFile->getMimeType()) + ) + : new DataResponse('', Http::STATUS_NOT_FOUND); + } catch (Exception $e) { + $this->logger->error('getMediaFile error', ['exception' => $e]); + return new DataResponse('', Http::STATUS_NOT_FOUND); + } + } + + /** + * @NoAdminRequired + * @NoCSRFRequired + * @PublicPage + * + * Serve the media files preview in the editor + * @param int $documentId + * @param int $sessionId + * @param string $sessionToken + * @param string $mediaFileName + * @param string|null $shareToken + * @return DataDownloadResponse|DataResponse|RedirectResponse + */ + public function getMediaFilePreview(int $documentId, int $sessionId, string $sessionToken, string $mediaFileName, ?string $shareToken = null) { + if (!$this->sessionService->isValidSession($documentId, $sessionId, $sessionToken)) { + return new DataResponse('', Http::STATUS_FORBIDDEN); + } + + try { + if ($shareToken) { + $preview = $this->imageService->getMediaFilePreviewPublic($documentId, $mediaFileName, $shareToken); + } else { + $userId = $this->getUserIdFromSession($documentId, $sessionId, $sessionToken); + $preview = $this->imageService->getMediaFilePreview($documentId, $mediaFileName, $userId); + } + if ($preview === null) { + return new DataResponse('', Http::STATUS_NOT_FOUND); + } + if ($preview['type'] === 'file') { + return new DataDownloadResponse( + $preview['file']->getContent(), + (string) Http::STATUS_OK, + $this->getSecureMimeType($preview['file']->getMimeType()) + ); + } elseif ($preview['type'] === 'icon') { + return new RedirectResponse($preview['iconUrl']); + } + } catch (Exception $e) { + $this->logger->error('getMediaFilePreview error', ['exception' => $e]); + return new DataResponse('', Http::STATUS_NOT_FOUND); + } + } + + /** + * @NoAdminRequired + * @NoCSRFRequired + * @PublicPage + * + * Serve the media files metadata in the editor + * @param int $documentId + * @param int $sessionId + * @param string $sessionToken + * @param string $mediaFileName + * @param string|null $shareToken + * @return DataResponse + */ + public function getMediaFileMetadata(int $documentId, int $sessionId, string $sessionToken, + string $mediaFileName, ?string $shareToken = null): DataResponse { + if (!$this->sessionService->isValidSession($documentId, $sessionId, $sessionToken)) { + return new DataResponse('', Http::STATUS_FORBIDDEN); + } + + try { + if ($shareToken) { + $metadata = $this->imageService->getMediaFileMetadataPublic($documentId, $mediaFileName, $shareToken); + } else { + $userId = $this->getUserIdFromSession($documentId, $sessionId, $sessionToken); + $metadata = $this->imageService->getMediaFileMetadataPrivate($documentId, $mediaFileName, $userId); + } + if ($metadata === null) { + return new DataResponse('', Http::STATUS_NOT_FOUND); + } + return new DataResponse($metadata); + } catch (Exception $e) { + $this->logger->error('getMediaFileMetadata error', ['exception' => $e]); return new DataResponse('', Http::STATUS_NOT_FOUND); } } diff --git a/lib/Service/ImageService.php b/lib/Service/ImageService.php index 901575355..f305a5e39 100644 --- a/lib/Service/ImageService.php +++ b/lib/Service/ImageService.php @@ -31,12 +31,14 @@ use OCA\Text\Controller\ImageController; use OCP\Constants; use OCP\Files\Folder; use OCP\Files\File; +use OCP\Files\IMimeTypeDetector; use OCP\Files\NotFoundException; use OCP\Files\NotPermittedException; use OCP\Files\SimpleFS\ISimpleFile; use OCP\IPreview; use OCP\Share\Exceptions\ShareNotFound; use OCP\Share\IShare; +use OCP\Util; use Throwable; use GuzzleHttp\Exception\ClientException; use GuzzleHttp\Exception\ConnectException; @@ -56,10 +58,6 @@ class ImageService { * @var IRootFolder */ private $rootFolder; - /** - * @var IClientService - */ - private $clientService; /** * @var LoggerInterface */ @@ -68,21 +66,25 @@ class ImageService { * @var IPreview */ private $previewManager; + /** + * @var IMimeTypeDetector + */ + private $mimeTypeDetector; public function __construct(IRootFolder $rootFolder, LoggerInterface $logger, ShareManager $shareManager, IPreview $previewManager, - IClientService $clientService) { + IMimeTypeDetector $mimeTypeDetector) { $this->rootFolder = $rootFolder; $this->shareManager = $shareManager; - $this->clientService = $clientService; $this->logger = $logger; $this->previewManager = $previewManager; + $this->mimeTypeDetector = $mimeTypeDetector; } /** - * Get image content or preview from file id + * Get image content or preview from file name * @param int $documentId * @param string $imageFileName * @param string $userId @@ -91,9 +93,9 @@ class ImageService { * @throws \OCP\Files\InvalidPathException * @throws \OCP\Files\NotPermittedException */ - public function getImage(int $documentId, string $imageFileName, string $userId) { + public function getImageFile(int $documentId, string $imageFileName, string $userId) { $textFile = $this->getTextFile($documentId, $userId); - return $this->getImagePreview($imageFileName, $textFile); + return $this->getImageFilePreview($imageFileName, $textFile); } /** @@ -107,9 +109,9 @@ class ImageService { * @throws \OCP\Files\InvalidPathException * @throws \OC\User\NoUserException */ - public function getImagePublic(int $documentId, string $imageFileName, string $shareToken) { + public function getImageFilePublic(int $documentId, string $imageFileName, string $shareToken) { $textFile = $this->getTextFilePublic($documentId, $shareToken); - return $this->getImagePreview($imageFileName, $textFile); + return $this->getImageFilePreview($imageFileName, $textFile); } /** @@ -121,10 +123,10 @@ class ImageService { * @throws \OCP\Files\InvalidPathException * @throws \OC\User\NoUserException */ - private function getImagePreview(string $imageFileName, File $textFile) { + private function getImageFilePreview(string $imageFileName, File $textFile) { $attachmentFolder = $this->getAttachmentDirectoryForFile($textFile, true); $imageFile = $attachmentFolder->get($imageFileName); - if ($imageFile instanceof File) { + if ($imageFile instanceof File && in_array($imageFile->getMimetype(), ImageController::IMAGE_MIME_TYPES)) { if ($this->previewManager->isMimeSupported($imageFile->getMimeType())) { return $this->previewManager->getPreview($imageFile, 1024, 1024); } @@ -133,6 +135,168 @@ class ImageService { return null; } + /** + * Get media file from file name + * @param int $documentId + * @param string $mediaFileName + * @param string $userId + * @return File|\OCP\Files\Node|ISimpleFile|null + * @throws NotFoundException + * @throws \OCP\Files\InvalidPathException + * @throws \OCP\Files\NotPermittedException + */ + public function getMediaFile(int $documentId, string $mediaFileName, string $userId) { + $textFile = $this->getTextFile($documentId, $userId); + return $this->getMediaFullFile($mediaFileName, $textFile); + } + + /** + * Get image content or preview from file id in public context + * @param int $documentId + * @param string $mediaFileName + * @param string $shareToken + * @return File|\OCP\Files\Node|ISimpleFile|null + * @throws NotFoundException + * @throws NotPermittedException + * @throws \OCP\Files\InvalidPathException + * @throws \OC\User\NoUserException + */ + public function getMediaFilePublic(int $documentId, string $mediaFileName, string $shareToken) { + $textFile = $this->getTextFilePublic($documentId, $shareToken); + return $this->getMediaFullFile($mediaFileName, $textFile); + } + + /** + * @param string $mediaFileName + * @param File $textFile + * @return File|null + * @throws NotFoundException + * @throws NotPermittedException + * @throws \OCP\Files\InvalidPathException + * @throws \OC\User\NoUserException + */ + private function getMediaFullFile(string $mediaFileName, File $textFile): ?File { + $attachmentFolder = $this->getAttachmentDirectoryForFile($textFile, true); + $mediaFile = $attachmentFolder->get($mediaFileName); + if ($mediaFile instanceof File) { + return $mediaFile; + } + return null; + } + + /** + * @param int $documentId + * @param string $mediaFileName + * @param string $userId + * @return array|null + * @throws NotFoundException + * @throws NotPermittedException + * @throws \OCP\Files\InvalidPathException + * @throws \OC\User\NoUserException + */ + public function getMediaFilePreview(int $documentId, string $mediaFileName, string $userId): ?array { + $textFile = $this->getTextFile($documentId, $userId); + return $this->getMediaFilePreviewFile($mediaFileName, $textFile); + } + /** + * @param int $documentId + * @param string $mediaFileName + * @param string $shareToken + * @return array|null + * @throws NotFoundException + * @throws NotPermittedException + * @throws \OCP\Files\InvalidPathException + * @throws \OC\User\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 + * @param string $mediaFileName + * @param File $textFile + * @return array|null + * @throws NotFoundException + * @throws NotPermittedException + * @throws \OCP\Files\InvalidPathException + * @throws \OC\User\NoUserException + */ + private function getMediaFilePreviewFile(string $mediaFileName, File $textFile): ?array { + $attachmentFolder = $this->getAttachmentDirectoryForFile($textFile, true); + $mediaFile = $attachmentFolder->get($mediaFileName); + if ($mediaFile instanceof File) { + 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; + } + + /** + * @param int $documentId + * @param string $mediaFileName + * @param string $userId + * @return array|null + * @throws NotFoundException + * @throws NotPermittedException + * @throws \OCP\Files\InvalidPathException + * @throws \OC\User\NoUserException + */ + public function getMediaFileMetadataPrivate(int $documentId, string $mediaFileName, string $userId): ?array { + $textFile = $this->getTextFile($documentId, $userId); + return $this->getMediaFileMetadata($mediaFileName, $textFile); + } + + /** + * @param int $documentId + * @param string $mediaFileName + * @param string $shareToken + * @return array|null + * @throws NotFoundException + * @throws NotPermittedException + * @throws \OCP\Files\InvalidPathException + * @throws \OC\User\NoUserException + */ + public function getMediaFileMetadataPublic(int $documentId, string $mediaFileName, string $shareToken): ?array { + $textFile = $this->getTextFilePublic($documentId, $shareToken); + return $this->getMediaFileMetadata($mediaFileName, $textFile); + } + + /** + * @param string $mediaFileName + * @param File $textFile + * @return array|null + * @throws NotFoundException + * @throws NotPermittedException + * @throws \OCP\Files\InvalidPathException + * @throws \OC\User\NoUserException + */ + private function getMediaFileMetadata(string $mediaFileName, File $textFile): ?array { + $attachmentFolder = $this->getAttachmentDirectoryForFile($textFile, true); + $mediaFile = $attachmentFolder->get($mediaFileName); + if ($mediaFile instanceof File) { + return [ + 'size' => Util::humanFileSize($mediaFile->getSize()), + 'mtime' => $mediaFile->getMTime(), + ]; + } + return null; + } + /** * Save an uploaded image in the attachment folder * @@ -221,21 +385,15 @@ class ImageService { * @throws \OCP\Files\InvalidPathException */ private function copyImageFile(File $imageFile, Folder $saveDir, File $textFile): array { - $mimeType = $imageFile->getMimeType(); - if (in_array($mimeType, ImageController::IMAGE_MIME_TYPES, true)) { - $fileName = $this->getUniqueFileName($saveDir, $imageFile->getName()); - $targetPath = $saveDir->getPath() . '/' . $fileName; - $targetFile = $imageFile->copy($targetPath); - // get file type and name - return [ - 'name' => $fileName, - 'dirname' => $saveDir->getName(), - 'id' => $targetFile->getId(), - 'documentId' => $textFile->getId(), - ]; - } + $fileName = $this->getUniqueFileName($saveDir, $imageFile->getName()); + $targetPath = $saveDir->getPath() . '/' . $fileName; + $targetFile = $imageFile->copy($targetPath); return [ - 'error' => 'Unsupported file type', + 'name' => $fileName, + 'dirname' => $saveDir->getName(), + 'id' => $targetFile->getId(), + 'documentId' => $textFile->getId(), + 'mimetype' => $targetFile->getMimetype(), ]; } @@ -389,49 +547,6 @@ class ImageService { throw new NotFoundException('Text file with id=' . $documentId . ' and shareToken ' . $shareToken . ' was not found.'); } - /** - * Download a file and write it to a resource - * @param string $url - * @param $resource - * @return array - */ - private function simpleDownload(string $url, $resource): array { - $client = $this->clientService->newClient(); - try { - $options = [ - // does not work with sink if SSE is enabled - // 'sink' => $resource, - // rather use stream and write to the file ourselves - 'stream' => true, - 'timeout' => 0, - 'headers' => [ - 'User-Agent' => 'Nextcloud Text', - ], - ]; - - $response = $client->get($url, $options); - $body = $response->getBody(); - while (!feof($body)) { - // write ~5 MB chunks - $chunk = fread($body, 5000000); - fwrite($resource, $chunk); - } - - return ['Content-Type' => $response->getHeader('Content-Type')]; - } catch (ServerException | ClientException $e) { - //$response = $e->getResponse(); - //if ($response->getStatusCode() === 401) { - $this->logger->warning('Impossible to download image', ['exception' => $e]); - return ['error' => 'Impossible to download image']; - } catch (ConnectException $e) { - $this->logger->error('Connection error', ['exception' => $e]); - return ['error' => 'Connection error']; - } catch (Throwable | Exception $e) { - $this->logger->error('Unknown download error', ['exception' => $e]); - return ['error' => 'Unknown download error']; - } - } - /** * Actually delete attachment files which are not pointed in the markdown content * @@ -498,7 +613,7 @@ class ImageService { $matches = []; // matches ![ANY_CONSIDERED_CORRECT_BY_PHP-MARKDOWN](.attachments.DOCUMENT_ID/ANY_FILE_NAME) and captures FILE_NAME preg_match_all( - '/\!\[(?>[^\[\]]+|\[(?>[^\[\]]+|\[(?>[^\[\]]+|\[(?>[^\[\]]+|\[(?>[^\[\]]+|\[(?>[^\[\]]+|\[\])*\])*\])*\])*\])*\])*\]\(\.attachments\.'.$fileId.'\/([^)&]+)\)/', + '/\!\[(?>[^\[\]]+|\[(?>[^\[\]]+|\[(?>[^\[\]]+|\[(?>[^\[\]]+|\[(?>[^\[\]]+|\[(?>[^\[\]]+|\[\])*\])*\])*\])*\])*\])*\]\(\.attachments\.' . $fileId . '\/([^)&]+)\)/', $content, $matches, PREG_SET_ORDER diff --git a/src/components/Editor/MediaHandler.vue b/src/components/Editor/MediaHandler.vue index 7004b5b16..1a0f939d9 100644 --- a/src/components/Editor/MediaHandler.vue +++ b/src/components/Editor/MediaHandler.vue @@ -132,16 +132,14 @@ export default { }) }, async uploadImageFile(file, position = null) { - if (!IMAGE_MIMES.includes(file.type)) { - showError(t('text', 'Image file format not supported')) - return - } - this.state.isUploadingImages = true return this.$syncService.uploadImage(file) .then((response) => { - this.insertAttachmentImage(response.data?.name, response.data?.id, position, response.data?.dirname) + this.insertAttachment( + response.data?.name, response.data?.id, file.type, + position, response.data?.dirname + ) }) .catch((error) => { console.error(error) @@ -165,7 +163,10 @@ export default { this.state.isUploadingImages = true return this.$syncService.insertImageFile(imagePath).then((response) => { - this.insertAttachmentImage(response.data?.name, response.data?.id, null, response.data?.dirname) + this.insertAttachment( + response.data?.name, response.data?.id, response.data?.mimetype, + null, response.data?.dirname + ) }).catch((error) => { console.error(error) showError(error?.response?.data?.error || error.message) @@ -173,7 +174,31 @@ export default { this.state.isUploadingImages = false }) }, - insertAttachmentImage(name, fileId, position = null, dirname = '') { + insertAttachment(name, fileId, mimeType, position = null, dirname = '') { + if (IMAGE_MIMES.includes(mimeType)) { + this.insertAttachmentImage(name, fileId, mimeType, position, dirname) + return + } + this.insertAttachmentMedia(name, fileId, mimeType, position, dirname) + }, + insertAttachmentMedia(name, fileId, mimeType, position = null, dirname = '') { + // inspired by the fixedEncodeURIComponent function suggested in + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent + const src = dirname + '/' + + encodeURIComponent(name).replace(/[!'()*]/g, (c) => { + return '%' + c.charCodeAt(0).toString(16).toUpperCase() + }) + // simply get rid of brackets to make sure link text is valid + // as it does not need to be unique and matching the real file name + const alt = name.replaceAll(/[[\]]/g, '') + + const chain = position + ? this.$editor.chain().focus(position) + : this.$editor.chain() + + chain.setImage({ src, alt }).focus().run() + }, + insertAttachmentImage(name, fileId, mimeType, position = null, dirname = '') { // inspired by the fixedEncodeURIComponent function suggested in // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent const src = dirname + '/' diff --git a/src/nodes/ImageView.vue b/src/nodes/ImageView.vue index eb812e6d0..8519ebd58 100644 --- a/src/nodes/ImageView.vue +++ b/src/nodes/ImageView.vue @@ -34,10 +34,25 @@ @mouseleave="showIcons = false">