[CORE][SCOPE] Implement basic visibility in feeds

This commit is contained in:
Diogo Peralta Cordeiro 2021-12-26 03:44:14 +00:00
parent d4bc1d097d
commit 3e13765f62
Signed by: diogo
GPG Key ID: 18D2D35001FBFAB0
8 changed files with 104 additions and 45 deletions

View File

@ -67,7 +67,7 @@ class Feed extends Component
} }
$actors = $actor_qb->getQuery()->execute(); $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]; return ['notes' => $notes ?? null, 'actors' => $actors ?? null];
} }

View File

@ -34,25 +34,66 @@ namespace Component\Feed\Util;
use App\Core\Controller; use App\Core\Controller;
use App\Core\Event; use App\Core\Event;
use App\Core\Log;
use App\Core\VisibilityScope;
use App\Entity\Actor;
use App\Util\Common; use App\Util\Common;
use function array_key_exists;
abstract class FeedController extends Controller 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 * notes or actors the user specified, as well as format the raw
* list of notes into a usable format * 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(); $actor = Common::actor();
if (\array_key_exists('notes', $result)) { if (array_key_exists('notes', $result)) {
$notes = $result['notes']; $notes = $result['notes'];
self::enforceScope($notes, $actor);
Event::handle('FilterNoteList', [$actor, &$notes, $result['request']]); Event::handle('FilterNoteList', [$actor, &$notes, $result['request']]);
Event::handle('FormatNoteList', [$notes, &$result['notes']]); Event::handle('FormatNoteList', [$notes, &$result['notes']]);
} }
return $result; 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;
}
} }

View File

