diff --git a/api/v1/announcements/PKPAnnouncementHandler.php b/api/v1/announcements/PKPAnnouncementHandler.php index 76113c41e0f..b6fee3df217 100644 --- a/api/v1/announcements/PKPAnnouncementHandler.php +++ b/api/v1/announcements/PKPAnnouncementHandler.php @@ -25,6 +25,7 @@ use PKP\announcement\Collector; use PKP\config\Config; use PKP\context\Context; +use PKP\core\exceptions\StoryTemporaryFileException; use PKP\db\DAORegistry; use PKP\facades\Locale; use PKP\handler\APIHandler; @@ -220,6 +221,19 @@ public function add($slimRequest, $response, $args) $announcement = Repo::announcement()->newDataObject($params); $announcementId = Repo::announcement()->add($announcement); + + try { + $announcement = Repo::announcement()->add($announcementId); + } catch (StoryTemporaryFileException $e) { + $announcement = Repo::announcement()->get($announcementId); + Repo::announcement()->delete($announcement); + return $response->withStatus(400)->withJson([ + 'image' => __('api.400.errorUploadingImage') + ]); + } + + $announcement = Repo::announcement()->get($announcementId); + $sendEmail = (bool) filter_var($params['sendEmail'], FILTER_VALIDATE_BOOLEAN); if ($context) { @@ -270,7 +284,14 @@ public function edit($slimRequest, $response, $args) return $response->withStatus(400)->withJson($errors); } - Repo::announcement()->edit($announcement, $params); + try { + Repo::announcement()->edit($announcement, $params); + } catch (StoryTemporaryFileException $e) { + Repo::announcement()->delete($announcement); + return $response->withStatus(400)->withJson([ + 'image' => __('api.400.errorUploadingImage') + ]); + } $announcement = Repo::announcement()->get($announcement->getId()); diff --git a/classes/announcement/Announcement.php b/classes/announcement/Announcement.php index 5779adb0e03..48a82bedc79 100644 --- a/classes/announcement/Announcement.php +++ b/classes/announcement/Announcement.php @@ -23,6 +23,9 @@ namespace PKP\announcement; +use APP\core\Application; +use APP\facades\Repo; +use APP\file\PublicFileManager; use PKP\db\DAORegistry; class Announcement extends \PKP\core\DataObject @@ -275,6 +278,67 @@ public function setDatetimePosted($datetimePosted) { $this->setData('datePosted', $datetimePosted); } + + /** + * Get the featured image data + */ + public function getImage(): ?array + { + return $this->getData('image'); + } + + /** + * Set the featured image data + */ + public function setImage(array $image): void + { + $this->setData('image', $image); + } + + /** + * Get the full URL to the image + * + * @param bool $withTimestamp Pass true to include a query argument with a timestamp + * of the date the image was uploaded in order to workaround cache bugs in browsers + */ + public function getImageUrl(bool $withTimestamp = true): string + { + $image = $this->getImage(); + + if (!$image) { + return ''; + } + + $filename = $image['uploadName']; + if ($withTimestamp) { + $filename .= '?'. strtotime($image['dateUploaded']); + } + + $publicFileManager = new PublicFileManager(); + + return join('/', [ + Application::get()->getRequest()->getBaseUrl(), + $this->getAssocId() + ? $publicFileManager->getContextFilesPath((int) $this->getAssocId()) + : $publicFileManager->getSiteFilesPath(), + Repo::announcement()->getImageSubdirectory(), + $filename + ]); + } + + /** + * Get the alt text for the image + */ + public function getImageAltText(): string + { + $image = $this->getImage(); + + if (!$image || !$image['altText']) { + return ''; + } + + return $image['altText']; + } } if (!PKP_STRICT_MODE) { diff --git a/classes/announcement/Repository.php b/classes/announcement/Repository.php index b2a86ce3920..d246ff6332f 100644 --- a/classes/announcement/Repository.php +++ b/classes/announcement/Repository.php @@ -13,8 +13,15 @@ namespace PKP\announcement; +use APP\core\Application; use APP\core\Request; +use APP\file\PublicFileManager; +use PKP\context\Context; use PKP\core\Core; +use PKP\core\exceptions\StoryTemporaryFileException; +use PKP\file\FileManager; +use PKP\file\TemporaryFile; +use PKP\file\TemporaryFileManager; use PKP\plugins\Hook; use PKP\services\PKPSchemaService; use PKP\validation\ValidatorFactory; @@ -128,6 +135,12 @@ public function add(Announcement $announcement): int { $announcement->setData('datePosted', Core::getCurrentDate()); $id = $this->dao->insert($announcement); + $announcement = $this->get($id); + + if ($announcement->getImage()) { + $this->handleImageUpload($announcement); + } + Hook::call('Announcement::add', [$announcement]); return $id; @@ -142,13 +155,28 @@ public function edit(Announcement $announcement, array $params) Hook::call('Announcement::edit', [$newAnnouncement, $announcement, $params]); $this->dao->update($newAnnouncement); + + if ($announcement->getImage()) { + $this->deleteImage($announcement); + } + + $image = $newAnnouncement->getImage(); + if ($image && $image['temporaryFileId']) { + $this->handleImageUpload($newAnnouncement); + } } /** @copydoc DAO::delete() */ public function delete(Announcement $announcement) { Hook::call('Announcement::delete::before', [$announcement]); + + if ($announcement->getImage()) { + $this->deleteImage($announcement); + } + $this->dao->delete($announcement); + Hook::call('Announcement::delete', [$announcement]); } @@ -161,4 +189,132 @@ public function deleteMany(Collector $collector) $this->delete($announcement); } } + + /** + * The subdirectory where announcement images are stored + */ + public function getImageSubdirectory(): string + { + return 'announcements'; + } + + /** + * Get the base URL for announcement file uploads + */ + public function getFileUploadBaseUrl(?Context $context = null): string + { + return join('/', [ + Application::get()->getRequest()->getPublicFilesUrl($context), + $this->getImageSubdirectory(), + ]); + } + + /** + * Handle image uploads + * + * @throws StoreTemporaryFileException Unable to store temporary file upload + */ + protected function handleImageUpload(Announcement $announcement): void + { + $image = $announcement->getImage(); + if ($image && $image['temporaryFileId']) { + $user = Application::get()->getRequest()->getUser(); + $image = $announcement->getImage(); + $temporaryFileManager = new TemporaryFileManager(); + $temporaryFile = $temporaryFileManager->getFile((int) $image['temporaryFileId'], $user?->getId()); + $filePath = $this->getImageSubdirectory() . '/' . $this->getImageFilename($announcement, $temporaryFile); + if ($this->storeTemporaryFile($temporaryFile, $filePath, $user?->getId(), $announcement)) { + $announcement->setImage( + $this->getImageData($announcement, $temporaryFile) + ); + $this->dao->update($announcement); + } else { + $this->delete($announcement); + throw new StoryTemporaryFileException($temporaryFile, $filePath, $user, $announcement); + } + } + } + + /** + * Store a temporary file upload in the public files directory + * + * @param string $newPath The new filename with the path relative to the public files directoruy + * @return bool Whether or not the operation was successful + */ + protected function storeTemporaryFile(TemporaryFile $temporaryFile, string $newPath, ?int $userId, Announcement $announcement): bool + { + $publicFileManager = new PublicFileManager(); + $temporaryFileManager = new TemporaryFileManager(); + + if ($announcement->getAssocId()) { + $result = $publicFileManager->copyContextFile( + $announcement->getAssocId(), + $temporaryFile->getFilePath(), + $newPath + ); + } else { + $result = $publicFileManager->copySiteFile( + $temporaryFile->getFilePath(), + $newPath + ); + } + + if (!$result) { + return false; + } + + $temporaryFileManager->deleteById($temporaryFile->getId(), $userId); + + return $result; + } + + /** + * Get the data array for a temporary file that has just been stored + * + * @return array Data about the image, like the upload name, alt text, and date uploaded + */ + protected function getImageData(Announcement $announcement, TemporaryFile $temporaryFile): array + { + $image = $announcement->getImage(); + + return [ + 'name' => $temporaryFile->getOriginalFileName(), + 'uploadName' => $this->getImageFilename($announcement, $temporaryFile), + 'dateUploaded' => Core::getCurrentDate(), + 'altText' => !empty($image['altText']) ? $image['altText'] : '', + ]; + } + + /** + * Get the filename of the image upload + */ + protected function getImageFilename(Announcement $announcement, TemporaryFile $temporaryFile): string + { + $fileManager = new FileManager(); + + return $announcement->getId() + . $fileManager->getImageExtension($temporaryFile->getFileType()); + } + + /** + * Delete the image related to announcement + */ + protected function deleteImage(Announcement $announcement): void + { + $image = $announcement->getImage(); + if ($image && $image['uploadName']) { + $publicFileManager = new PublicFileManager(); + $filesPath = $announcement->getAssocId() + ? $publicFileManager->getContextFilesPath($announcement->getAssocId()) + : $publicFileManager->getSiteFilesPath(); + + $publicFileManager->deleteByPath( + join('/', [ + $filesPath, + $this->getImageSubdirectory(), + $image['uploadName'], + ]) + ); + } + } } diff --git a/classes/components/forms/announcement/PKPAnnouncementForm.php b/classes/components/forms/announcement/PKPAnnouncementForm.php index d6a8f158a3a..744e46dfda9 100644 --- a/classes/components/forms/announcement/PKPAnnouncementForm.php +++ b/classes/components/forms/announcement/PKPAnnouncementForm.php @@ -20,6 +20,7 @@ use PKP\components\forms\FieldOptions; use PKP\components\forms\FieldRichTextarea; use PKP\components\forms\FieldText; +use PKP\components\forms\FieldUploadImage; use PKP\components\forms\FormComponent; use PKP\context\Context; use PKP\db\DAORegistry; @@ -42,7 +43,7 @@ class PKPAnnouncementForm extends FormComponent * @param string $action URL to submit the form to * @param array $locales Supported locales */ - public function __construct($action, $locales, ?Context $context = null) + public function __construct($action, $locales, string $baseUrl, string $temporaryFileApiUrl, ?Context $context = null) { $this->action = $action; $this->locales = $locales; @@ -68,6 +69,13 @@ public function __construct($action, $locales, ?Context $context = null) 'toolbar' => 'bold italic superscript subscript | link | blockquote bullist numlist', 'plugins' => 'paste,link,lists', ])) + ->addField(new FieldUploadImage('image', [ + 'label' => __('manager.image'), + 'baseUrl' => $baseUrl, + 'options' => [ + 'url' => $temporaryFileApiUrl, + ], + ])) ->addField(new FieldText('dateExpire', [ 'label' => __('manager.announcements.form.dateExpire'), 'description' => __('manager.announcements.form.dateExpireInstructions'), diff --git a/classes/core/exceptions/StoreTemporaryFileException.php b/classes/core/exceptions/StoreTemporaryFileException.php index 2185c5dd7f8..c313fc22474 100644 --- a/classes/core/exceptions/StoreTemporaryFileException.php +++ b/classes/core/exceptions/StoreTemporaryFileException.php @@ -36,4 +36,4 @@ public function __construct(public TemporaryFile $temporaryFile, public string $ } parent::__construct($message); } -} \ No newline at end of file +} diff --git a/classes/file/PKPPublicFileManager.php b/classes/file/PKPPublicFileManager.php index 48743b23513..30f87a75cdd 100644 --- a/classes/file/PKPPublicFileManager.php +++ b/classes/file/PKPPublicFileManager.php @@ -80,6 +80,19 @@ public function uploadSiteFile($fileName, $destFileName) return $this->uploadFile($fileName, $this->getSiteFilesPath() . '/' . $destFileName); } + /** + * Copy a file to the site's public directory. + * + * @param string $sourceFile the source of the file to copy + * @param string $destFileName the destination file name + * + * @return bool + */ + public function copySiteFile($sourceFile, $destFileName) + { + return $this->copyFile($sourceFile, $this->getSiteFilesPath() . '/' . $destFileName); + } + /** * Copy a file to a context's public directory. * diff --git a/locale/en/manager.po b/locale/en/manager.po index ad15e4a872c..c462ae781fd 100644 --- a/locale/en/manager.po +++ b/locale/en/manager.po @@ -580,6 +580,9 @@ msgstr "No users were found." msgid "manager.groups.title" msgstr "Title" +msgid "manager.image" +msgstr "Image" + msgid "manager.importExport" msgstr "Import/Export Data" diff --git a/pages/admin/AdminHandler.php b/pages/admin/AdminHandler.php index 39fe300ec8b..0432608f8be 100644 --- a/pages/admin/AdminHandler.php +++ b/pages/admin/AdminHandler.php @@ -206,7 +206,7 @@ public function settings($args, $request) $siteStatisticsForm = new \PKP\components\forms\site\PKPSiteStatisticsForm($apiUrl, $locales, $site); $highlightsListPanel = $this->getHighlightsListPanel(); $announcementSettingsForm = new PKPAnnouncementSettingsForm($apiUrl, $locales, $site); - $announcementsForm = new PKPAnnouncementForm($announcementsApiUrl, $locales); + $announcementsForm = new PKPAnnouncementForm($announcementsApiUrl, $locales, Repo::announcement()->getFileUploadBaseUrl(), $temporaryFileApiUrl); $announcementsListPanel = $this->getAnnouncementsListPanel($announcementsApiUrl, $announcementsForm); $templateMgr = TemplateManager::getManager($request); diff --git a/pages/management/ManagementHandler.php b/pages/management/ManagementHandler.php index 607496ed39c..9fd690faac9 100644 --- a/pages/management/ManagementHandler.php +++ b/pages/management/ManagementHandler.php @@ -23,6 +23,7 @@ use APP\file\PublicFileManager; use APP\handler\Handler; use APP\template\TemplateManager; +use PKP\components\forms\announcement\PKPAnnouncementForm; use PKP\components\forms\context\PKPDoiRegistrationSettingsForm; use PKP\components\forms\context\PKPEmailSetupForm; use PKP\components\forms\context\PKPInformationForm; @@ -357,7 +358,13 @@ public function announcements($args, $request) $locales = $this->getSupportedFormLocales($context); - $announcementForm = new \PKP\components\forms\announcement\PKPAnnouncementForm($apiUrl, $locales, $request->getContext()); + $announcementForm = new PKPAnnouncementForm( + $apiUrl, + $locales, + Repo::announcement()->getFileUploadBaseUrl($context), + $this->getTemporaryFileApiUrl($context), + $request->getContext() + ); $collector = Repo::announcement() ->getCollector() diff --git a/schemas/announcement.json b/schemas/announcement.json index 6a686b044ae..349b136d680 100644 --- a/schemas/announcement.json +++ b/schemas/announcement.json @@ -68,6 +68,32 @@ "readOnly": true, "apiSummary": true }, + "image": { + "type": "object", + "description": "The image to show with this announcement.", + "apiSummary": true, + "validation": [ + "nullable" + ], + "properties": { + "temporaryFileId": { + "type": "integer", + "writeOnly": true + }, + "name": { + "type": "string" + }, + "uploadName": { + "type": "string" + }, + "dateUploaded": { + "type": "string" + }, + "altText": { + "type": "string" + } + } + }, "title": { "type": "string", "multilingual": true,