[CORE][SCOPE] Implement basic visibility in feeds
This commit is contained in:
parent
d4bc1d097d
commit
3e13765f62
@ -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];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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()) {
|
||||||
|
@ -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'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
@ -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;
|
|
||||||
}
|
}
|
||||||
|
@ -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'],
|
||||||
|
@ -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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user