From 9f72b52e1d5dccd0537b5a95fd6a37c39375bc49 Mon Sep 17 00:00:00 2001 From: Wilco Louwerse Date: Tue, 13 Aug 2024 13:03:25 +0200 Subject: [PATCH] Added publication download endpoint with working pdf or zip download --- appinfo/routes.php | 1 + composer.json | 3 +- composer.lock | 5 +- lib/Controller/PublicationsController.php | 317 +++++++++++++++------- lib/Service/FileService.php | 21 +- lib/Templates/publication.html.twig | 12 +- 6 files changed, 251 insertions(+), 108 deletions(-) diff --git a/appinfo/routes.php b/appinfo/routes.php index a14b912e..e58f75ba 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -15,6 +15,7 @@ ['name' => 'metadata#page', 'url' => '/metadata', 'verb' => 'GET'], ['name' => 'publications#page', 'url' => '/publications', 'verb' => 'GET'], ['name' => 'publications#attachments', 'url' => '/api/publications/{id}/attachments', 'verb' => 'GET', 'requirements' => ['id' => '.+']], + ['name' => 'publications#download', 'url' => '/api/publications/{id}/download', 'verb' => 'GET', 'requirements' => ['id' => '.+']], ['name' => 'catalogi#page', 'url' => '/catalogi', 'verb' => 'GET'], ['name' => 'search#index', 'url' => '/search', 'verb' => 'GET'], ['name' => 'search#index', 'url' => '/api/search', 'verb' => 'GET'], diff --git a/composer.json b/composer.json index 04da6115..2a4dc0fa 100644 --- a/composer.json +++ b/composer.json @@ -40,9 +40,10 @@ }, "require": { "php": "^8.1", + "ext-zip": "*", + "adbario/php-dot-notation": "^3.3.0", "bamarni/composer-bin-plugin": "^1.8", "elasticsearch/elasticsearch": "^v8.14.0", - "adbario/php-dot-notation": "^3.3.0", "guzzlehttp/guzzle": "^7.0", "mpdf/mpdf": "^8.2", "symfony/twig-bundle": "^6.4", diff --git a/composer.lock b/composer.lock index 3d760808..7284d015 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "519778d6fe3eee049665d8072fd07402", + "content-hash": "93ceb09bbb9b85fb7d77d68d6d6eefe1", "packages": [ { "name": "adbario/php-dot-notation", @@ -4220,7 +4220,8 @@ "prefer-stable": false, "prefer-lowest": false, "platform": { - "php": "^8.1" + "php": "^8.1", + "ext-zip": "*" }, "platform-dev": [], "platform-overrides": { diff --git a/lib/Controller/PublicationsController.php b/lib/Controller/PublicationsController.php index 879dd590..468908bb 100644 --- a/lib/Controller/PublicationsController.php +++ b/lib/Controller/PublicationsController.php @@ -19,7 +19,8 @@ use OCP\AppFramework\Http\JSONResponse; use OCP\IAppConfig; use OCP\IRequest; -use OCP\IUserSession; +use RecursiveDirectoryIterator; +use RecursiveIteratorIterator; use Symfony\Component\Uid\Uuid; use Twig\Environment; use Twig\Error\LoaderError; @@ -27,6 +28,7 @@ use Twig\Error\SyntaxError; use Twig\Loader\FilesystemLoader; use Mpdf\Mpdf; +use ZipArchive; class PublicationsController extends Controller { @@ -38,8 +40,7 @@ public function __construct private readonly PublicationMapper $publicationMapper, private readonly AttachmentMapper $attachmentMapper, private readonly IAppConfig $config, - private readonly FileService $fileService, - private readonly IUserSession $userSession + private readonly FileService $fileService ) { parent::__construct($appName, $request); @@ -152,18 +153,20 @@ public function index(ObjectService $objectService, SearchService $searchService * @NoAdminRequired * @NoCSRFRequired */ - public function attachments(string|int $id, ObjectService $objectService): JSONResponse + public function attachments(string|int $id, ObjectService $objectService, array|null $publication = null): JSONResponse { - $jsonResponse = $this->show($id, $objectService); - $publication = $jsonResponse->getData(); - if (is_array($publication) === true && isset($publication['error']) === true) { - return new JSONResponse(data: $publication, statusCode: $jsonResponse->getStatus()); - } + if ($publication === null) { + $jsonResponse = $this->show($id, $objectService); + $publication = $jsonResponse->getData(); + if (is_array($publication) === true && isset($publication['error']) === true) { + return new JSONResponse(data: $publication, statusCode: $jsonResponse->getStatus()); + } - if ($this->config->hasKey(app: $this->appName, key: 'mongoStorage') === false - || $this->config->getValueString(app: $this->appName, key: 'mongoStorage') !== '1' - ) { - $publication = $publication->jsonSerialize(); + if ($this->config->hasKey(app: $this->appName, key: 'mongoStorage') === false + || $this->config->getValueString(app: $this->appName, key: 'mongoStorage') !== '1' + ) { + $publication = $publication->jsonSerialize(); + } } $attachments = $publication['attachments']; @@ -216,6 +219,213 @@ public function show(string|int $id, ObjectService $objectService): JSONResponse } + /** + * Creates a pdf file containing all metadata of the given publication. + * + * @param ObjectService $objectService The ObjectService, used to connect to a MongoDB database. + * @param string|int $id The id of a Publication we want to create / update a pdf file for. + * @param bool|null $download If we should return a download response (true = default) or only generate and save the file in NextCloud (false). + * @param array|null $publication If we already have a publication body prevent extra database requests by passing it along. + * + * @return JSONResponse A JSONResponse ... todo + * @throws LoaderError|RuntimeError|SyntaxError|MpdfException|Exception + */ + private function createPublicationFile(ObjectService $objectService, string|int $id, ?bool $download = true, ?array $publication = null): JSONResponse + { + if ($publication === null) { + $jsonResponse = $this->show(id: $id, objectService: $objectService); + $publication = $jsonResponse->getData(); + if (is_array($publication) === true && isset($publication['error']) === true) { + return new JSONResponse(data: $publication, statusCode: $jsonResponse->getStatus()); + } + + if ($this->config->hasKey(app: $this->appName, key: 'mongoStorage') === false + || $this->config->getValueString(app: $this->appName, key: 'mongoStorage') !== '1' + ) { + $publication = $publication->jsonSerialize(); + } + } + + // Initialize Twig + $loader = new FilesystemLoader(paths: 'lib/Templates', rootPath: '/var/www/html/apps-extra/opencatalogi'); + $twig = new Environment($loader); + + // Render the Twig template + $html = $twig->render(name: 'publication.html.twig', context: ['publication' => $publication]); + + // Check if the directory exists, if not, create it + if (file_exists(filename: '/tmp/mpdf') === false) { + mkdir(directory: '/tmp/mpdf', recursive: true); + } + + // Set permissions for the directory (ensure it's writable) + chmod(filename: '/tmp/mpdf', permissions: 0777); + + // Initialize mPDF + $mpdf = new Mpdf(config: ['tempDir' => '/tmp/mpdf']); + + // Write HTML to PDF + $mpdf->WriteHTML(html: $html); + + // Output to a file + $filename = "{$publication['title']}.pdf"; + $mpdf->Output(name: $filename, dest: Destination::FILE); + + // Create the Publicaties folder and the Publication specific folder. + $this->fileService->createFolder(folderPath: 'Publicaties'); + $publicationFolder = $this->fileService->getPublicationFolderName( + publicationId: $publication['id'], + publicationTitle: $publication['title'] + ); + $this->fileService->createFolder(folderPath: "Publicaties/$publicationFolder"); + + // Save the uploaded file + $filePath = "Publicaties/$publicationFolder/$filename"; + $this->fileService->deleteFile(filePath: $filePath); + $created = $this->fileService->uploadFile( + content: file_get_contents(filename: $filename), + filePath: $filePath + ); + + // Create ShareLink + $shareLink = $this->fileService->createShareLink(path: $filePath); + + if ($created === false) { + return new JSONResponse(data: ['error' => "Failed to upload this file: $filePath to NextCloud"], statusCode: 500); + } + + if ($download === true) { + // Output directly to the browser + $mpdf->Output(name: $filename, dest: Destination::DOWNLOAD); + } + + // Remove tmp folder + rmdir(directory: '/tmp/mpdf'); + + return new JSONResponse(['downloadUrl' => "$shareLink/download"], 200); + } + + + /** + * todo: + * + * @param ObjectService $objectService + * @param string|int $id + * + * @return JSONResponse + * @throws LoaderError|MpdfException|RuntimeError|SyntaxError + */ + private function creatPublicationZip(ObjectService $objectService, string|int $id): JSONResponse + { + // Get the publication. + $jsonResponse = $this->show(id: $id, objectService: $objectService); + $publication = $jsonResponse->getData(); + if (is_array($publication) === true && isset($publication['error']) === true) { + return new JSONResponse(data: $publication, statusCode: $jsonResponse->getStatus()); + } + + if ($this->config->hasKey(app: $this->appName, key: 'mongoStorage') === false + || $this->config->getValueString(app: $this->appName, key: 'mongoStorage') !== '1' + ) { + $publication = $publication->jsonSerialize(); + } + + // Update the publication .pdf file containing publication metadata. + $jsonResponse = $this->createPublicationFile(objectService: $objectService, id: $id, download: false, publication: $publication); + $publicationFile = $jsonResponse->getData(); + if (is_array($publicationFile) === true && isset($publicationFile['error']) === true) { + return new JSONResponse(data: $publicationFile, statusCode: $jsonResponse->getStatus()); + } + + // Get all publication attachments. + $attachments = $this->attachments(id: $id, objectService: $objectService, publication: $publication)->getData(); + if (isset($attachments['results']) === false) { + return new JSONResponse(data: ['error' => "failed to get attachments for this publication: $id"], statusCode: 500); + } + + // Temporary paths. + $tempFolder = '/tmp/nextcloud_download_' . $publication['title']; + $tempZip = '/tmp/publicatie_' . $publication['title'] . '.zip'; + + // Create temporary directory + if (file_exists(filename: $tempFolder) === false) { + mkdir(directory: $tempFolder, recursive: true); + if (count($attachments['results']) > 0) { + mkdir(directory: "$tempFolder/Bijlagen", recursive: true); + } + } + + // Add .pdf file containing publication metadata. + $file_content = file_get_contents(filename: $publicationFile['downloadUrl']); + if ($file_content !== false) { + file_put_contents(filename: "$tempFolder/{$publication['title']}.pdf", data: $file_content); + } + + // Add all attachments in Bijlagen folder. + foreach ($attachments['results'] as $attachment) { + $attachment = $attachment->jsonSerialize(); + $file_content = file_get_contents(filename: $attachment['downloadUrl']); + if ($file_content !== false) { + $filePath = explode(separator: '/', string: $attachment['reference']); + file_put_contents(filename: "$tempFolder/Bijlagen/".end(array: $filePath), data: $file_content); + } + } + + // Create ZIP archive. + $zip = new ZipArchive(); + if ($zip->open(filename: $tempZip, flags: ZipArchive::CREATE | ZipArchive::OVERWRITE) === TRUE) { + $files = new RecursiveIteratorIterator( + iterator: new RecursiveDirectoryIterator($tempFolder), + mode: RecursiveIteratorIterator::LEAVES_ONLY + ); + + foreach ($files as $name => $file) { + // Skip directories (they would be added automatically) + if ($file->isDir() === false) { + $filePath = $file->getRealPath(); + $relativePath = substr(string: $filePath, offset: strlen(string: $tempFolder) + 1); + + // Add file to zip + $zip->addFile(filepath: $filePath, entryname: $relativePath); + } + } + $zip->close(); + } else { + return new JSONResponse(data: ['error' => "failed to create ZIP archive for this publication: $id"], statusCode: 500); + } + + // Send the ZIP file to the client for download. + header(header: 'Content-Type: application/zip'); + header(header: 'Content-disposition: attachment; filename=' . basename($tempZip)); + header(header: 'Content-Length: ' . filesize($tempZip)); + readfile(filename: $tempZip); + + // Cleanup temporary files. + array_map(callback: 'unlink', array: glob(pattern: "$tempFolder/*.*")); + rmdir(directory: $tempFolder); + unlink(filename: $tempZip); + + return new JSONResponse([], 200); + } + + + /** + * @NoAdminRequired + * @NoCSRFRequired + */ + public function download(string|int $id, ObjectService $objectService): JSONResponse + { + return match ($this->request->getHeader('Accept')) { + 'application/pdf' => $this->createPublicationFile(objectService: $objectService, id: $id), + 'application/zip' => $this->creatPublicationZip(objectService: $objectService, id: $id), + default => new JSONResponse( + data: ['error' => 'Unsupported Accept header, please use [application/pdf] or [application/zip]'], + statusCode: 400 + ), + }; + } + + /** * @NoAdminRequired * @NoCSRFRequired @@ -268,8 +478,6 @@ public function create(ObjectService $objectService, ElasticSearchService $elast } - $this->createPublicationFile(objectService: $objectService, publication: $returnData); - // get post from requests return new JSONResponse($returnData); } @@ -330,8 +538,6 @@ public function update(string|int $id, ObjectService $objectService, ElasticSear } - $this->createPublicationFile(objectService: $objectService, publication: $returnData); - // get post from requests return new JSONResponse($returnData); } @@ -380,81 +586,4 @@ public function destroy(string|int $id, ObjectService $objectService, ElasticSea // get post from requests return new JSONResponse($returnData); } - - /** - * TODO - * - * @param ObjectService $objectService - * @param array|null $publication - * @param null|string|int $id - * - * @return bool - * @throws LoaderError|RuntimeError|SyntaxError|MpdfException|Exception - */ - public function createPublicationFile(ObjectService $objectService, ?array $publication = null, null|string|int $id = null): bool - { - if (empty($publication) === true) { - if ($id === null) { - return false; - } - - $publication = $this->show(id: $id, objectService: $objectService)->getData(); - if ($this->config->hasKey(app: $this->appName, key: 'mongoStorage') === false - || $this->config->getValueString(app: $this->appName, key: 'mongoStorage') !== '1' - ) { - $publication = $publication->jsonSerialize(); - } - } - - // Initialize Twig - $loader = new FilesystemLoader('lib/Templates', '/var/www/html/apps-extra/opencatalogi'); - $twig = new Environment($loader); - - // Render the Twig template - $html = $twig->render('publication.html.twig', ['publication' => $publication]); - - // Check if the directory exists, if not, create it - if (!file_exists('/tmp/mpdf')) { - mkdir('/tmp/mpdf', 0777, true); - } - - // Set permissions for the directory (ensure it's writable) - chmod('/tmp/mpdf', 0777); - - // Initialize mPDF - $mpdf = new Mpdf(['tempDir' => '/tmp/mpdf']); - - // Write HTML to PDF - $mpdf->WriteHTML($html); - - // Output to a file or directly to the browser - $filename = "{$publication['title']}.pdf"; - $mpdf->Output($filename, Destination::FILE); - - // Create the Publicaties folder and the Publication specific folder. - $this->fileService->createFolder(folderPath: 'Publicaties'); - $publicationFolder = $this->fileService->getPublicationFolderName( - publicationId: $publication['id'], - publicationTitle: $publication['title'] - ); - $this->fileService->createFolder(folderPath: "Publicaties/$publicationFolder"); - - // Save the uploaded file - $filePath = "Publicaties/$publicationFolder/$filename"; - $this->fileService->deleteFile(filePath: $filePath); - $created = $this->fileService->uploadFile( - content: file_get_contents(filename: $filename), - filePath: $filePath - ); - - // Todo: -// return $this->fileService->createShareLink(path: $filePath); - - if ($created === false) { -// return new JSONResponse(data: ['error' => "Failed to upload file. This file: $filePath might already exist"], statusCode: 400); - return false; - } - - return true; - } } diff --git a/lib/Service/FileService.php b/lib/Service/FileService.php index 68339656..ea1b6c6f 100644 --- a/lib/Service/FileService.php +++ b/lib/Service/FileService.php @@ -4,8 +4,13 @@ use DateTime; use Exception; +use OCP\Files\GenericFileException; +use OCP\Files\InvalidPathException; use OCP\Files\IRootFolder; +use OCP\Files\NotFoundException; +use OCP\Files\NotPermittedException; use OCP\IUserSession; +use OCP\Lock\LockedException; use OCP\Share\IManager; use Psr\Log\LoggerInterface; @@ -76,7 +81,7 @@ public function createShareLink(string $path, ?int $shareType = 3, ?int $permiss $userId = $currentUser ? $currentUser->getUID() : 'Guest'; try { $userFolder = $this->rootFolder->getUserFolder(userId: $userId); - } catch(\OCP\Files\NotPermittedException) { + } catch(NotPermittedException) { $this->logger->error("Can't create share link for $path because user (folder) couldn't be found"); return "User (folder) couldn't be found"; @@ -85,7 +90,7 @@ public function createShareLink(string $path, ?int $shareType = 3, ?int $permiss try { // Note: if we ever want to create share links for folders instead of files, just remove this try catch and only use setTarget, not setNodeId. $file = $userFolder->get(path: $path); - } catch(\OCP\Files\NotFoundException $e) { + } catch(NotFoundException $e) { $this->logger->error("Can't create share link for $path because file doesn't exist"); return 'File not found at '.$path; @@ -136,7 +141,7 @@ public function uploadFile(mixed $content, string $filePath): bool try { try { $userFolder->get(path: $filePath); - } catch(\OCP\Files\NotFoundException $e) { + } catch(NotFoundException $e) { $userFolder->newFile(path: $filePath); $file = $userFolder->get(path: $filePath); @@ -149,7 +154,7 @@ public function uploadFile(mixed $content, string $filePath): bool $this->logger->warning("File $filePath already exists."); return false; - } catch(\OCP\Files\NotPermittedException|\OCP\Files\GenericFileException|\OCP\Lock\LockedException $e) { + } catch(NotPermittedException|GenericFileException|LockedException $e) { $this->logger->error("Can't create file $filePath: " . $e->getMessage()); throw new Exception("Can't write to file $filePath"); @@ -179,13 +184,13 @@ public function deleteFile(string $filePath): bool $file->delete(); return true; - } catch(\OCP\Files\NotFoundException $e) { + } catch(NotFoundException $e) { // File does not exist. $this->logger->warning("File $filePath does not exist."); return false; } - } catch(\OCP\Files\NotPermittedException|\OCP\Files\InvalidPathException $e) { + } catch(NotPermittedException|InvalidPathException $e) { $this->logger->error("Can't delete file $filePath: " . $e->getMessage()); throw new Exception("Can't delete file $filePath"); @@ -212,7 +217,7 @@ public function createFolder(string $folderPath): bool try { try { $userFolder->get(path: $folderPath); - } catch(\OCP\Files\NotFoundException $e) { + } catch(NotFoundException $e) { $userFolder->newFolder(path: $folderPath); return true; @@ -222,7 +227,7 @@ public function createFolder(string $folderPath): bool $this->logger->info("This folder already exits $folderPath"); return false; - } catch(\OCP\Files\NotPermittedException $e) { + } catch(NotPermittedException $e) { $this->logger->error("Can't create folder $folderPath: " . $e->getMessage()); throw new Exception("Can\'t create folder $folderPath"); diff --git a/lib/Templates/publication.html.twig b/lib/Templates/publication.html.twig index 31e98837..dd83cc3f 100644 --- a/lib/Templates/publication.html.twig +++ b/lib/Templates/publication.html.twig @@ -1,10 +1,15 @@

Publicatie {{ publication.title }}

+{% if publication.catalogi|default %}

Catalogi: {{ publication.catalogi.title }}

+{% endif %} +{% if publication.metaData|default %}

Publicatie Type: {{ publication.metaData.title }}

-

Referentie: {{ publication.reference }}

-

Samenvatting: {{ publication.summary }}

-

Beschrijving: {{ publication.description }}

+{% endif %} +{% if publication.reference|default %}

Referentie: {{ publication.reference }}

{% endif %} +{% if publication.summary|default %}

Samenvatting: {{ publication.summary }}

{% endif %} +{% if publication.description|default %}

Beschrijving: {{ publication.description }}

{% endif %} +{% if publication.data|default %}