From 3e13765f62f6e4203e0d7ec344835ad12a0c2806 Mon Sep 17 00:00:00 2001 From: Diogo Peralta Cordeiro Date: Sun, 26 Dec 2021 03:44:14 +0000 Subject: [PATCH] [CORE][SCOPE] Implement basic visibility in feeds --- components/Feed/Feed.php | 2 +- components/Feed/Util/FeedController.php | 47 +++++++++++++++-- components/Posting/Posting.php | 67 +++++++++++++++++-------- src/Controller/Feeds.php | 8 --- src/Core/Controller.php | 2 +- src/Core/VisibilityScope.php | 14 +++--- src/Entity/Note.php | 4 +- src/Util/Bitmap.php | 5 ++ 8 files changed, 104 insertions(+), 45 deletions(-) diff --git a/components/Feed/Feed.php b/components/Feed/Feed.php index 1f69ad2b0d..bb8a6bf6b9 100644 --- a/components/Feed/Feed.php +++ b/components/Feed/Feed.php @@ -67,7 +67,7 @@ class Feed extends Component } $actors = $actor_qb->getQuery()->execute(); - // TODO: Enforce scoping on the notes before returning + // N.B.: Scope is only enforced at FeedController level return ['notes' => $notes ?? null, 'actors' => $actors ?? null]; } diff --git a/components/Feed/Util/FeedController.php b/components/Feed/Util/FeedController.php index 3acb01dd28..7ac7afbcce 100644 --- a/components/Feed/Util/FeedController.php +++ b/components/Feed/Util/FeedController.php @@ -34,25 +34,66 @@ namespace Component\Feed\Util; use App\Core\Controller; use App\Core\Event; +use App\Core\Log; +use App\Core\VisibilityScope; +use App\Entity\Actor; use App\Util\Common; +use function array_key_exists; abstract class FeedController extends Controller { /** - * Post process the result of a feed controller, to remove any + * Post-processing of the result of a feed controller, to remove any * notes or actors the user specified, as well as format the raw * list of notes into a usable format */ - public static function post_process(array $result): array + public static function postProcess(array $result): array { $actor = Common::actor(); - if (\array_key_exists('notes', $result)) { + if (array_key_exists('notes', $result)) { $notes = $result['notes']; + self::enforceScope($notes, $actor); Event::handle('FilterNoteList', [$actor, &$notes, $result['request']]); Event::handle('FormatNoteList', [$notes, &$result['notes']]); } return $result; } + + private static function enforceScope(array &$notes, ?Actor $actor): void + { + $filtered_notes = []; + foreach($notes as $note) { + switch($note->getScope()) { + case VisibilityScope::LOCAL: // The controller handles it if private + case VisibilityScope::PUBLIC: + $filtered_notes[] = $note; + break; + case VisibilityScope::ADDRESSEE: + // If the actor is logged in and + if (!is_null($actor) && + ( + // Is either the author Or + $note->getActorId() == $actor->getId() || + // one of the targets + in_array($actor->getId(), $note->getNotificationTargetIds()) + )) { + $filtered_notes[] = $note; + } + break; + case VisibilityScope::GROUP: + // Only for the group to see + break; + case VisibilityScope::COLLECTION: // no break + case VisibilityScope::MESSAGE: + // Only for the collection to see (they will only find it in their notifications) + break; + default: + Log::warning("Unknown scope found: {$note->getScope()}."); + } + } + // Replace notes with filtered ones I/O + $notes = $filtered_notes; + } } diff --git a/components/Posting/Posting.php b/components/Posting/Posting.php index 12c7d15b83..fffecfcdf8 100644 --- a/components/Posting/Posting.php +++ b/components/Posting/Posting.php @@ -28,6 +28,8 @@ use App\Core\Entity; use App\Core\Event; use App\Core\Form; use App\Core\GSFile; +use App\Core\VisibilityScope; +use App\Util\Exception\BugFoundException; use function App\Core\I18n\_m; use App\Core\Modules\Component; use App\Core\Router\Router; @@ -56,6 +58,8 @@ use Symfony\Component\HttpFoundation\File\Exception\FormSizeFileException; use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Validator\Constraints\Length; +use function count; +use function is_null; class Posting extends Component { @@ -69,7 +73,7 @@ class Posting extends Component */ public function onAppendRightPostingBlock(Request $request, array &$res): bool { - if (\is_null($user = Common::user())) { + if (is_null($user = Common::user())) { return Event::next; } @@ -99,12 +103,17 @@ class Posting extends Component $form_params[] = ['in', ChoiceType::class, ['label' => _m('In:'), 'multiple' => false, 'expanded' => false, 'choices' => $in_targets]]; } - $form_params[] = ['visibility', ChoiceType::class, ['label' => _m('Visibility:'), 'multiple' => false, 'expanded' => false, 'data' => 'public', 'choices' => [_m('Public') => 'public', _m('Instance') => 'instance', _m('Private') => 'private']]]; + // TODO: if in group page, add GROUP visibility to the choices. + $form_params[] = ['visibility', ChoiceType::class, ['label' => _m('Visibility:'), 'multiple' => false, 'expanded' => false, 'data' => 'public', 'choices' => [ + _m('Public') => VisibilityScope::PUBLIC, + _m('Local') => VisibilityScope::LOCAL, + _m('Addressee') => VisibilityScope::ADDRESSEE + ]]]; $form_params[] = ['content', TextareaType::class, ['label' => _m('Content:'), 'data' => $initial_content, 'attr' => ['placeholder' => _m($placeholder)], 'constraints' => [new Length(['max' => Common::config('site', 'text_limit')])]]]; $form_params[] = ['attachments', FileType::class, ['label' => _m('Attachments:'), 'multiple' => true, 'required' => false, 'invalid_message' => _m('Attachment not valid.')]]; $form_params[] = FormFields::language($actor, $context_actor, label: _m('Note language'), help: _m('The selected language will be federated and added as a lang attribute, preferred language can be set up in settings')); - if (\count($available_content_types) > 1) { + if (count($available_content_types) > 1) { $form_params[] = ['content_type', ChoiceType::class, [ 'label' => _m('Text format:'), 'multiple' => false, 'expanded' => false, @@ -126,7 +135,11 @@ class Posting extends Component $data = $form->getData(); if (empty($data['content']) && empty($data['attachments'])) { // TODO Display error: At least one of `content` and `attachments` must be provided - throw new ClientException(_m('You must enter content or provide at least one attachment to post a note')); + throw new ClientException(_m('You must enter content or provide at least one attachment to post a note.')); + } + + if (!VisibilityScope::isValue($data['visibility'])) { + throw new ClientException(_m('You have selected an impossible visibility.')); } $content_type = $data['content_type'] ?? $available_content_types[array_key_first($available_content_types)]; @@ -134,12 +147,13 @@ class Posting extends Component Event::handle('AddExtraArgsToNoteContent', [$request, $actor, $data, &$extra_args, $form_params, $form]); self::storeLocalNote( - $user->getActor(), - $data['content'], - $content_type, - $data['language'], - $data['attachments'], + actor: $user->getActor(), + content: $data['content'], + content_type: $content_type, + language: $data['language'], + scope: $data['visibility'], target: $data['in'] ?? null, + attachments: $data['attachments'], process_note_content_extra_args: $extra_args, ); @@ -160,26 +174,34 @@ class Posting extends Component * $actor_id, possibly as a reply to note $reply_to and with flag * $is_local. Sanitizes $content and $attachments * - * @param array $attachments Array of UploadedFile to be stored as GSFiles associated to this note - * @param array $processed_attachments Array of [Attachment, Attachment's name] to be associated to this $actor and Note + * @param Actor $actor + * @param string|null $content + * @param string $content_type + * @param string|null $language + * @param int|null $scope + * @param string|null $target + * @param array $attachments Array of UploadedFile to be stored as GSFiles associated to this note + * @param array $processed_attachments Array of [Attachment, Attachment's name] to be associated to this $actor and Note * @param array $process_note_content_extra_args Extra arguments for the event ProcessNoteContent * + * @return Note * @throws ClientException * @throws DuplicateFoundException * @throws ServerException - * - * @return Entity|mixed + * @throws BugFoundException */ public static function storeLocalNote( - Actor $actor, + Actor $actor, ?string $content, - string $content_type, + string $content_type, ?string $language = null, - array $attachments = [], - array $processed_attachments = [], + ?int $scope = null, ?string $target = null, - array $process_note_content_extra_args = [], - ) { + array $attachments = [], + array $processed_attachments = [], + array $process_note_content_extra_args = [], + ): Note { + $scope ??= VisibilityScope::PUBLIC; // TODO: If site is private, default to LOCAL $rendered = null; $mentions = []; if (!empty($content)) { @@ -191,8 +213,9 @@ class Posting extends Component 'content' => $content, 'content_type' => $content_type, 'rendered' => $rendered, - 'language_id' => !\is_null($language) ? Language::getByLocale($language)->getId() : null, + 'language_id' => !is_null($language) ? Language::getByLocale($language)->getId() : null, 'is_local' => true, + 'scope' => $scope, ]); /** @var UploadedFile[] $attachments */ @@ -238,7 +261,7 @@ class Posting extends Component ]); DB::persist($activity); - if (!\is_null($target)) { + if (!is_null($target)) { switch ($target[0]) { case '!': $mentions[] = [ @@ -254,7 +277,7 @@ class Posting extends Component $mentioned = []; foreach (F\unique(F\flat_map($mentions, fn (array $m) => $m['mentioned'] ?? []), fn (Actor $a) => $a->getId()) as $m) { - if (!\is_null($m)) { + if (!is_null($m)) { $mentioned[] = $m->getId(); if ($m->isGroup()) { diff --git a/src/Controller/Feeds.php b/src/Controller/Feeds.php index ed527a3341..00f9116df5 100644 --- a/src/Controller/Feeds.php +++ b/src/Controller/Feeds.php @@ -44,12 +44,6 @@ use Symfony\Component\HttpFoundation\Request; class Feeds extends FeedController { - // Can't have constants inside herestring - private $public_scope = VisibilityScope::PUBLIC; - private $instance_scope = VisibilityScope::PUBLIC | VisibilityScope::SITE; - private $message_scope = VisibilityScope::MESSAGE; - private $subscriber_scope = VisibilityScope::PUBLIC | VisibilityScope::SUBSCRIBER; - /** * The Planet feed represents every local post. Which is what this instance has to share with the universe. */ @@ -63,7 +57,6 @@ class Feeds extends FeedController return [ '_template' => 'feed/feed.html.twig', 'page_title' => _m(\is_null(Common::user()) ? 'Feed' : 'Planet'), - 'should_format' => true, 'notes' => $data['notes'], ]; } @@ -83,7 +76,6 @@ class Feeds extends FeedController return [ '_template' => 'feed/feed.html.twig', 'page_title' => _m('Home'), - 'should_format' => true, 'notes' => $data['notes'], ]; } diff --git a/src/Core/Controller.php b/src/Core/Controller.php index f98bbca71f..8ef512d73b 100644 --- a/src/Core/Controller.php +++ b/src/Core/Controller.php @@ -138,7 +138,7 @@ abstract class Controller extends AbstractController implements EventSubscriberI // XXX: Could we do this differently? if (is_subclass_of($controller, FeedController::class)) { - $this->vars = FeedController::post_process($this->vars); + $this->vars = FeedController::postProcess($this->vars); } // Respond in the most preferred acceptable content type diff --git a/src/Core/VisibilityScope.php b/src/Core/VisibilityScope.php index c0919acecb..780c810d3a 100644 --- a/src/Core/VisibilityScope.php +++ b/src/Core/VisibilityScope.php @@ -25,12 +25,10 @@ use App\Util\Bitmap; class VisibilityScope extends Bitmap { - public const PUBLIC = 1; - public const SITE = 2; - public const ADDRESSEE = 4; - public const GROUP = 8; - public const SUBSCRIBER = 16; - public const MESSAGE = 32; - - public static int $instance_scope = self::PUBLIC | self::SITE; + public const PUBLIC = 1; // Can be shown everywhere (default) + public const LOCAL = 2; // Non-public and non-federated (default in private sites) + public const ADDRESSEE = 4; // Only if the actor is the author or one of the targets + public const GROUP = 8; // Only in the Group feed + public const COLLECTION = 16; // Only for the collection to see (same as addressee but not available in feeds, notifications only) + public const MESSAGE = 32; // Direct Message (same as Collection, but also with dedicated plugin) } diff --git a/src/Entity/Note.php b/src/Entity/Note.php index 2011027ef6..83382eefce 100644 --- a/src/Entity/Note.php +++ b/src/Entity/Note.php @@ -58,7 +58,7 @@ class Note extends Entity private ?int $reply_to; private bool $is_local; private ?string $source; - private int $scope = 1; + private int $scope = VisibilityScope::PUBLIC; private ?string $url; private ?int $language_id; private DateTimeInterface $created; @@ -468,7 +468,7 @@ class Note extends Entity 'reply_to' => ['type' => 'int', 'foreign key' => true, 'target' => 'Note.id', 'multiplicity' => 'one to one', 'description' => 'note replied to, null if root of a conversation'], 'is_local' => ['type' => 'bool', 'not null' => true, 'description' => 'was this note generated by a local actor'], 'source' => ['type' => 'varchar', 'foreign key' => true, 'length' => 32, 'target' => 'NoteSource.code', 'multiplicity' => 'many to one', 'description' => 'fkey to source of note, like "web", "im", or "clientname"'], - 'scope' => ['type' => 'int', 'not null' => true, 'default' => VisibilityScope::PUBLIC, 'description' => 'bit map for distribution scope; 0 = everywhere; 1 = this server only; 2 = addressees; 4 = groups; 8 = subscribers; 16 = messages; null = default'], + 'scope' => ['type' => 'int', 'not null' => true, 'default' => VisibilityScope::PUBLIC, 'description' => 'bit map for distribution scope; 1 = everywhere; 2 = this server only; 4 = addressees; 8 = groups; 16 = collection; 32 = messages'], 'url' => ['type' => 'text', 'description' => 'Permalink to Note'], 'language_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Language.id', 'multiplicity' => 'one to many', 'description' => 'The language for this note'], 'created' => ['type' => 'datetime', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was created'], diff --git a/src/Util/Bitmap.php b/src/Util/Bitmap.php index 057e980d31..a4c15d65aa 100644 --- a/src/Util/Bitmap.php +++ b/src/Util/Bitmap.php @@ -86,4 +86,9 @@ abstract class Bitmap { return self::_to($r, false); } + + public static function isValue(int $value): bool + { + return in_array($value, (new ReflectionClass(static::class))->getConstants()); + } }