@ -28,6 +28,8 @@ use App\Core\Entity;
use App\Core\Event; use App\Core\Event;
use App\Core\Form; use App\Core\Form;
use App\Core\GSFile; use App\Core\GSFile;
use App\Core\VisibilityScope;
use App\Util\Exception\BugFoundException;
use function App\Core\I18n\_m; use function App\Core\I18n\_m;
use App\Core\Modules\Component; use App\Core\Modules\Component;
use App\Core\Router\Router; 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\File\UploadedFile;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Validator\Constraints\Length; use Symfony\Component\Validator\Constraints\Length;
use function count;
use function is_null;
class Posting extends Component class Posting extends Component
{ {
@ -69,7 +73,7 @@ class Posting extends Component
*/ */
public function onAppendRightPostingBlock(Request $request, array &$res): bool public function onAppendRightPostingBlock(Request $request, array &$res): bool
{ {
if (\is_null($user = Common::user())) { if (is_null($user = Common::user())) {
return Event::next; 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[] = ['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[] = ['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[] = ['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')); $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, $form_params[] = ['content_type', ChoiceType::class,
[ [
'label' => _m('Text format:'), 'multiple' => false, 'expanded' => false, 'label' => _m('Text format:'), 'multiple' => false, 'expanded' => false,
@ -126,7 +135,11 @@ class Posting extends Component
$data = $form->getData(); $data = $form->getData();
if (empty($data['content']) && empty($data['attachments'])) { if (empty($data['content']) && empty($data['attachments'])) {
// TODO Display error: At least one of `content` and `attachments` must be provided // 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)]; $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]); Event::handle('AddExtraArgsToNoteContent', [$request, $actor, $data, &$extra_args, $form_params, $form]);
self::storeLocalNote( self::storeLocalNote(
$user->getActor(), actor: $user->getActor(),
$data['content'], content: $data['content'],
$content_type, content_type: $content_type,
$data['language'], language: $data['language'],
$data['attachments'], scope: $data['visibility'],
target: $data['in'] ?? null, target: $data['in'] ?? null,
attachments: $data['attachments'],
process_note_content_extra_args: $extra_args, 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 * $actor_id, possibly as a reply to note $reply_to and with flag
* $is_local. Sanitizes $content and $attachments * $is_local. Sanitizes $content and $attachments
* *
* @param array $attachments Array of UploadedFile to be stored as GSFiles associated to this note * @param Actor $actor
* @param array $processed_attachments Array of [Attachment, Attachment's name] to be associated to this $actor and Note * @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 * @param array $process_note_content_extra_args Extra arguments for the event ProcessNoteContent
* *
* @return Note
* @throws ClientException * @throws ClientException
* @throws DuplicateFoundException * @throws DuplicateFoundException
* @throws ServerException * @throws ServerException
* * @throws BugFoundException
* @return Entity|mixed
*/ */
public static function storeLocalNote( public static function storeLocalNote(
Actor $actor, Actor $actor,
?string $content, ?string $content,
string $content_type, string $content_type,
?string $language = null, ?string $language = null,
array $attachments = [], ?int $scope = null,
array $processed_attachments = [],
?string $target = 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; $rendered = null;
$mentions = []; $mentions = [];
if (!empty($content)) { if (!empty($content)) {
@ -191,8 +213,9 @@ class Posting extends Component
'content' => $content, 'content' => $content,
'content_type' => $content_type, 'content_type' => $content_type,
'rendered' => $rendered, '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, 'is_local' => true,
'scope' => $scope,
]); ]);
/** @var UploadedFile[] $attachments */ /** @var UploadedFile[] $attachments */
@ -238,7 +261,7 @@ class Posting extends Component
]); ]);
DB::persist($activity); DB::persist($activity);
if (!\is_null($target)) { if (!is_null($target)) {
switch ($target[0]) { switch ($target[0]) {
case '!': case '!':
$mentions[] = [ $mentions[] = [
@ -254,7 +277,7 @@ class Posting extends Component
$mentioned = []; $mentioned = [];
foreach (F\unique(F\flat_map($mentions, fn (array $m) => $m['mentioned'] ?? []), fn (Actor $a) => $a->getId()) as $m) { 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(); $mentioned[] = $m->getId();
if ($m->isGroup()) { if ($m->isGroup()) {

View File

@ -44,12 +44,6 @@ use Symfony\Component\HttpFoundation\Request;
class Feeds extends FeedController 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. * 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 [ return [
'_template' => 'feed/feed.html.twig', '_template' => 'feed/feed.html.twig',
'page_title' => _m(\is_null(Common::user()) ? 'Feed' : 'Planet'), 'page_title' => _m(\is_null(Common::user()) ? 'Feed' : 'Planet'),
'should_format' => true,
'notes' => $data['notes'], 'notes' => $data['notes'],
]; ];
} }
@ -83,7 +76,6 @@ class Feeds extends FeedController
return [ return [
'_template' => 'feed/feed.html.twig', '_template' => 'feed/feed.html.twig',
'page_title' => _m('Home'), 'page_title' => _m('Home'),
'should_format' => true,
'notes' => $data['notes'], 'notes' => $data['notes'],
]; ];
} }

View File

@ -138,7 +138,7 @@ abstract class Controller extends AbstractController implements EventSubscriberI
// XXX: Could we do this differently? // XXX: Could we do this differently?
if (is_subclass_of($controller, FeedController::class)) { 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 // Respond in the most preferred acceptable content type

View File

@ -25,12 +25,10 @@ use App\Util\Bitmap;
class VisibilityScope extends Bitmap class VisibilityScope extends Bitmap
{ {
public const PUBLIC = 1; public const PUBLIC = 1; // Can be shown everywhere (default)
public const SITE = 2; public const LOCAL = 2; // Non-public and non-federated (default in private sites)
public const ADDRESSEE = 4; public const ADDRESSEE = 4; // Only if the actor is the author or one of the targets
public const GROUP = 8; public const GROUP = 8; // Only in the Group feed
public const SUBSCRIBER = 16; public const COLLECTION = 16; // Only for the collection to see (same as addressee but not available in feeds, notifications only)
public const MESSAGE = 32; public const MESSAGE = 32; // Direct Message (same as Collection, but also with dedicated plugin)
public static int $instance_scope = self::PUBLIC | self::SITE;
} }

View File

@ -58,7 +58,7 @@ class Note extends Entity
private ?int $reply_to; private ?int $reply_to;
private bool $is_local; private bool $is_local;
private ?string $source; private ?string $source;
private int $scope = 1; private int $scope = VisibilityScope::PUBLIC;
private ?string $url; private ?string $url;
private ?int $language_id; private ?int $language_id;
private DateTimeInterface $created; 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'], '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'], '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"'], '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'], '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'], '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'], 'created' => ['type' => 'datetime', 'not null' => true, 'default' => 'CURRENT_TIMESTAMP', 'description' => 'date this record was created'],

View File

@ -86,4 +86,9 @@ abstract class Bitmap
{ {
return self::_to($r, false); return self::_to($r, false);
} }
public static function isValue(int $value): bool
{
return in_array($value, (new ReflectionClass(static::class))->getConstants());
}
} }