From 99c6d0d8d7476a1112d5afb4194bf04a2b02a0bb Mon Sep 17 00:00:00 2001 From: David Sevilla Martin Date: Mon, 20 May 2024 19:20:14 -0400 Subject: [PATCH] Add permissions and settings to disable & prevent image uploads + bring back image URL inputs --- extend.php | 1 + js/src/admin/index.ts | 14 ++++ js/src/forum/components/PollForm.tsx | 68 ++++++++++++++++--- .../components/UploadPollImageButton.tsx | 23 ++++++- js/src/forum/models/Poll.ts | 4 ++ js/src/forum/models/PollOption.ts | 4 ++ resources/less/forum.less | 6 ++ resources/locale/en.yml | 6 +- src/Api/AddForumAttributes.php | 14 ++++ .../Controllers/UploadPollImageController.php | 23 ++++++- .../UploadPollOptionImageController.php | 26 +++++-- src/Api/Serializers/PollOptionSerializer.php | 4 ++ src/Api/Serializers/PollSerializer.php | 9 +++ src/Validators/PollValidator.php | 1 + 14 files changed, 184 insertions(+), 19 deletions(-) diff --git a/extend.php b/extend.php index 3724d4a9..fe84e377 100755 --- a/extend.php +++ b/extend.php @@ -110,6 +110,7 @@ ->default('fof-polls.enableGlobalPolls', false) ->default('fof-polls.image_height', 250) ->default('fof-polls.image_width', 250) + ->default('fof-polls.allowImageUploads', false) ->serializeToForum('globalPollsEnabled', 'fof-polls.enableGlobalPolls', 'boolval') ->serializeToForum('allowPollOptionImage', 'fof-polls.allowOptionImage', 'boolval') ->serializeToForum('pollMaxOptions', 'fof-polls.maxOptions', 'intval') diff --git a/js/src/admin/index.ts b/js/src/admin/index.ts index 2e7107fa..d47155dc 100755 --- a/js/src/admin/index.ts +++ b/js/src/admin/index.ts @@ -20,6 +20,12 @@ app.initializers.add('fof/polls', () => { label: app.translator.trans('fof-polls.admin.settings.max_options'), min: 2, }) + .registerSetting({ + setting: 'fof-polls.allowImageUploads', + type: 'switch', + label: app.translator.trans('fof-polls.admin.settings.allow_image_uploads'), + help: app.translator.trans('fof-polls.admin.settings.allow_image_uploads_help'), + }) .registerSetting({ setting: 'fof-polls.enableGlobalPolls', type: 'boolean', @@ -77,6 +83,14 @@ app.initializers.add('fof/polls', () => { }, 'start' ) + .registerPermission( + { + icon: 'fas fa-image', + label: app.translator.trans('fof-polls.admin.permissions.upload_images'), + permission: 'uploadPollImages', + }, + 'start' + ) .registerPermission( { icon: 'fas fa-poll', diff --git a/js/src/forum/components/PollForm.tsx b/js/src/forum/components/PollForm.tsx index e2733182..68f6da0f 100644 --- a/js/src/forum/components/PollForm.tsx +++ b/js/src/forum/components/PollForm.tsx @@ -100,18 +100,32 @@ export default class PollForm extends Component { 95 ); + const hasImage = this.image(); + items.add( 'poll_image',
-

{app.translator.trans('fof-polls.forum.modal.poll_image.help')}

- - + {this.uploadConditional( + this.state.poll?.isImageUpload(), + <> +

{app.translator.trans('fof-polls.forum.modal.poll_image.help')}

+ + + , + + )}
, 90 ); - if (this.image()) { + if (hasImage) { items.add( 'poll_image_alt',
@@ -262,8 +276,11 @@ export default class PollForm extends Component { displayOptions(): ItemList { const items = new ItemList(); + const canUpload = app.forum.attribute('canUploadPollImages'); this.options.forEach((option, i) => { + const imgFunc = this.optionImageUrls[i]; + items.add( 'option-' + i,
@@ -277,10 +294,23 @@ export default class PollForm extends Component { /> {app.forum.attribute('allowPollOptionImage') && (
- -

{app.translator.trans('fof-polls.forum.modal.poll_option_image.help')}

- - + {this.uploadConditional( + option?.isImageUpload(), + <> + +

{app.translator.trans('fof-polls.forum.modal.poll_option_image.help')}

+ + + , + + + )}
)} @@ -400,4 +430,26 @@ export default class PollForm extends Component { pollOptionImageUploadSuccess(index: number, fileName: string | null | undefined): void { this.optionImageUrls[index] = Stream(fileName); } + + uploadConditional(isUpload: boolean, ifCanUpload: JSX.Element, ifCannotUpload: JSX.Element) { + const canUpload = app.forum.attribute('canUploadPollImages'); + const canUploadNow = this.state.poll?.exists || (app.forum.attribute('canStartPolls') && app.forum.attribute('canStartGlobalPolls')); + + // if can upload OR image is already uploaded + if (canUpload || isUpload) { + // may not have enough permissions to upload before creating poll + if (!canUploadNow && !isUpload) { + return ( + <> + {ifCannotUpload} +

{app.translator.trans('fof-polls.forum.modal.poll_image.later_help')}

+ + ); + } + + return ifCanUpload; + } + + return ifCannotUpload; + } } diff --git a/js/src/forum/components/UploadPollImageButton.tsx b/js/src/forum/components/UploadPollImageButton.tsx index 4e9abe9d..9f68d64c 100644 --- a/js/src/forum/components/UploadPollImageButton.tsx +++ b/js/src/forum/components/UploadPollImageButton.tsx @@ -1,7 +1,7 @@ import app from 'flarum/forum/app'; import Button, { IButtonAttrs } from 'flarum/common/components/Button'; import classList from 'flarum/common/utils/classList'; -import type Mithril from 'mithril'; +import Mithril from 'mithril'; import Poll from '../models/Poll'; import PollOption from '../models/PollOption'; @@ -31,6 +31,7 @@ export default class UploadPollImageButton extends Button('canUploadPollImages'); if (imageUrl) { this.attrs.onclick = this.remove.bind(this); @@ -40,14 +41,19 @@ export default class UploadPollImageButton extends Button

-

{super.view({ ...vnode, children: app.translator.trans('fof-polls.forum.upload_image.remove_button') })}

+

+ {super.view({ + ...vnode, + children: app.translator.trans('fof-polls.forum.upload_image.remove_button'), + })} +

); } else { this.attrs.onclick = this.upload.bind(this); } - return super.view({ ...vnode, children: app.translator.trans('fof-polls.forum.upload_image.upload_button') }); + return canUpload && super.view({ ...vnode, children: app.translator.trans('fof-polls.forum.upload_image.upload_button') }); } /** @@ -98,6 +104,17 @@ export default class UploadPollImageButton extends Button { + if (this.attrs.poll?.exists) { + this.attrs.poll.pushAttributes({ image: null, imageUrl: null, isImageUpload: false }); + } + + if (this.attrs.option?.exists) { + this.attrs.option.pushAttributes({ imageUrl: false }); + } + + return upload; + }) .then(this.success.bind(this), this.failure.bind(this)); } diff --git a/js/src/forum/models/Poll.ts b/js/src/forum/models/Poll.ts index 22777852..97f95370 100755 --- a/js/src/forum/models/Poll.ts +++ b/js/src/forum/models/Poll.ts @@ -26,6 +26,10 @@ export default class Poll extends Model { return Model.attribute('imageAlt').call(this); } + isImageUpload() { + return Model.attribute('isImageUpload').call(this); + } + hasEnded() { return Model.attribute('hasEnded').call(this); } diff --git a/js/src/forum/models/PollOption.ts b/js/src/forum/models/PollOption.ts index 031dca36..21f3c85f 100755 --- a/js/src/forum/models/PollOption.ts +++ b/js/src/forum/models/PollOption.ts @@ -11,6 +11,10 @@ export default class PollOption extends Model { return Model.attribute('imageUrl').call(this); } + isImageUpload() { + return Model.attribute('isImageUpload').call(this); + } + voteCount() { return Model.attribute('voteCount').call(this); } diff --git a/resources/less/forum.less b/resources/less/forum.less index 6f378898..217d552e 100755 --- a/resources/less/forum.less +++ b/resources/less/forum.less @@ -58,6 +58,12 @@ fieldset { flex-grow: 1; } + + .Poll-answer-image { + img { + max-width: 100%; + } + } } .PollModal--button { diff --git a/resources/locale/en.yml b/resources/locale/en.yml index c4797ef1..8c093d7d 100755 --- a/resources/locale/en.yml +++ b/resources/locale/en.yml @@ -2,6 +2,8 @@ fof-polls: admin: settings: allow_option_image: Allow an image URL to be provided for each poll option + allow_image_uploads: Allow image uploads for polls and their options + allow_image_uploads_help: If enabled, users will be able to upload images alongside also providing an image URL (if enabled for options, always enabled for the poll itself). enable_global_polls: Enable global polls enable_global_polls_help: Global polls are not tied to a specific post, and may be accessed from a dedicated polls page. max_options: Maximum number of options per poll @@ -15,6 +17,7 @@ fof-polls: start_global: Start a global poll self_edit: Edit created polls (requires post edit permission) self_post_edit: Edit *all* polls on own posts (requires post edit permission) + upload_images: Allow users to upload images for polls vote: Vote on polls change_vote: Change vote moderate: Edit & remove polls @@ -86,7 +89,7 @@ fof-polls: hide_votes_label: Hide votes until poll ends allow_change_vote_label: Allow users to change their vote question_placeholder: Question - subtitle_placeholder: Subtitle/Description (Optional) + subtitle_placeholder: Subtitle/Description (optional) submit: Submit delete: => core.ref.delete error: => core.lib.error.generic_message @@ -95,6 +98,7 @@ fof-polls: help: Upload an image to be displayed alongside the poll (optional). alt_label: Image Alt Text alt_help_text: This text is required when an image is set, it will be displayed if the image fails to load. + later_help: You will be able to upload an image after creating the poll. poll_option_image: label: Poll Answer Image help: Upload an image to be displayed alongside the poll answer (optional). diff --git a/src/Api/AddForumAttributes.php b/src/Api/AddForumAttributes.php index 6e2db43c..35f62e8b 100644 --- a/src/Api/AddForumAttributes.php +++ b/src/Api/AddForumAttributes.php @@ -12,14 +12,28 @@ namespace FoF\Polls\Api; use Flarum\Api\Serializer\ForumSerializer; +use Flarum\Settings\SettingsRepositoryInterface; class AddForumAttributes { + /** + * @var SettingsRepositoryInterface + */ + protected $settings; + + public function __construct(SettingsRepositoryInterface $settings) + { + $this->settings = $settings; + } + public function __invoke(ForumSerializer $serializer, array $model, array $attributes): array { $attributes['canStartPolls'] = $serializer->getActor()->can('discussion.polls.start'); $attributes['canStartGlobalPolls'] = $serializer->getActor()->can('startGlobalPoll'); + $areUploadsEnabled = (bool) $this->settings->get('fof-polls.allowImageUploads'); + $attributes['canUploadPollImages'] = $areUploadsEnabled && $serializer->getActor()->can('uploadPollImages'); + return $attributes; } } diff --git a/src/Api/Controllers/UploadPollImageController.php b/src/Api/Controllers/UploadPollImageController.php index 5d8a166a..65cc26a3 100644 --- a/src/Api/Controllers/UploadPollImageController.php +++ b/src/Api/Controllers/UploadPollImageController.php @@ -71,8 +71,25 @@ public function handle(ServerRequestInterface $request): ResponseInterface $actor = RequestUtil::getActor($request); $pollId = Arr::get($request->getQueryParams(), 'pollId'); - if ($actor->cannot('startPoll') || $actor->cannot('startGlobalPoll')) { - throw new PermissionDeniedException('You do not have permission to upload poll images'); + $areUploadsAllowed = (bool) $this->settings->get('fof-polls.allowImageUploads'); + + if (!$areUploadsAllowed) { + throw new PermissionDeniedException(); + } + + $actor->assertCan('uploadPollImages'); + + // if a poll ID is given, check that the user can edit that poll (and thus upload images!) + if ($pollId) { + $poll = Poll::findOrFail($pollId); + + $actor->assertCan('edit', $poll); + } else { + $poll = null; + + // we don't know whether this image is for a global or a regular poll -- image upload can be done before poll creation + $actor->assertCan('startPoll'); + $actor->assertCan('startGlobalPoll'); } $file = Arr::get($request->getUploadedFiles(), $this->filenamePrefix); @@ -83,7 +100,7 @@ public function handle(ServerRequestInterface $request): ResponseInterface $this->uploadDir->put($uploadName, $encodedImage); - if ($pollId && $poll = Poll::find($pollId)) { + if ($pollId && $poll) { $poll->image = $uploadName; $poll->save(); } diff --git a/src/Api/Controllers/UploadPollOptionImageController.php b/src/Api/Controllers/UploadPollOptionImageController.php index 2333f5bc..b0d87dc2 100644 --- a/src/Api/Controllers/UploadPollOptionImageController.php +++ b/src/Api/Controllers/UploadPollOptionImageController.php @@ -27,19 +27,37 @@ public function handle(ServerRequestInterface $request): ResponseInterface $actor = RequestUtil::getActor($request); $optionId = Arr::get($request->getQueryParams(), 'optionId'); - if ($actor->cannot('startPoll') || $actor->cannot('startGlobalPoll')) { - throw new PermissionDeniedException('You do not have permission to upload poll option images'); + $areUploadsAllowed = (bool) $this->settings->get('fof-polls.allowImageUploads'); + + if (!$areUploadsAllowed) { + throw new PermissionDeniedException(); + } + + $actor->assertCan('uploadPollImages'); + + // if an option ID is given, check that the user can edit that poll (and thus upload images!) + if ($optionId) { + $option = PollOption::findOrFail($optionId); + $poll = $option->poll; + + $actor->assertCan('edit', $poll); + } else { + $option = null; + + // we don't know whether this image is for a global or a regular poll -- image upload can be done before poll creation + $actor->assertCan('startPoll'); + $actor->assertCan('startGlobalPoll'); } $file = Arr::get($request->getUploadedFiles(), $this->filenamePrefix); - $uploadName = $uploadName = $this->uploadName(); + $uploadName = $this->uploadName(); $encodedImage = $this->makeImage($file, $uploadName); $this->uploadDir->put($uploadName, $encodedImage); - if ($optionId && $option = PollOption::find($optionId)) { + if ($option) { $option->image_url = $uploadName; $option->save(); } diff --git a/src/Api/Serializers/PollOptionSerializer.php b/src/Api/Serializers/PollOptionSerializer.php index 1703bd06..9bf8f980 100755 --- a/src/Api/Serializers/PollOptionSerializer.php +++ b/src/Api/Serializers/PollOptionSerializer.php @@ -40,6 +40,10 @@ protected function getDefaultAttributes($option) 'voteCount' => $this->actor->can('seeVoteCount', $option->poll) ? (int) $option->vote_count : null, ]; + if ($attributes['imageUrl']) { + $attributes['isImageUpload'] = !filter_var($option->image_url, FILTER_VALIDATE_URL); + } + return $attributes; } diff --git a/src/Api/Serializers/PollSerializer.php b/src/Api/Serializers/PollSerializer.php index fcc8e637..83bae030 100755 --- a/src/Api/Serializers/PollSerializer.php +++ b/src/Api/Serializers/PollSerializer.php @@ -64,6 +64,10 @@ protected function getDefaultAttributes($poll) $attributes['allowChangeVote'] = $poll->allow_change_vote; } + if ($attributes['image']) { + $attributes['isImageUpload'] = !filter_var($poll->image, FILTER_VALIDATE_URL); + } + return $attributes; } @@ -107,6 +111,11 @@ protected function getImageUrl(Poll $poll): ?string return null; } + // if image is a URL, return it + if (filter_var($poll->image, FILTER_VALIDATE_URL)) { + return $poll->image; + } + /** @var Cloud */ $fileSystem = resolve(Factory::class)->disk('fof-polls'); diff --git a/src/Validators/PollValidator.php b/src/Validators/PollValidator.php index d663c4f1..e2253fde 100755 --- a/src/Validators/PollValidator.php +++ b/src/Validators/PollValidator.php @@ -22,6 +22,7 @@ protected function getRules() return [ 'question' => 'required', 'publicPoll' => 'nullable|boolean', + 'image' => 'nullable|url', 'endDate' => [ 'nullable', // max of 'timestamp' SQL column is 2038-01-18