91 Commits
v3 ... v3

Author SHA1 Message Date
tsmethurst
49a80a3c40 [PLUGIN][ActivityPub][TESTS] Add GoToSocial test fixtures 2022-10-21 13:01:32 +02:00
tsmethurst
97114e38e0 [PLUGIN][ActivityPub][TESTS] Replace invalid URL in fixtures 2022-10-21 11:47:27 +01:00
tsmethurst
2df30e2987 [PLUGIN][ActivityPub] Sign outgoing GET requests on behalf of relevant actor 2022-10-21 11:31:35 +01:00
tsmethurst
3b3ded5212 [PLUGIN][ActivityPub] Fix incorrect use of ActivityPubActor::create, should be ::createOrUpdate 2022-10-21 11:31:35 +01:00
tsmethurst
dc240fae49 [DOCKER] Fix incorrect script mount in worker 2022-10-21 11:31:35 +01:00
5cbb1627f2 [COMPONENT][Language] Fix collection query build event incorrectly not setting 'actor_language' join
Thanks to tsmethurst <tobi.smethurst@protonmail.com> for finding the error
2022-10-21 11:31:35 +01:00
46ff8aacd2 [UTIL][TemporaryFile] Silence warnings in critical section inside TemporaryFile 2022-10-21 11:30:47 +01:00
c4d6df4637 [TESTS] Fixup failing tests
Not a permanent solution
2022-10-21 11:30:37 +01:00
053bc38792 [TESTS] Fix tests 2022-10-19 22:39:17 +01:00
2fd46ca886 [TOOLS] Continue raising PHPStan level to 6 2022-10-19 22:39:17 +01:00
c31f3d4997 [TOOLS] Continue raising PHPStan to level 6 2022-10-19 22:39:17 +01:00
Hugo Sales
e6bb418fe6 [TOOLS] Begin raising PHPStan level to 6 2022-10-19 22:38:49 +01:00
fed2242a56 [TOOLS] Raise PHPStan level to 5 and fix associated error, fixing some bugs in the process 2022-10-19 22:38:49 +01:00
edeee49af9 [TOOLS] Fix errors pointed out by PHPStan level 4 2022-10-19 22:38:49 +01:00
4d7742e0e1 [OAuth2] Fix error in plugin install 2022-10-19 22:38:49 +01:00
76f2cdd212 [DEPENDENCIES] Update dependencies 2022-10-19 22:38:44 +01:00
a2aa45fb1f [DOCS] Expand developer Event documentation 2022-04-03 22:05:19 +01:00
d4b7e990ce [CORE][Event] Make all events return \EventResult, enforced at container build time 2022-04-03 21:40:32 +01:00
aef1fac536 [SECURITY] Refactor security hardening code and disable unused stream wrappers
Ensure unwanted enviorment variables are removed from the actual
global environment rather than just the `$_ENV` superglobal variable

Disable stream wrappers, as this is an unexpected feature for most
developers and can be exploited. For instance, `phar://` can be used
to override any class and thus provide code execution (through
`__wakeup` or `__costruct`, for instance). Not a complete solution, as
`php://` can also be abused, but we can't disable it as it gets used
_somewhere_ in our dependencies
2022-04-03 18:02:54 +01:00
556ac85061 [PLUGIN][Pinboard] For tag list request, respond with the most common variant and the corresponding count for each canon tag 2022-04-01 02:10:12 +01:00
539104ec33 [PLUGIN][Pinboard] Refactor and cleanup code 2022-04-01 00:17:57 +01:00
74ffd261b8 [PLUGIN][Pinboard] Implement tag handling 2022-04-01 00:16:04 +01:00
ca9945a4be [ENTITY][Actor][COMPONENT][Tag] Add Actor->getNoteTags(?string $note_type) which gets a cached list of NoteTags for notes of type $note_type for the actor 2022-04-01 00:11:01 +01:00
08587b6942 [COMPONENT][Link][Tag] Refactor to make it easier to create links or tags from other places 2022-04-01 00:09:25 +01:00
1664293cf7 [PLUGIN][Pinboard] Change token to user user ID rather than nickname, to avoid complications with it possibly changing 2022-03-31 22:06:37 +01:00
94ab4ce8c4 [PLUGIN][Pinboard] Invalidate token and it's cache when actor information is changed via ActorForms 2022-03-31 03:47:14 +01:00
dd70de20da [PLUGIN][Pinboard] Implement token authentication and settings page, allowing the user to enable, disable, refresh or consult their token 2022-03-31 03:29:31 +01:00
ded9c86054 [CORE][DB] Add DB::refetch, which refetches an entity from the database, so it's managed and definitely up to date (use when wanting to update entities from cache) 2022-03-31 03:29:31 +01:00
20e07c9140 [CORE][DB] Make DB::dql return an object rather than an array if limit 1 is specified 2022-03-31 03:29:31 +01:00
4e2f6545ec [COMPONENT][Person][PLUGIN][WebHooks] Rename person settings section from 'others' to 'api' 2022-03-31 03:29:31 +01:00
f6a8f44420 [COMPONENT][Person][TEMPLATES] Move persosn settings template from core to the component 2022-03-31 03:29:31 +01:00
fd71d6ee7d [PLUGIN][UnboundGroup] Finish implementation 2022-03-29 00:57:41 +01:00
dfc5918c2c [PLUGIN][ActivityPub] Federate out Service information in Activities 2022-03-28 23:54:19 +01:00
83599ef866 [CORE][Modules][Plugin] version should be static 2022-03-28 23:54:18 +01:00
fa82306f6f [COMPONENT][Posting] Blog posts should be Articles by default 2022-03-28 23:54:18 +01:00
10f71e9fed [UI][TEMPLATES] Fix note text template. Use rendered content directly 2022-03-28 23:23:07 +01:00
e2501ee927 [PLUGIN][Pinboard] Implement remaining API endpoints, restructure, fix template 2022-03-28 23:23:07 +01:00
a9665177ea [PLUGIN][Blog] Move to plugins, mistakenly was in components 2022-03-28 20:59:16 +01:00
41861d284c [COMPONENT][Circle] Correct self tags settings text 2022-03-28 20:59:16 +01:00
bd868a2675 [PLUGINS][Pinboard] Add initial implementation of Pinboard API, lacking authentication, tags and feed endpoints 2022-03-28 20:59:16 +01:00
87e35716c1 [UTIL] Add Formatting::explode(array , string ) 2022-03-28 20:59:16 +01:00
dac94f53cd [CORE][Entity] Rename createOrUpdate to 'checkExistingAndCreateOrUpdate', remove update feature from 'create' and add 'createOrUpdate' and fix users 2022-03-28 20:59:15 +01:00
b10c359dec [DEPENDENCIES] Update dependencies 2022-03-28 20:59:15 +01:00
483983790a [CORE][Router] Rename \App\Core\Router\Router to \App\Core\Router and merge \App\Core\Router\RouteLoader with \App\Core\Router 2022-03-28 20:59:15 +01:00
60af9f5e9b [CORE][Queue] Rename App\Core\Queue\Queue to App\Core\Queue 2022-03-28 20:59:15 +01:00
abe35428da [CORE][DB] Rename App\Core\DB\DB to App\Core\DB 2022-03-28 20:59:14 +01:00
ca5520edbf [PLUGIN][WebHooks] Add hook for subscriptions 2022-03-28 20:59:14 +01:00
e3e14c53ef [PLUGIN][ActivityPub] Model/Note->toJson federate the url, even though it's the same as the id 2022-03-28 20:59:14 +01:00
be33c20614 [PLUGIN][ActivityPub] Improve flexibility of Type layer, accomodate more elaborate understanding of Group Announces after FEP-2100 development 2022-03-28 20:58:48 +01:00
7305a725cb [PLUGIN][UnboundGroup] First steps on implementing AP FEP-2100 2022-03-28 20:57:43 +01:00
fd4c3b0e68 [PLUGIN][Embed][Test] Move Test to correct location 2022-03-28 20:53:50 +01:00
16f51e5143 [COMPONENT][Notification] ->getSubscribers() should not be pre-included
Notification bug fix on Subscription component
Correct docblock
2022-03-28 20:53:19 +01:00
ba4230447e [COMPONENT][Group] Add orderBy to query, as otherwise the feed order is wrong 2022-03-28 20:49:28 +01:00
7463044971 [COMPONENT][Circle] Ensure strict typing on getter 2022-03-28 20:48:29 +01:00
7027633ed5 [PLUGIN][WebHooks] Make request method configurable
This way, PUT can be used, which doesn't seem to be the standard, so isn't the default, but which makes sense to me, as it doesn't have a response, which we don't care about anyway
2022-03-24 00:51:00 +00:00
48b42c539c [PLUGINS][WebHooks] Use ActivityPub to serialize the activity, so the object is included 2022-03-24 00:51:00 +00:00
d41a67a9f9 [PLUGIN][WebHooks] Add WebHooks plugin, which allows for sending a POST request to an external resource when a notification or a follow occurs 2022-03-24 00:51:00 +00:00
13f22c911c [COMPONENT][Notification] Feed: Fix typo in query 2022-03-23 16:09:13 +00:00
56b8710b26 [PLUGIN][ActivityPub][Notification] Fix some issues with targetting 2022-03-23 13:23:44 +00:00
e63c310d70 [COMPONENT][Notification] Always pre-add Actor subscribers when notifying 2022-03-23 13:23:44 +00:00
03f449035a [PLUGIN][ActivityPub][Model][Activity] Sometimes we don't have a local, move on with encapsulated 2022-03-23 13:23:44 +00:00
8808195a80 [PLUGIN][ActivityPub][Test] Test @language handling 2022-03-23 13:23:44 +00:00
45344c80d1 [PLUGIN][ActivityPub][Model][Note] Fix @language handling 2022-03-23 13:23:43 +00:00
7eddbd343d [PLUGIN][ActivityPub][Test] Add Like{Note} fixture 2022-03-23 13:23:43 +00:00
259d2da05a [CORE][Controller] Add default handler for when using http methods 2022-03-23 13:23:43 +00:00
2f7fdf6ee4 [PLUGIN][ActivityPub][Test] Activity: Create Page
Fixed a couple of bugs
2022-03-19 22:21:35 +00:00
6955872e05 [PLUGIN][ActivityPub][Model][Activity] toJson: When in activity context, use object's context if available 2022-03-19 22:20:32 +00:00
23e88b30a6 [COMPONENT][Blog] This is not used for replies 2022-03-19 22:18:33 +00:00
60713878f0 [TESTS] Load languages prior to remaining fixtures 2022-03-19 22:18:00 +00:00
06c67b31c2 [PLUGIN][ActivityPub][Model][Note] toJson: Respect source attribute and @language from context 2022-03-19 18:01:25 +00:00
a08b661779 [COMPONENT][Group] Cast integer string to int when getting group from context 2022-03-19 18:01:25 +00:00
0649a5154c [PLUGIN][ActivityPub][Test][Model][Note] fromJson 2022-03-19 18:01:24 +00:00
91fecd77ba [TOOLS][DOCKER] Use a more robust way to check for database availability 2022-03-19 17:20:12 +00:00
e22fe55bbe [TOOLS] Add .well-known/acme-challenge/ root certbot to nginx container, to allow certbot certificate renewals 2022-03-19 07:32:01 +00:00
dd62825169 [PLUGIN][ActivityPub][Model][Note] fromJson: Respect source attribute and @language from context 2022-03-15 17:49:09 +00:00
27706d63f4 [PLUGIN][OAuth] Fix login for OAuth 2022-03-14 21:41:22 +00:00
20f690c532 [TESTS] Fix a couple of issues from last changes 2022-03-14 18:37:39 +00:00
888c3798b7 [COMPONENT][Notification] Make logic more generic and robust
Fixed various bugs

Some important concepts to bear in mind:

* Notification: Associated with activities, won't be reconstructed
together with objects, can be thought of as transient

* Attention: Associated with objects, will be reconstructed with them, can
be thought as persistent

* Notifications and Attentions have no direct implications.

* Mentions are a specific form of attentions in notes, leads to the creation of Attentions.

Finally,

Potential PHP issue detected and reported: https://github.com/php/php-src/issues/8199
`static::method()` from a non static context (such as a class method) calls `__call`, rather than
the expected `__callStatic`. Can be fixed by using `(static fn() => static::method())()`, but the
usage of the magic method is strictly unnecessary in this case.
2022-03-14 11:37:09 +00:00
e1cceac150 [CORE][Form][TESTS] Fix FormTest::handle 2022-03-13 18:53:53 +00:00
63ef9292f3 [DEPENDENCIES] Update dependencies 2022-03-13 18:17:32 +00:00
cbae649991 [PLUGIN][ActivityPub][TESTS] Move ActivityPub test fixtures to new facility 2022-03-13 18:11:11 +00:00
1d8bba3949 [TESTS][MODULES] Move Test Fixtures to tests/fixtures folder and add support for loading fixtures from components and plugins 2022-03-13 18:00:21 +00:00
18864ca9fa [CONTROLLER][Security] Override the _next form field in Security->register to redirect to login page 2022-03-13 16:01:51 +00:00
390c532456 [PLUGIN][ActivityPub][Tests] Create Actor Tests 2022-03-13 16:00:35 +00:00
636cb681d6 [PLUGIN][ActivityPub][Tests] Create a TestCase for the plugin 2022-03-13 15:54:14 +00:00
7d84323df4 [PLUGIN][ActivityPub][Tests] Add some fixtures for GNU social's 2022-03-13 15:53:21 +00:00
2d7850ccfb [PLUGIN][ActivityPub][Tests] Borrow test fixtures from Lemmy 2022-03-13 15:52:48 +00:00
d8108dbc32 [COMPONENT][Posting] Fix request handling issues that resulted from splitting creation and controller 2022-03-13 15:52:48 +00:00
cf05d3dbb0 [ENTITY][TESTS] Fix Note->isVisibleTo with and associated test 2022-03-13 15:03:03 +00:00
eb3c848fc8 [TOOLS][TESTS] Ensure database schema is up to date in tests 2022-03-13 14:22:18 +00:00
5c708af272 [CORE][Form] Remove unweildy return of form errors from Form::handle 2022-03-13 14:19:56 +00:00
354 changed files with 7054 additions and 3585 deletions

View File

@@ -3,4 +3,4 @@ KERNEL_CLASS='App\Kernel'
APP_SECRET='$ecretf0rt3st'
SYMFONY_DEPRECATIONS_HELPER=999999
PANTHER_APP_ENV=panther
DATABASE_URL=postgresql://postgres:password@db:5432/social
DATABASE_URL=postgresql://postgres:password@db:5432/test

View File

@@ -178,7 +178,7 @@ return $config
// There MUST NOT be a space after the opening parenthesis. There MUST NOT be a space before the closing parenthesis.
'no_spaces_inside_parenthesis' => true,
// Removes `@param`, `@return` and `@var` tags that don't provide any useful information.
'no_superfluous_phpdoc_tags' => true,
'no_superfluous_phpdoc_tags' => false,
// Remove trailing commas in list function calls.
'no_trailing_comma_in_list_call' => true,
// PHP single-line arrays should not have trailing comma.

View File

@@ -22,10 +22,10 @@ declare(strict_types = 1);
namespace Component\Attachment;
use App\Core\Cache;
use App\Core\DB\DB;
use App\Core\DB;
use App\Core\Event;
use App\Core\Modules\Component;
use App\Core\Router\RouteLoader;
use App\Core\Router;
use App\Entity\Actor;
use App\Entity\Note;
use App\Util\Formatting;
@@ -34,10 +34,11 @@ use Component\Attachment\Entity as E;
use Doctrine\Common\Collections\ExpressionBuilder;
use Doctrine\ORM\Query\Expr;
use Doctrine\ORM\QueryBuilder;
use EventResult;
class Attachment extends Component
{
public function onAddRoute(RouteLoader $r): bool
public function onAddRoute(Router $r): EventResult
{
$r->connect('note_attachment_show', '/object/note/{note_id<\d+>}/attachment/{attachment_id<\d+>}', [C\Attachment::class, 'attachmentShowWithNote']);
$r->connect('note_attachment_view', '/object/note/{note_id<\d+>}/attachment/{attachment_id<\d+>}/view', [C\Attachment::class, 'attachmentViewWithNote']);
@@ -51,13 +52,13 @@ class Attachment extends Component
*
* This can be used in the future to deduplicate images by visual content
*/
public function onHashFile(string $filename, ?string &$out_hash): bool
public function onHashFile(string $filename, ?string &$out_hash): EventResult
{
$out_hash = hash_file(E\Attachment::FILEHASH_ALGO, $filename);
return Event::stop;
}
public function onNoteDeleteRelated(Note &$note, Actor $actor): bool
public function onNoteDeleteRelated(Note &$note, Actor $actor): EventResult
{
Cache::delete("note-attachments-{$note->getId()}");
foreach ($note->getAttachments() as $attachment) {
@@ -68,7 +69,7 @@ class Attachment extends Component
return Event::next;
}
public function onCollectionQueryAddJoins(QueryBuilder &$note_qb, QueryBuilder &$actor_qb): bool
public function onCollectionQueryAddJoins(QueryBuilder &$note_qb, QueryBuilder &$actor_qb): EventResult
{
if (!\in_array('attachment_to_note', $note_qb->getAllAliases())) {
$note_qb->leftJoin(
@@ -84,7 +85,7 @@ class Attachment extends Component
/**
* Populate $note_expr with the criteria for looking for notes with attachments
*/
public function onCollectionQueryCreateExpression(ExpressionBuilder $eb, string $term, ?string $locale, ?Actor $actor, &$note_expr, &$actor_expr): bool
public function onCollectionQueryCreateExpression(ExpressionBuilder $eb, string $term, ?string $locale, ?Actor $actor, &$note_expr, &$actor_expr): EventResult
{
$include_term = str_contains($term, ':') ? explode(':', $term)[1] : $term;
if (Formatting::startsWith($term, ['note-types:', 'notes-incude:', 'note-filter:'])) {

View File

@@ -24,7 +24,7 @@ declare(strict_types = 1);
namespace Component\Attachment\Controller;
use App\Core\Controller;
use App\Core\DB\DB;
use App\Core\DB;
use App\Core\Event;
use App\Core\GSFile;
use function App\Core\I18n\_m;

View File

@@ -21,7 +21,7 @@ declare(strict_types = 1);
namespace Component\Attachment\Entity;
use App\Core\DB\DB;
use App\Core\DB;
use App\Core\Entity;
use DateTimeInterface;

View File

@@ -24,13 +24,13 @@ declare(strict_types = 1);
namespace Component\Attachment\Entity;
use App\Core\Cache;
use App\Core\DB\DB;
use App\Core\DB;
use App\Core\Entity;
use App\Core\Event;
use App\Core\GSFile;
use function App\Core\I18n\_m;
use App\Core\Log;
use App\Core\Router\Router;
use App\Core\Router;
use App\Entity\Note;
use App\Util\Common;
use App\Util\Exception\ClientException;

View File

@@ -24,12 +24,12 @@ declare(strict_types = 1);
namespace Component\Attachment\Entity;
use App\Core\Cache;
use App\Core\DB\DB;
use App\Core\DB;
use App\Core\Entity;
use App\Core\Event;
use App\Core\GSFile;
use App\Core\Log;
use App\Core\Router\Router;
use App\Core\Router;
use App\Entity\Note;
use App\Util\Common;
use App\Util\Exception\ClientException;

View File

@@ -21,7 +21,7 @@ declare(strict_types = 1);
namespace Component\Attachment\Entity;
use App\Core\DB\DB;
use App\Core\DB;
use App\Core\Entity;
use DateTimeInterface;

View File

@@ -21,7 +21,7 @@ declare(strict_types = 1);
namespace Component\Attachment\Entity;
use App\Core\DB\DB;
use App\Core\DB;
use App\Core\Entity;
use DateTimeInterface;

View File

@@ -23,7 +23,7 @@ declare(strict_types = 1);
namespace Component\Attachment\tests\Controller;
use App\Core\DB\DB;
use App\Core\DB;
use App\Util\GNUsocialTestCase;
use Component\Attachment\Entity\Attachment;
use Component\Attachment\Entity\AttachmentToNote;

View File

@@ -21,10 +21,10 @@ declare(strict_types = 1);
namespace Component\Attachment\tests\Entity;
use App\Core\DB\DB;
use App\Core\DB;
use App\Core\Event;
use App\Core\GSFile;
use App\Core\Router\Router;
use App\Core\Router;
use App\Entity\Note;
use App\Util\GNUsocialTestCase;
use App\Util\TemporaryFile;
@@ -107,7 +107,7 @@ class AttachmentTest extends GNUsocialTestCase
static::assertSame('Untitled attachment', $attachment->getBestTitle());
$attachment->setFilename($filename);
$actor = DB::findOneBy('actor', ['nickname' => 'taken_user']);
$actor = DB::findOneBy('actor', ['nickname' => 'taken_user']);
DB::persist($note = Note::create(['actor_id' => $actor->getId(), 'content' => 'attachment: some content', 'content_type' => 'text/plain', 'is_local' => true]));
Conversation::assignLocalConversation($note, null);
DB::persist(AttachmentToNote::create(['attachment_id' => $attachment->getId(), 'note_id' => $note->getId(), 'title' => 'A title']));

View File

@@ -21,7 +21,7 @@ declare(strict_types = 1);
namespace Component\Attachment\tests\Entity;
use App\Core\DB\DB;
use App\Core\DB;
use App\Core\Event;
use App\Util\Exception\NotStoredLocallyException;
use App\Util\GNUsocialTestCase;

View File

@@ -22,26 +22,27 @@ declare(strict_types = 1);
namespace Component\Avatar;
use App\Core\Cache;
use App\Core\DB\DB;
use App\Core\DB;
use App\Core\Event;
use App\Core\GSFile;
use App\Core\Modules\Component;
use App\Core\Router\RouteLoader;
use App\Core\Router\Router;
use App\Core\Router;
use App\Util\Common;
use Component\Attachment\Entity\Attachment;
use Component\Attachment\Entity\AttachmentThumbnail;
use Component\Avatar\Controller as C;
use Component\Avatar\Exception\NoAvatarException;
use EventResult;
use Symfony\Component\HttpFoundation\Request;
class Avatar extends Component
{
public function onInitializeComponent()
public function onInitializeComponent(): EventResult
{
return EventResult::next;
}
public function onAddRoute(RouteLoader $r): bool
public function onAddRoute(Router $r): EventResult
{
$r->connect('avatar_actor', '/actor/{actor_id<\d+>}/avatar/{size<full|big|medium|small>?medium}', [Controller\Avatar::class, 'avatar_view']);
$r->connect('avatar_default', '/avatar/default/{size<full|big|medium|small>?medium}', [Controller\Avatar::class, 'default_avatar_view']);
@@ -50,9 +51,11 @@ class Avatar extends Component
}
/**
* @param SettingsTabsType $tabs
*
* @throws \App\Util\Exception\ClientException
*/
public function onPopulateSettingsTabs(Request $request, string $section, &$tabs): bool
public function onPopulateSettingsTabs(Request $request, string $section, &$tabs): EventResult
{
if ($section === 'profile') {
$tabs[] = [
@@ -65,7 +68,7 @@ class Avatar extends Component
return Event::next;
}
public function onAvatarUpdate(int $actor_id): bool
public function onAvatarUpdate(int $actor_id): EventResult
{
Cache::delete("avatar-{$actor_id}");
foreach (['full', 'big', 'medium', 'small'] as $size) {
@@ -128,6 +131,8 @@ class Avatar extends Component
*
* Returns the avatar file's hash, mimetype, title and path.
* Ensures exactly one cached value exists
*
* @return array{id: null|int, filename: null|string, title: string, mimetype: string, filepath?: string}
*/
public static function getAvatarFileInfo(int $actor_id, string $size = 'medium'): array
{

View File

@@ -24,7 +24,7 @@ declare(strict_types = 1);
namespace Component\Avatar\Controller;
use App\Core\Controller;
use App\Core\DB\DB;
use App\Core\DB;
use App\Core\Event;
use App\Core\Form;
use App\Core\GSFile;

View File

@@ -24,10 +24,10 @@ declare(strict_types = 1);
namespace Component\Avatar\Entity;
use App\Core\Cache;
use App\Core\DB\DB;
use App\Core\DB;
use App\Core\Entity;
use App\Core\Event;
use App\Core\Router\Router;
use App\Core\Router;
use App\Util\Common;
use Component\Attachment\Entity\Attachment;
use Component\Attachment\Entity\AttachmentThumbnail;

View File

@@ -24,16 +24,14 @@ declare(strict_types = 1);
namespace Component\Circle;
use App\Core\Cache;
use App\Core\DB\DB;
use App\Core\DB;
use App\Core\Event;
use function App\Core\I18n\_m;
use App\Core\Modules\Component;
use App\Core\Router\RouteLoader;
use App\Core\Router\Router;
use App\Core\Router;
use App\Entity\Actor;
use App\Entity\Feed;
use App\Entity\LocalUser;
use App\Util\Common;
use App\Util\Nickname;
use Component\Circle\Controller as CircleController;
use Component\Circle\Entity\ActorCircle;
@@ -41,6 +39,7 @@ use Component\Circle\Entity\ActorCircleSubscription;
use Component\Circle\Entity\ActorTag;
use Component\Collection\Util\MetaCollectionTrait;
use Component\Tag\Tag;
use EventResult;
use Functional as F;
use Symfony\Component\HttpFoundation\Request;
@@ -55,12 +54,13 @@ use Symfony\Component\HttpFoundation\Request;
*/
class Circle extends Component
{
/** @phpstan-use MetaCollectionTrait<ActorCircle> */
use MetaCollectionTrait;
public const TAG_CIRCLE_REGEX = '/' . Nickname::BEFORE_MENTIONS . '@#([\pL\pN_\-\.]{1,64})/';
protected const SLUG = 'circle';
protected const PLURAL_SLUG = 'circles';
public function onAddRoute(RouteLoader $r): bool
public function onAddRoute(Router $r): EventResult
{
$r->connect('actor_circle_view_by_circle_id', '/circle/{circle_id<\d+>}', [CircleController\Circle::class, 'circleById']);
// View circle members by (tagger id or nickname) and tag
@@ -95,20 +95,23 @@ class Circle extends Component
];
}
public function onPopulateSettingsTabs(Request $request, string $section, array &$tabs): bool
public function onPopulateSettingsTabs(Request $request, string $section, array &$tabs): EventResult
{
if ($section === 'profile' && \in_array($request->get('_route'), ['person_actor_settings', 'group_actor_settings'])) {
$tabs[] = [
'title' => 'Self tags',
'desc' => 'Add or remove tags on yourself',
'title' => _m('Self tags'),
'desc' => _m('Add or remove tags to this actor'),
'id' => 'settings-self-tags',
'controller' => CircleController\SelfTagsSettings::settingsSelfTags($request, Common::actor(), 'settings-self-tags-details'),
'controller' => CircleController\SelfTagsSettings::settingsSelfTags($request, Actor::getById((int) $request->get('id')), 'settings-self-tags-details'),
];
}
return Event::next;
}
public function onPostingFillTargetChoices(Request $request, Actor $actor, array &$targets): bool
/**
* @param Actor[] $targets
*/
public function onPostingFillTargetChoices(Request $request, Actor $actor, array &$targets): EventResult
{
$circles = $actor->getCircles();
foreach ($circles as $circle) {
@@ -120,6 +123,9 @@ class Circle extends Component
// Meta Collection -------------------------------------------------------------------
/**
* @param array<string, mixed> $vars
*/
private function getActorIdFromVars(array $vars): int
{
$id = $vars['request']->get('id', null);
@@ -131,7 +137,7 @@ class Circle extends Component
return $user->getId();
}
public static function createCircle(Actor|int $tagger_id, string $tag): int
public static function createCircle(Actor|int $tagger_id, string $tag): int|null
{
$tagger_id = \is_int($tagger_id) ? $tagger_id : $tagger_id->getId();
$circle = ActorCircle::create([
@@ -147,7 +153,10 @@ class Circle extends Component
return $circle->getId();
}
protected function createCollection(Actor $owner, array $vars, string $name)
/**
* @param array<string, mixed> $vars
*/
protected function createCollection(Actor $owner, array $vars, string $name): void
{
$this->createCircle($owner, $name);
DB::persist(ActorTag::create([
@@ -157,7 +166,12 @@ class Circle extends Component
]));
}
protected function removeItem(Actor $owner, array $vars, $items, array $collections)
/**
* @param array<string, mixed> $vars
* @param array<int> $items
* @param array<mixed> $collections
*/
protected function removeItem(Actor $owner, array $vars, array $items, array $collections): bool
{
$tagger_id = $owner->getId();
$tagged_id = $this->getActorIdFromVars($vars);
@@ -170,9 +184,15 @@ class Circle extends Component
DB::removeBy(ActorTag::class, ['tagger' => $tagger_id, 'tagged' => $tagged_id, 'tag' => $tag]);
}
Cache::delete(Actor::cacheKeys($tagger_id)['circles']);
return true;
}
protected function addItem(Actor $owner, array $vars, $items, array $collections)
/**
* @param array<string, mixed> $vars
* @param array<int> $items
* @param array<mixed> $collections
*/
protected function addItem(Actor $owner, array $vars, array $items, array $collections): void
{
$tagger_id = $owner->getId();
$tagged_id = $this->getActorIdFromVars($vars);
@@ -189,8 +209,10 @@ class Circle extends Component
/**
* @see MetaCollectionPlugin->shouldAddToRightPanel
*
* @param array<string, mixed> $vars
*/
protected function shouldAddToRightPanel(Actor $user, $vars, Request $request): bool
protected function shouldAddToRightPanel(Actor $user, array $vars, Request $request): bool
{
return \in_array($vars['path'], ['actor_view_nickname', 'actor_view_id']);
}
@@ -202,9 +224,11 @@ class Circle extends Component
* Differs from the overwritten method in MetaCollectionsTrait, since retrieved Collections come from the $owner
* itself, and from every Actor that is a part of its ActorCircle.
*
* @param Actor $owner the Actor, and by extension its own circle of Actors
* @param null|array $vars Page vars sent by AppendRightPanelBlock event
* @param bool $ids_only true if only the Collections ids are to be returned
* @param Actor $owner the Actor, and by extension its own circle of Actors
* @param null|array<string, mixed> $vars Page vars sent by AppendRightPanelBlock event
* @param bool $ids_only true if only the Collections ids are to be returned
*
* @return ($ids_only is true ? int[] : ActorCircle[])
*/
protected function getCollectionsBy(Actor $owner, ?array $vars = null, bool $ids_only = false): array
{
@@ -220,7 +244,7 @@ class Circle extends Component
return $ids_only ? array_map(fn ($x) => $x->getId(), $circles) : $circles;
}
public function onCreateDefaultFeeds(int $actor_id, LocalUser $user, int &$ordering)
public function onCreateDefaultFeeds(int $actor_id, LocalUser $user, int &$ordering): EventResult
{
DB::persist(Feed::create([
'actor_id' => $actor_id,

View File

@@ -38,6 +38,8 @@ class Circle extends CircleController
*
* @throws \App\Util\Exception\ServerException
* @throws ClientException
*
* @return ControllerResultType
*/
public function circleById(int|ActorCircle $circle_id): array
{
@@ -57,11 +59,17 @@ class Circle extends CircleController
}
}
/**
* @return ControllerResultType
*/
public function circleByTaggerIdAndTag(int $tagger_id, string $tag): array
{
return $this->circleById(ActorCircle::getByPK(['tagger' => $tagger_id, 'tag' => $tag]));
}
/**
* @return ControllerResultType
*/
public function circleByTaggerNicknameAndTag(string $tagger_nickname, string $tag): array
{
return $this->circleById(ActorCircle::getByPK(['tagger' => LocalUser::getByNickname($tagger_nickname)->getId(), 'tag' => $tag]));

View File

@@ -24,23 +24,27 @@ declare(strict_types = 1);
namespace Component\Circle\Controller;
use App\Core\Cache;
use App\Core\DB\DB;
use App\Core\Router\Router;
use App\Core\DB;
use App\Core\Router;
use App\Entity\Actor;
use App\Entity\LocalUser;
use Component\Circle\Entity\ActorCircle;
use Component\Collection\Util\Controller\MetaCollectionController;
/**
* @extends MetaCollectionController<Circles>
*/
class Circles extends MetaCollectionController
{
protected const SLUG = 'circle';
protected const PLURAL_SLUG = 'circles';
protected string $page_title = 'Actor circles';
public function createCollection(int $owner_id, string $name)
public function createCollection(int $owner_id, string $name): bool
{
return \Component\Circle\Circle::createCircle($owner_id, $name);
return !\is_null(\Component\Circle\Circle::createCircle($owner_id, $name));
}
public function getCollectionUrl(int $owner_id, ?string $owner_nickname, int $collection_id): string
{
return Router::url(
@@ -49,21 +53,26 @@ class Circles extends MetaCollectionController
);
}
public function getCollectionItems(int $owner_id, $collection_id): array
/**
* @return Circles[]
*/
public function getCollectionItems(int $owner_id, int $collection_id): array
{
$notes = []; // TODO: Use Feed::query
return [
'_template' => 'collection/notes.html.twig',
'notes' => $notes,
];
return []; // TODO
}
/**
* @return Circles[]
*/
public function feedByCircleId(int $circle_id)
{
// Owner id isn't used
return $this->getCollectionItems(0, $circle_id);
}
/**
* @return Circles[]
*/
public function feedByTaggerIdAndTag(int $tagger_id, string $tag)
{
// Owner id isn't used
@@ -71,6 +80,9 @@ class Circles extends MetaCollectionController
return $this->getCollectionItems($tagger_id, $circle_id);
}
/**
* @return Circles[]
*/
public function feedByTaggerNicknameAndTag(string $tagger_nickname, string $tag)
{
$tagger_id = LocalUser::getByNickname($tagger_nickname)->getId();
@@ -82,12 +94,13 @@ class Circles extends MetaCollectionController
{
return DB::findBy(ActorCircle::class, ['tagger' => $owner_id], order_by: ['id' => 'desc']);
}
public function getCollectionBy(int $owner_id, int $collection_id): ActorCircle
public function getCollectionBy(int $owner_id, int $collection_id): self
{
return DB::findOneBy(ActorCircle::class, ['id' => $collection_id, 'actor_id' => $owner_id]);
}
public function setCollectionName(int $actor_id, string $actor_nickname, ActorCircle $collection, string $name)
public function setCollectionName(int $actor_id, string $actor_nickname, ActorCircle $collection, string $name): void
{
foreach ($collection->getActorTags(db_reference: true) as $at) {
$at->setTag($name);
@@ -96,7 +109,7 @@ class Circles extends MetaCollectionController
Cache::delete(Actor::cacheKeys($actor_id)['circles']);
}
public function removeCollection(int $actor_id, string $actor_nickname, ActorCircle $collection)
public function removeCollection(int $actor_id, string $actor_nickname, ActorCircle $collection): void
{
foreach ($collection->getActorTags(db_reference: true) as $at) {
DB::remove($at);

View File

@@ -6,7 +6,7 @@ namespace Component\Circle\Controller;
use App\Core\Cache;
use App\Core\Controller;
use App\Core\DB\DB;
use App\Core\DB;
use function App\Core\I18n\_m;
use App\Entity as E;
use App\Util\Common;
@@ -45,7 +45,7 @@ class SelfTagsSettings extends Controller
foreach ($tags as $tag) {
$tag = CompTag::sanitize($tag);
[$actor_tag, $actor_tag_existed] = ActorTag::createOrUpdate([
[$actor_tag, $actor_tag_existed] = ActorTag::checkExistingAndCreateOrUpdate([
'tagger' => $target->getId(), // self tag means tagger = tagger in ActorTag
'tagged' => $target->getId(),
'tag' => $tag,

View File

@@ -22,9 +22,10 @@ declare(strict_types = 1);
namespace Component\Circle\Entity;
use App\Core\Cache;
use App\Core\DB\DB;
use App\Core\DB;
use App\Core\Entity;
use App\Core\Router\Router;
use App\Core\Router;
use App\Entity\Actor;
use DateTimeInterface;
/**
@@ -144,6 +145,9 @@ class ActorCircle extends Entity
return $this->tag;
}
/**
* @return ActorTag[]
*/
public function getActorTags(bool $db_reference = false): array
{
$handle = fn () => DB::findBy('actor_tag', ['tagger' => $this->getTagger(), 'tag' => $this->getTag()]);
@@ -156,7 +160,10 @@ class ActorCircle extends Entity
);
}
public function getTaggedActors()
/**
* @return Actor[]
*/
public function getTaggedActors(): array
{
return Cache::get(
"circle-{$this->getId()}-tagged-actors",
@@ -170,6 +177,9 @@ class ActorCircle extends Entity
);
}
/**
* @return Actor[]
*/
public function getSubscribedActors(?int $offset = null, ?int $limit = null): array
{
return Cache::get(

View File

@@ -21,9 +21,9 @@ declare(strict_types = 1);
namespace Component\Circle\Entity;
use App\Core\DB\DB;
use App\Core\DB;
use App\Core\Entity;
use App\Core\Router\Router;
use App\Core\Router;
use App\Entity\Actor;
use Component\Tag\Tag;
use DateTimeInterface;

View File

@@ -7,14 +7,18 @@ namespace Component\Circle\Form;
use App\Core\Form;
use function App\Core\I18n\_m;
use App\Util\Form\ArrayTransformer;
use Component\Circle\Entity\ActorTag;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
abstract class SelfTagsForm
{
/**
* @return array [Form (add), ?Form (existing)]
* @param ActorTag[] $actor_self_tags
*
* @return array{FormInterface, ?FormInterface} [Form (add), ?Form (existing)]
*/
public static function handleTags(
Request $request,
@@ -34,7 +38,7 @@ abstract class SelfTagsForm
$existing_form = !empty($form_definition) ? Form::create($form_definition) : null;
$add_form = Form::create([
['new-tags', TextType::class, ['label' => ' ', 'data' => [], 'required' => false, 'help' => _m('Tags for yourself (letters, numbers, -, ., and _), comma- or space-separated.'), 'transformer' => ArrayTransformer::class]],
['new-tags', TextType::class, ['label' => ' ', 'data' => [], 'required' => false, 'help' => _m('Tags for this actor (letters, numbers, -, ., and _), comma- or space-separated.'), 'transformer' => ArrayTransformer::class]],
[$add_form_name = 'new-tags-add', SubmitType::class, ['label' => $add_label]],
]);

View File

@@ -4,7 +4,7 @@ declare(strict_types = 1);
namespace Component\Collection;
use App\Core\DB\DB;
use App\Core\DB;
use App\Core\Event;
use App\Core\Modules\Component;
use App\Entity\Actor;
@@ -14,6 +14,7 @@ use Component\Subscription\Entity\ActorSubscription;
use Doctrine\Common\Collections\ExpressionBuilder;
use Doctrine\ORM\Query\Expr;
use Doctrine\ORM\QueryBuilder;
use EventResult;
class Collection extends Component
{
@@ -22,6 +23,11 @@ class Collection extends Component
*
* Supports a variety of query terms and is used both in feeds and
* in search. Uses query builders to allow for extension
*
* @param array<string, OrderByType> $note_order_by
* @param array<string, OrderByType> $actor_order_by
*
* @return array{notes: null|Note[], actors: null|Actor[]}
*/
public static function query(string $query, int $page, ?string $locale = null, ?Actor $actor = null, array $note_order_by = [], array $actor_order_by = []): array
{
@@ -64,7 +70,7 @@ class Collection extends Component
return ['notes' => $notes ?? null, 'actors' => $actors ?? null];
}
public function onCollectionQueryAddJoins(QueryBuilder &$note_qb, QueryBuilder &$actor_qb): bool
public function onCollectionQueryAddJoins(QueryBuilder &$note_qb, QueryBuilder &$actor_qb): EventResult
{
$note_aliases = $note_qb->getAllAliases();
if (!\in_array('subscription', $note_aliases)) {
@@ -79,57 +85,60 @@ class Collection extends Component
/**
* Convert $term to $note_expr and $actor_expr, search criteria. Handles searching for text
* notes, for different types of actors and for the content of text notes
*
* @param mixed $note_expr
* @param mixed $actor_expr
*/
public function onCollectionQueryCreateExpression(ExpressionBuilder $eb, string $term, ?string $locale, ?Actor $actor, &$note_expr, &$actor_expr)
public function onCollectionQueryCreateExpression(ExpressionBuilder $eb, string $term, ?string $locale, ?Actor $actor, &$note_expr, &$actor_expr): EventResult
{
if (str_contains($term, ':')) {
$term = explode(':', $term);
if (Formatting::startsWith($term[0], 'note')) {
switch ($term[0]) {
case 'notes-all':
$note_expr = $eb->neq('note.created', null);
break;
case 'note-local':
$note_expr = $eb->eq('note.is_local', filter_var($term[1], \FILTER_VALIDATE_BOOLEAN));
break;
case 'note-types':
case 'notes-include':
case 'note-filter':
if (\is_null($note_expr)) {
$note_expr = [];
}
if (array_intersect(explode(',', $term[1]), ['text', 'words']) !== []) {
$note_expr[] = $eb->neq('note.content', null);
} else {
$note_expr[] = $eb->eq('note.content', null);
}
break;
case 'note-conversation':
$note_expr = $eb->eq('note.conversation_id', (int) trim($term[1]));
break;
case 'note-from':
case 'notes-from':
$subscribed_expr = $eb->eq('subscription.subscriber_id', $actor->getId());
$type_consts = [];
if ($term[1] === 'subscribed') {
$type_consts = null;
}
foreach (explode(',', $term[1]) as $from) {
if (str_starts_with($from, 'subscribed-')) {
[, $type] = explode('-', $from);
if (\in_array($type, ['actor', 'actors'])) {
$type_consts = null;
} else {
$type_consts[] = \constant(Actor::class . '::' . mb_strtoupper($type === 'organisation' ? 'group' : $type));
case 'notes-all':
$note_expr = $eb->neq('note.created', null);
break;
case 'note-local':
$note_expr = $eb->eq('note.is_local', filter_var($term[1], \FILTER_VALIDATE_BOOLEAN));
break;
case 'note-types':
case 'notes-include':
case 'note-filter':
if (\is_null($note_expr)) {
$note_expr = [];
}
if (array_intersect(explode(',', $term[1]), ['text', 'words']) !== []) {
$note_expr[] = $eb->neq('note.content', null);
} else {
$note_expr[] = $eb->eq('note.content', null);
}
break;
case 'note-conversation':
$note_expr = $eb->eq('note.conversation_id', (int) trim($term[1]));
break;
case 'note-from':
case 'notes-from':
$subscribed_expr = $eb->eq('subscription.subscriber_id', $actor->getId());
$type_consts = [];
if ($term[1] === 'subscribed') {
$type_consts = null;
}
foreach (explode(',', $term[1]) as $from) {
if (str_starts_with($from, 'subscribed-')) {
[, $type] = explode('-', $from);
if (\in_array($type, ['actor', 'actors'])) {
$type_consts = null;
} else {
$type_consts[] = \constant(Actor::class . '::' . mb_strtoupper($type === 'organisation' ? 'group' : $type));
}
}
}
}
if (\is_null($type_consts)) {
$note_expr = $subscribed_expr;
} elseif (!empty($type_consts)) {
$note_expr = $eb->andX($subscribed_expr, $eb->in('note_actor.type', $type_consts));
}
break;
if (\is_null($type_consts)) {
$note_expr = $subscribed_expr;
} elseif (!empty($type_consts)) {
$note_expr = $eb->andX($subscribed_expr, $eb->in('note_actor.type', $type_consts));
}
break;
}
} elseif (Formatting::startsWith($term, 'actor-')) {
switch ($term[0]) {
@@ -143,8 +152,8 @@ class Collection extends Component
foreach (
[
Actor::PERSON => ['person', 'people'],
Actor::GROUP => ['group', 'groups', 'org', 'orgs', 'organisation', 'organisations', 'organization', 'organizations'],
Actor::BOT => ['bot', 'bots'],
Actor::GROUP => ['group', 'groups', 'org', 'orgs', 'organisation', 'organisations', 'organization', 'organizations'],
Actor::BOT => ['bot', 'bots'],
] as $type => $match) {
if (array_intersect(explode(',', $term[1]), $match) !== []) {
$actor_expr[] = $eb->eq('actor.type', $type);

View File

@@ -4,6 +4,9 @@ declare(strict_types = 1);
namespace Component\Collection\Util\Controller;
/**
* @extends OrderedCollection<\Component\Circle\Entity\ActorCircle>
*/
class CircleController extends OrderedCollection
{
}

View File

@@ -6,15 +6,25 @@ namespace Component\Collection\Util\Controller;
use App\Core\Controller;
use App\Entity\Actor;
use App\Entity\Note;
use App\Util\Common;
use Component\Collection\Collection as CollectionModule;
use Component\Collection\Collection as CollectionComponent;
class Collection extends Controller
/**
* @template T
*/
abstract class Collection extends Controller
{
/**
* @param array<string, OrderByType> $note_order_by
* @param array<string, OrderByType> $actor_order_by
*
* @return array{notes: null|Note[], actors: null|Actor[]}
*/
public function query(string $query, ?string $locale = null, ?Actor $actor = null, array $note_order_by = [], array $actor_order_by = []): array
{
$actor ??= Common::actor();
$locale ??= Common::currentLanguage()->getLocale();
return CollectionModule::query($query, $this->int('page') ?? 1, $locale, $actor, $note_order_by, $actor_order_by);
return CollectionComponent::query($query, $this->int('page') ?? 1, $locale, $actor, $note_order_by, $actor_order_by);
}
}

View File

@@ -38,12 +38,23 @@ use App\Entity\Note;
use App\Util\Common;
use Functional as F;
/**
* @template T
*
* @extends OrderedCollection<T>
*/
abstract class FeedController extends OrderedCollection
{
/**
* 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
*
* @template NA of Note|Actor
*
* @param NA[] $result
*
* @return NA[]
*/
protected function postProcess(array $result): array
{
@@ -58,6 +69,9 @@ abstract class FeedController extends OrderedCollection
return $result;
}
/**
* @param Note[] $notes
*/
private static function enforceScope(array &$notes, ?Actor $actor, ?Actor $in = null): void
{
$notes = F\select($notes, fn (Note $n) => $n->isVisibleTo($actor, $in));

View File

@@ -31,7 +31,7 @@ declare(strict_types = 1);
namespace Component\Collection\Util\Controller;
use App\Core\DB\DB;
use App\Core\DB;
use App\Core\Form;
use function App\Core\I18n\_m;
use App\Entity\LocalUser;
@@ -39,8 +39,14 @@ use App\Util\Common;
use App\Util\Exception\RedirectException;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormView;
use Symfony\Component\HttpFoundation\Request;
/**
* @template T of object
*
* @extends FeedController<T>
*/
abstract class MetaCollectionController extends FeedController
{
protected const SLUG = 'collectionsEntry';
@@ -48,17 +54,36 @@ abstract class MetaCollectionController extends FeedController
protected string $page_title = 'Collections';
abstract public function getCollectionUrl(int $owner_id, string $owner_nickname, int $collection_id): string;
abstract public function getCollectionItems(int $owner_id, $collection_id): array;
abstract public function getCollectionsByActorId(int $owner_id): array;
abstract public function getCollectionBy(int $owner_id, int $collection_id);
abstract public function createCollection(int $owner_id, string $name);
/**
* @return T[]
*/
abstract public function getCollectionItems(int $owner_id, int $collection_id): array;
/**
* @return T[]
*/
abstract public function getCollectionsByActorId(int $owner_id): array;
/**
* @return T A collection
*/
abstract public function getCollectionBy(int $owner_id, int $collection_id): object;
abstract public function createCollection(int $owner_id, string $name): bool;
/**
* @return ControllerResultType
*/
public function collectionsViewByActorNickname(Request $request, string $nickname): array
{
$user = DB::findOneBy(LocalUser::class, ['nickname' => $nickname]);
return self::collectionsView($request, $user->getId(), $nickname);
}
/**
* @return ControllerResultType
*/
public function collectionsViewByActorId(Request $request, int $id): array
{
return self::collectionsView($request, $id, null);
@@ -70,7 +95,7 @@ abstract class MetaCollectionController extends FeedController
* @param int $id actor id
* @param ?string $nickname actor nickname
*
* @return array twig template options
* @return ControllerResultType twig template options
*/
public function collectionsView(Request $request, int $id, ?string $nickname): array
{
@@ -113,34 +138,23 @@ abstract class MetaCollectionController extends FeedController
// the functions and passing that class to the template.
// This is suggested at https://web.archive.org/web/20220226132328/https://stackoverflow.com/questions/3595727/twig-pass-function-into-template/50364502
$fn = new class($id, $nickname, $request, $this, static::SLUG) {
private $id;
private $nick;
private $request;
private $parent;
private $slug;
public function __construct($id, $nickname, $request, $parent, $slug)
public function __construct(private int $id, private string $nickname, private Request $request, private object $parent, private string $slug)
{
$this->id = $id;
$this->nick = $nickname;
$this->request = $request;
$this->parent = $parent;
$this->slug = $slug;
}
// there's already an injected function called path,
// that maps to Router::url(name, args), but since
// I want to preserve nicknames, I think it's better
// to use that getUrl function
public function getUrl($cid)
public function getUrl(int $cid): string
{
return $this->parent->getCollectionUrl($this->id, $this->nick, $cid);
return $this->parent->getCollectionUrl($this->id, $this->nickname, $cid);
}
// There are many collections in this page and we need two
// forms for each one of them: one form to edit the collection's
// name and another to remove the collection.
// creating the edit form
public function editForm($collection)
public function editForm(object $collection): FormView
{
$edit = Form::create([
['name', TextType::class, [
@@ -159,7 +173,7 @@ abstract class MetaCollectionController extends FeedController
]);
$edit->handleRequest($this->request);
if ($edit->isSubmitted() && $edit->isValid()) {
$this->parent->setCollectionName($this->id, $this->nick, $collection, $edit->getData()['name']);
$this->parent->setCollectionName($this->id, $this->nickname, $collection, $edit->getData()['name']);
DB::flush();
throw new RedirectException();
}
@@ -167,7 +181,7 @@ abstract class MetaCollectionController extends FeedController
}
// creating the remove form
public function rmForm($collection)
public function rmForm(object $collection): FormView
{
$rm = Form::create([
['remove_' . $collection->getId(), SubmitType::class, [
@@ -180,7 +194,7 @@ abstract class MetaCollectionController extends FeedController
]);
$rm->handleRequest($this->request);
if ($rm->isSubmitted()) {
$this->parent->removeCollection($this->id, $this->nick, $collection);
$this->parent->removeCollection($this->id, $this->nickname, $collection);
DB::flush();
throw new RedirectException();
}
@@ -198,12 +212,18 @@ abstract class MetaCollectionController extends FeedController
];
}
/**
* @return ControllerResultType
*/
public function collectionsEntryViewNotesByNickname(Request $request, string $nickname, int $cid): array
{
$user = DB::findOneBy(LocalUser::class, ['nickname' => $nickname]);
return self::collectionsEntryViewNotesByActorId($request, $user->getId(), $cid);
}
/**
* @return ControllerResultType
*/
public function collectionsEntryViewNotesByActorId(Request $request, int $id, int $cid): array
{
$collection = $this->getCollectionBy($id, $cid);

View File

@@ -4,6 +4,11 @@ declare(strict_types = 1);
namespace Component\Collection\Util\Controller;
class OrderedCollection extends Collection
/**
* @template T
*
* @extends Collection<T>
*/
abstract class OrderedCollection extends Collection
{
}

View File

@@ -31,7 +31,7 @@ declare(strict_types = 1);
namespace Component\Collection\Util;
use App\Core\DB\DB;
use App\Core\DB;
use App\Core\Event;
use App\Core\Form;
use function App\Core\I18n\_m;
@@ -39,11 +39,15 @@ use App\Entity\Actor;
use App\Util\Common;
use App\Util\Exception\RedirectException;
use App\Util\Formatting;
use EventResult;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\HttpFoundation\Request;
/**
* @template T
* */
trait MetaCollectionTrait
{
//protected const SLUG = 'collection';
@@ -52,40 +56,44 @@ trait MetaCollectionTrait
/**
* create a collection owned by Actor $owner.
*
* @param Actor $owner The collection's owner
* @param array $vars Page vars sent by AppendRightPanelBlock event
* @param string $name Collection's name
* @param Actor $owner The collection's owner
* @param array<string, mixed> $vars Page vars sent by AppendRightPanelBlock event
* @param string $name Collection's name
*/
abstract protected function createCollection(Actor $owner, array $vars, string $name);
abstract protected function createCollection(Actor $owner, array $vars, string $name): void;
/**
* remove item from collections.
*
* @param Actor $owner Current user
* @param array $vars Page vars sent by AppendRightPanelBlock event
* @param array $items Array of collections's ids to remove the current item from
* @param array $collections List of ids of collections owned by $owner
* @param Actor $owner Current user
* @param array<string, mixed> $vars Page vars sent by AppendRightPanelBlock event
* @param int[] $items Array of collections's ids to remove the current item from
* @param int[] $collections List of ids of collections owned by $owner
*/
abstract protected function removeItem(Actor $owner, array $vars, array $items, array $collections);
abstract protected function removeItem(Actor $owner, array $vars, array $items, array $collections): bool;
/**
* add item to collections.
*
* @param Actor $owner Current user
* @param array $vars Page vars sent by AppendRightPanelBlock event
* @param array $items Array of collections's ids to add the current item to
* @param array $collections List of ids of collections owned by $owner
* @param Actor $owner Current user
* @param array<string, mixed> $vars Page vars sent by AppendRightPanelBlock event
* @param int[] $items Array of collections's ids to add the current item to
* @param int[] $collections List of ids of collections owned by $owner
*/
abstract protected function addItem(Actor $owner, array $vars, array $items, array $collections);
abstract protected function addItem(Actor $owner, array $vars, array $items, array $collections): void;
/**
* Check the route to determine whether the widget should be added
*
* @param array<string, mixed> $vars
*/
abstract protected function shouldAddToRightPanel(Actor $user, $vars, Request $request): bool;
abstract protected function shouldAddToRightPanel(Actor $user, array $vars, Request $request): bool;
/**
* Get array of collections's owned by $actor
*
* @param Actor $owner Collection's owner
* @param ?array $vars Page vars sent by AppendRightPanelBlock event
* @param bool $ids_only if true, the function must return only the primary key or each collections
* @param Actor $owner Collection's owner
* @param null|array<string, mixed> $vars Page vars sent by AppendRightPanelBlock event
* @param bool $ids_only if true, the function must return only the primary key or each collections
*
* @return int[]|T[]
*/
abstract protected function getCollectionsBy(Actor $owner, ?array $vars = null, bool $ids_only = false): array;
@@ -93,8 +101,11 @@ trait MetaCollectionTrait
* Append Collections widget to the right panel.
* It's compose of two forms: one to select collections to add
* the current item to, and another to create a new collection.
*
* @param array<string, mixed> $vars
* @param string[] $res
*/
public function onAppendRightPanelBlock(Request $request, $vars, &$res): bool
public function onAppendRightPanelBlock(Request $request, array $vars, array &$res): EventResult
{
$user = Common::actor();
if (\is_null($user)) {
@@ -186,7 +197,10 @@ trait MetaCollectionTrait
return Event::next;
}
public function onEndShowStyles(array &$styles, string $route): bool
/**
* @param string[] $styles
*/
public function onEndShowStyles(array &$styles, string $route): EventResult
{
$styles[] = 'components/Collection/assets/css/widget.css';
$styles[] = 'components/Collection/assets/css/pages.css';

View File

@@ -32,6 +32,9 @@ abstract class Parser
{
/**
* Merge $parts into $criteria_arr
*
* @param mixed[] $parts
* @param Criteria[] $criteria_arr
*/
private static function connectParts(array &$parts, array &$criteria_arr, string $last_op, mixed $eb, bool $force = false): void
{

View File

@@ -28,11 +28,11 @@ declare(strict_types = 1);
namespace Component\Conversation\Controller;
use App\Core\Cache;
use App\Core\DB\DB;
use App\Core\DB;
use App\Core\Form;
use function App\Core\I18n\_m;
use App\Core\Log;
use App\Core\Router\Router;
use App\Core\Router;
use App\Entity\Note;
use App\Util\Common;
use App\Util\Exception\ClientException;
@@ -46,6 +46,9 @@ use Component\Conversation\Entity\ConversationMute;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\HttpFoundation\Request;
/**
* @extends FeedController<\App\Entity\Note>
*/
class Conversation extends FeedController
{
/**
@@ -55,7 +58,10 @@ class Conversation extends FeedController
*
* @throws \App\Util\Exception\ServerException
*
* @return array Array containing keys: 'notes' (all known notes in the given Conversation), 'should_format' (boolean, stating if onFormatNoteList events may or not format given notes), 'page_title' (used as the title header)
* @return ControllerResultType Array containing keys: 'notes' (all known
* notes in the given Conversation), 'should_format' (boolean, stating if
* onFormatNoteList events may or not format given notes), 'page_title'
* (used as the title header)
*/
public function showConversation(Request $request, int $conversation_id): array
{
@@ -83,7 +89,7 @@ class Conversation extends FeedController
* @throws NoSuchNoteException
* @throws ServerException
*
* @return array
* @return ControllerResultType
*/
public function addReply(Request $request)
{
@@ -103,7 +109,7 @@ class Conversation extends FeedController
* @throws \App\Util\Exception\RedirectException
* @throws \App\Util\Exception\ServerException
*
* @return array Array containing templating where the form is to be rendered, and the form itself
* @return ControllerResultType Array containing templating where the form is to be rendered, and the form itself
*/
public function muteConversation(Request $request, int $conversation_id)
{

View File

@@ -28,12 +28,11 @@ declare(strict_types = 1);
namespace Component\Conversation;
use App\Core\Cache;
use App\Core\DB\DB;
use App\Core\DB;
use App\Core\Event;
use function App\Core\I18n\_m;
use App\Core\Modules\Component;
use App\Core\Router\RouteLoader;
use App\Core\Router\Router;
use App\Core\Router;
use App\Entity\Activity;
use App\Entity\Actor;
use App\Entity\Note;
@@ -41,12 +40,13 @@ use App\Util\Common;
use App\Util\Formatting;
use Component\Conversation\Entity\Conversation as ConversationEntity;
use Component\Conversation\Entity\ConversationMute;
use EventResult;
use Functional as F;
use Symfony\Component\HttpFoundation\Request;
class Conversation extends Component
{
public function onAddRoute(RouteLoader $r): bool
public function onAddRoute(Router $r): EventResult
{
$r->connect('conversation', '/conversation/{conversation_id<\d+>}', [Controller\Conversation::class, 'showConversation']);
$r->connect('conversation_mute', '/conversation/{conversation_id<\d+>}/mute', [Controller\Conversation::class, 'muteConversation']);
@@ -95,17 +95,17 @@ class Conversation extends Component
* HTML rendering event that adds a reply link as a note
* action, if a user is logged in.
*
* @param \App\Entity\Note $note The Note being rendered
* @param array $actions Contains keys 'url' (linking 'conversation_reply_to'
* route), 'title' (used as title for aforementioned url),
* 'classes' (CSS styling classes used to visually inform the user of action context),
* 'id' (HTML markup id used to redirect user to this anchor upon performing the action)
* @param \App\Entity\Note $note The Note being rendered
* @param array{url: string, title: string, classes: string, id: string} $actions
* Contains keys 'url' (linking 'conversation_reply_to' route),
* 'title' (used as title for aforementioned url), 'classes' (CSS styling
* classes used to visually inform the user of action context), 'id' (HTML
* markup id used to redirect user to this anchor upon performing the
* action)
*
* @throws \App\Util\Exception\ServerException
*
* @return bool EventHook
*/
public function onAddNoteActions(Request $request, Note $note, array &$actions): bool
public function onAddNoteActions(Request $request, Note $note, array &$actions): EventResult
{
if (\is_null(Common::user())) {
return Event::next;
@@ -140,12 +140,13 @@ class Conversation extends Component
/**
* Append on note information about user actions.
*
* @param array $vars Contains information related to Note currently being rendered
* @param array $result Contains keys 'actors', and 'action'. Needed to construct a string, stating who ($result['actors']), has already performed a reply ($result['action']), in the given Note (vars['note'])
*
* @return bool EventHook
* @param array<string, mixed> $vars Contains information related to Note currently being rendered
* @param array{actors: Actor[], action: string} $result
*cContains keys 'actors', and 'action'. Needed to construct a string,
* stating who ($result['actors']), has already performed a reply
* ($result['action']), in the given Note (vars['note'])
*/
public function onAppendCardNote(array $vars, array &$result): bool
public function onAppendCardNote(array $vars, array &$result): EventResult
{
if (str_contains($vars['request']->getPathInfo(), 'conversation')) {
return Event::next;
@@ -195,10 +196,8 @@ class Conversation extends Component
*
* @param \App\Entity\Actor $actor The Actor currently attempting to post a Note
* @param null|\App\Entity\Actor $context_actor The 'owner' of the current route (e.g. Group or Actor), used to target it
*
* @return bool EventHook
*/
public function onPostingGetContextActor(Request $request, Actor $actor, ?Actor &$context_actor): bool
public function onPostingGetContextActor(Request $request, Actor $actor, ?Actor &$context_actor): EventResult
{
$to_note_id = $this->getReplyToIdFromRequest($request);
if (!\is_null($to_note_id)) {
@@ -212,14 +211,12 @@ class Conversation extends Component
/**
* Posting event to add extra information to Component\Posting form data
*
* @param array $data Transport data to be filled with reply_to_id
* @param array{reply_to_id: int} $data Transport data to be filled with reply_to_id
*
* @throws \App\Util\Exception\ClientException
* @throws \App\Util\Exception\NoSuchNoteException
*
* @return bool EventHook
*/
public function onPostingModifyData(Request $request, Actor $actor, array &$data): bool
public function onPostingModifyData(Request $request, Actor $actor, array &$data): EventResult
{
$to_note_id = $this->getReplyToIdFromRequest($request);
if (!\is_null($to_note_id)) {
@@ -232,8 +229,10 @@ class Conversation extends Component
/**
* Add minimal Note card to RightPanel template
*
* @param string[] $elements
*/
public function onPrependPostingForm(Request $request, array &$elements): bool
public function onPrependPostingForm(Request $request, array &$elements): EventResult
{
$elements[] = Formatting::twigRenderFile('cards/blocks/note_compact_wrapper.html.twig', ['note' => Note::getById((int) $request->query->get('reply_to_id'))]);
return Event::next;
@@ -245,10 +244,8 @@ class Conversation extends Component
*
* @param \App\Entity\Note $note Note being deleted
* @param \App\Entity\Actor $actor Actor that performed the delete action
*
* @return bool EventHook
*/
public function onNoteDeleteRelated(Note &$note, Actor $actor): bool
public function onNoteDeleteRelated(Note &$note, Actor $actor): EventResult
{
// Ensure we have the most up to date replies
Cache::delete(Note::cacheKeys($note->getId())['replies']);
@@ -260,14 +257,14 @@ class Conversation extends Component
/**
* Adds extra actions related to Conversation Component, that act upon/from the given Note.
*
* @param \App\Entity\Note $note Current Note being rendered
* @param array $actions Containing 'url' (Controller connected route), 'title' (used in anchor link containing the url), ?'classes' (CSS classes required for styling, if needed)
* @param \App\Entity\Note $note Current Note being rendered
* @param array{url: string, title: string, classes?: string} $actions Containing 'url' (Controller connected
* route), 'title' (used in anchor link containing the url), ?'classes' (CSS classes required for styling, if
* needed)
*
* @throws \App\Util\Exception\ServerException
*
* @return bool EventHook
*/
public function onAddExtraNoteActions(Request $request, Note $note, array &$actions): bool
public function onAddExtraNoteActions(Request $request, Note $note, array &$actions): EventResult
{
if (\is_null($user = Common::user())) {
return Event::next;
@@ -300,10 +297,8 @@ class Conversation extends Component
* Prevents new Notifications to appear for muted conversations
*
* @param Activity $activity Notification Activity
*
* @return bool EventHook
*/
public function onNewNotificationShould(Activity $activity, Actor $actor): bool
public function onNewNotificationShould(Activity $activity, Actor $actor): EventResult
{
if ($activity->getObjectType() === 'note' && ConversationMute::isMuted($activity, $actor)) {
return Event::stop;

View File

@@ -23,9 +23,9 @@ declare(strict_types = 1);
namespace Component\Conversation\Entity;
use App\Core\DB\DB;
use App\Core\DB;
use App\Core\Entity;
use App\Core\Router\Router;
use App\Core\Router;
/**
* Entity class for Conversations

View File

@@ -24,7 +24,7 @@ declare(strict_types = 1);
namespace Component\Conversation\Entity;
use App\Core\Cache;
use App\Core\DB\DB;
use App\Core\DB;
use App\Core\Entity;
use App\Entity\Activity;
use App\Entity\Actor;

View File

@@ -41,10 +41,15 @@ use App\Util\HTML\Heading;
use Component\Collection\Util\Controller\FeedController;
use Symfony\Component\HttpFoundation\Request;
/**
* @extends FeedController<\App\Entity\Note>
*/
class Feeds extends FeedController
{
/**
* The Planet feed represents every local post. Which is what this instance has to share with the universe.
*
* @return ControllerResultType
*/
public function public(Request $request): array
{
@@ -60,6 +65,8 @@ class Feeds extends FeedController
/**
* The Home feed represents everything that concerns a certain actor (its subscriptions)
*
* @return ControllerResultType
*/
public function home(Request $request): array
{

View File

@@ -25,12 +25,13 @@ namespace Component\Feed;
use App\Core\Event;
use App\Core\Modules\Component;
use App\Core\Router\RouteLoader;
use App\Core\Router;
use Component\Feed\Controller as C;
use EventResult;
class Feed extends Component
{
public function onAddRoute(RouteLoader $r): bool
public function onAddRoute(Router $r): EventResult
{
$r->connect('feed_public', '/feed/public', [C\Feeds::class, 'public']);
$r->connect('feed_home', '/feed/home', [C\Feeds::class, 'home']);

View File

@@ -23,7 +23,7 @@ declare(strict_types = 1);
namespace Component\Feed\tests\Controller;
use App\Core\Router\Router;
use App\Core\Router;
use App\Util\GNUsocialTestCase;
use Component\Feed\Controller\Feeds;
use Jchook\AssertThrows\AssertThrows;

View File

@@ -34,7 +34,7 @@ declare(strict_types = 1);
namespace Component\FreeNetwork\Controller;
use App\Core\DB\DB;
use App\Core\DB;
use function App\Core\I18n\_m;
use App\Util\Common;
use Component\Collection\Util\Controller\FeedController;

View File

@@ -32,7 +32,7 @@ declare(strict_types = 1);
namespace Component\FreeNetwork\Entity;
use App\Core\DB\DB;
use App\Core\DB;
use App\Core\Entity;
use App\Entity\Actor;
use Component\FreeNetwork\Util\Discovery;

View File

@@ -21,15 +21,14 @@ declare(strict_types = 1);
namespace Component\FreeNetwork;
use App\Core\DB\DB;
use App\Core\DB;
use App\Core\Event;
use App\Core\GSFile;
use App\Core\HTTPClient;
use function App\Core\I18n\_m;
use App\Core\Log;
use App\Core\Modules\Component;
use App\Core\Router\RouteLoader;
use App\Core\Router\Router;
use App\Core\Router;
use App\Entity\Activity;
use App\Entity\Actor;
use App\Entity\LocalUser;
@@ -55,6 +54,7 @@ use Component\FreeNetwork\Util\WebfingerResource;
use Component\FreeNetwork\Util\WebfingerResource\WebfingerResourceActor;
use Component\FreeNetwork\Util\WebfingerResource\WebfingerResourceNote;
use Doctrine\Common\Collections\ExpressionBuilder;
use EventResult;
use Exception;
use const PREG_SET_ORDER;
use Symfony\Component\HttpFoundation\JsonResponse;
@@ -80,13 +80,13 @@ class FreeNetwork extends Component
public const OAUTH_AUTHORIZE_REL = 'http://apinamespace.org/oauth/authorize';
private static array $protocols = [];
public function onInitializeComponent(): bool
public function onInitializeComponent(): EventResult
{
Event::handle('AddFreeNetworkProtocol', [&self::$protocols]);
return Event::next;
}
public function onAddRoute(RouteLoader $m): bool
public function onAddRoute(Router $m): EventResult
{
// Feeds
$m->connect('feed_network', '/feed/network', [Feeds::class, 'network']);
@@ -112,7 +112,7 @@ class FreeNetwork extends Component
return Event::next;
}
public function onCreateDefaultFeeds(int $actor_id, LocalUser $user, int &$ordering)
public function onCreateDefaultFeeds(int $actor_id, LocalUser $user, int &$ordering): EventResult
{
DB::persist(\App\Entity\Feed::create(['actor_id' => $actor_id, 'url' => Router::url($route = 'feed_network'), 'route' => $route, 'title' => _m('Meteorites'), 'ordering' => $ordering++]));
DB::persist(\App\Entity\Feed::create(['actor_id' => $actor_id, 'url' => Router::url($route = 'feed_clique'), 'route' => $route, 'title' => _m('Planetary System'), 'ordering' => $ordering++]));
@@ -120,7 +120,7 @@ class FreeNetwork extends Component
return Event::next;
}
public function onStartGetProfileAcctUri(Actor $profile, &$acct): bool
public function onStartGetProfileAcctUri(Actor $profile, &$acct): EventResult
{
$wfr = new WebFingerResourceActor($profile);
try {
@@ -148,7 +148,7 @@ class FreeNetwork extends Component
* @throws NoSuchActorException
* @throws ServerException
*/
public function onEndGetWebFingerResource(string $resource, ?WebfingerResource &$target = null, array $args = []): bool
public function onEndGetWebFingerResource(string $resource, ?WebfingerResource &$target = null, array $args = []): EventResult
{
// * Either we didn't find the profile, then we want to make
// the $profile variable null for clarity.
@@ -224,7 +224,7 @@ class FreeNetwork extends Component
return Event::next;
}
public function onStartHostMetaLinks(array &$links): bool
public function onStartHostMetaLinks(array &$links): EventResult
{
foreach (Discovery::supportedMimeTypes() as $type) {
$links[] = new XML_XRD_Element_Link(
@@ -244,8 +244,10 @@ class FreeNetwork extends Component
/**
* Add a link header for LRDD Discovery
*
* @param mixed $action
*/
public function onStartShowHTML($action): bool
public function onStartShowHTML($action): EventResult
{
if ($action instanceof ShowstreamAction) {
$resource = $action->getTarget()->getUri();
@@ -258,13 +260,13 @@ class FreeNetwork extends Component
return Event::next;
}
public function onStartDiscoveryMethodRegistration(Discovery $disco): bool
public function onStartDiscoveryMethodRegistration(Discovery $disco): EventResult
{
$disco->registerMethod('\Component\FreeNetwork\Util\LrddMethod\LrddMethodWebfinger');
return Event::next;
}
public function onEndDiscoveryMethodRegistration(Discovery $disco): bool
public function onEndDiscoveryMethodRegistration(Discovery $disco): EventResult
{
$disco->registerMethod('\Component\FreeNetwork\Util\LrddMethod\LrddMethodHostMeta');
$disco->registerMethod('\Component\FreeNetwork\Util\LrddMethod\LrddMethodLinkHeader');
@@ -276,7 +278,7 @@ class FreeNetwork extends Component
* @throws ClientException
* @throws ServerException
*/
public function onControllerResponseInFormat(string $route, array $accept_header, array $vars, ?Response &$response = null): bool
public function onControllerResponseInFormat(string $route, array $accept_header, array $vars, ?Response &$response = null): EventResult
{
if (!\in_array($route, ['freenetwork_hostmeta', 'freenetwork_hostmeta_format', 'freenetwork_webfinger', 'freenetwork_webfinger_format', 'freenetwork_ownerxrd'])) {
return Event::next;
@@ -344,6 +346,7 @@ class FreeNetwork extends Component
* @param string $preMention Character(s) that signals a mention ('@', '!'...)
*
* @return array the matching URLs (without @ or acct:) and each respective position in the given string
*
* @example.com/mublog/user
*/
public static function extractUrlMentions(string $text, string $preMention = '@'): array
@@ -375,9 +378,10 @@ class FreeNetwork extends Component
* @param $mentions
*
* @return bool hook return value
*
* @example.com/mublog/user
*/
public function onEndFindMentions(Actor $sender, string $text, array &$mentions): bool
public function onEndFindMentions(Actor $sender, string $text, array &$mentions): EventResult
{
$matches = [];
@@ -496,6 +500,9 @@ class FreeNetwork extends Component
return Event::next;
}
/**
* @param Actor[] $targets
*/
public static function notify(Actor $sender, Activity $activity, array $targets, ?string $reason = null): bool
{
foreach (self::$protocols as $protocol) {
@@ -517,8 +524,11 @@ class FreeNetwork extends Component
/**
* Add fediverse: query expression
* // TODO: adding WebFinger would probably be nice
*
* @param mixed $note_expr
* @param mixed $actor_expr
*/
public function onCollectionQueryCreateExpression(ExpressionBuilder $eb, string $term, ?string $locale, ?Actor $actor, &$note_expr, &$actor_expr): bool
public function onCollectionQueryCreateExpression(ExpressionBuilder $eb, string $term, ?string $locale, ?Actor $actor, &$note_expr, &$actor_expr): EventResult
{
if (Formatting::startsWith($term, ['fediverse:'])) {
foreach (self::$protocols as $protocol) {
@@ -531,7 +541,7 @@ class FreeNetwork extends Component
return Event::next;
}
public function onPluginVersion(array &$versions): bool
public function onPluginVersion(array &$versions): EventResult
{
$versions[] = [
'name' => 'WebFinger',

View File

@@ -6,7 +6,7 @@ namespace Component\FreeNetwork\Util\WebfingerResource;
use App\Core\Event;
use App\Core\Log;
use App\Core\Router\Router;
use App\Core\Router;
use App\Entity\Actor;
use App\Util\Common;
use Component\FreeNetwork\Exception\WebfingerReconstructionException;

View File

@@ -26,7 +26,7 @@ namespace Component\Group\Controller;
use App\Core\ActorLocalRoles;
use App\Core\Cache;
use App\Core\Controller;
use App\Core\DB\DB;
use App\Core\DB;
use App\Core\Form;
use function App\Core\I18n\_m;
use App\Core\Log;
@@ -61,6 +61,8 @@ class Group extends Controller
*
* @throws RedirectException
* @throws ServerException
*
* @return ControllerResultType
*/
public function groupCreate(Request $request): array
{
@@ -89,6 +91,8 @@ class Group extends Controller
* @throws NicknameTooLongException
* @throws NotFoundException
* @throws ServerException
*
* @return ControllerResultType
*/
public function groupSettings(Request $request, int $id): array
{

View File

@@ -24,10 +24,10 @@ declare(strict_types = 1);
namespace Component\Group\Controller;
use App\Core\Cache;
use App\Core\DB\DB;
use App\Core\DB;
use App\Core\Form;
use function App\Core\I18n\_m;
use App\Core\Router\Router;
use App\Core\Router;
use App\Entity\Actor;
use App\Entity as E;
use App\Util\Common;
@@ -40,10 +40,15 @@ use Component\Subscription\Entity\ActorSubscription;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\HttpFoundation\Request;
/**
* @extends FeedController<\App\Entity\Note>
*/
class GroupFeed extends FeedController
{
/**
* @throws ServerException
*
* @return ControllerResultType
*/
public function groupView(Request $request, Actor $group): array
{
@@ -80,6 +85,7 @@ class GroupFeed extends FeedController
WHERE act.object_type = 'note' AND act.id IN
(SELECT att.activity_id FROM \Component\Notification\Entity\Notification AS att WHERE att.target_id = :id)
)
ORDER BY n.created DESC
EOF, ['id' => $group->getId()]);
return [
@@ -95,6 +101,8 @@ class GroupFeed extends FeedController
/**
* @throws ClientException
* @throws ServerException
*
* @return ControllerResultType
*/
public function groupViewId(Request $request, int $id): array
{
@@ -118,6 +126,8 @@ class GroupFeed extends FeedController
*
* @throws ClientException
* @throws ServerException
*
* @return ControllerResultType
*/
public function groupViewNickname(Request $request, string $nickname): array
{

View File

@@ -21,7 +21,7 @@ declare(strict_types = 1);
namespace Component\Group\Entity;
use App\Core\DB\DB;
use App\Core\DB;
use App\Core\Entity;
use App\Entity\Actor;
use App\Util\Exception\NicknameEmptyException;

View File

@@ -24,8 +24,7 @@ namespace Component\Group;
use App\Core\Event;
use function App\Core\I18n\_m;
use App\Core\Modules\Component;
use App\Core\Router\RouteLoader;
use App\Core\Router\Router;
use App\Core\Router;
use App\Entity\Activity;
use App\Entity\Actor;
use App\Util\Common;
@@ -34,11 +33,12 @@ use App\Util\Nickname;
use Component\Group\Controller as C;
use Component\Group\Entity\LocalGroup;
use Component\Notification\Notification;
use EventResult;
use Symfony\Component\HttpFoundation\Request;
class Group extends Component
{
public function onAddRoute(RouteLoader $r): bool
public function onAddRoute(Router $r): EventResult
{
$r->connect(id: 'group_actor_view_id', uri_path: '/group/{id<\d+>}', target: [C\GroupFeed::class, 'groupViewId']);
$r->connect(id: 'group_actor_view_nickname', uri_path: '/!{nickname<' . Nickname::DISPLAY_FMT . '>}', target: [C\GroupFeed::class, 'groupViewNickname']);
@@ -50,13 +50,20 @@ class Group extends Component
/**
* Enqueues a notification for an Actor (such as person or group) which means
* it shows up in their home feed and such.
*
* @param Actor[] $targets
*/
public function onNewNotificationWithTargets(Actor $sender, Activity $activity, array $targets = [], ?string $reason = null): bool
public function onNewNotificationStart(Actor $sender, Activity $activity, array $targets = [], ?string $reason = null): EventResult
{
foreach ($targets as $target) {
if ($target->isGroup()) {
// The Group announces to its subscribers
Notification::notify($target, $activity, $target->getSubscribers(), $reason);
Notification::notify(
sender: $target,
activity: $activity,
targets: $target->getSubscribers(),
reason: $reason,
);
}
}
@@ -65,8 +72,11 @@ class Group extends Component
/**
* Add an <a href=group_actor_settings> to the profile card for groups, if the current actor can access them
*
* @param array<string, mixed> $vars
* @param string[] $res
*/
public function onAppendCardProfile(array $vars, array &$res): bool
public function onAppendCardProfile(array $vars, array &$res): EventResult
{
$actor = Common::actor();
$group = $vars['actor'];
@@ -75,7 +85,6 @@ class Group extends Component
$url = Router::url('group_actor_settings', ['id' => $group->getId()]);
$res[] = HTML::html(['a' => ['attrs' => ['href' => $url, 'title' => _m('Edit group settings'), 'class' => 'profile-extra-actions'], _m('Group settings')]]);
}
$res[] = HTML::html(['a' => ['attrs' => ['href' => Router::url('blog_post', ['in' => $group->getId()]), 'title' => _m('Make a new blog post'), 'class' => 'profile-extra-actions'], _m('Post in blog')]]);
}
return Event::next;
}
@@ -99,13 +108,16 @@ class Group extends Component
case 'group_actor_view_nickname':
return LocalGroup::getActorByNickname($identifier);
case 'group_actor_view_id':
return Actor::getById($identifier);
return Actor::getById((int) $identifier);
}
}
return null;
}
public function onPostingFillTargetChoices(Request $request, Actor $actor, array &$targets): bool
/**
* @param Actor[] $targets
*/
public function onPostingFillTargetChoices(Request $request, Actor $actor, array &$targets): EventResult
{
$group = $this->getGroupFromContext($request);
if (!\is_null($group)) {
@@ -124,7 +136,7 @@ class Group extends Component
*
* @param null|Actor $context_actor Actor group, if current route is part of an existing Group set of routes
*/
public function onPostingGetContextActor(Request $request, Actor $actor, ?Actor &$context_actor): bool
public function onPostingGetContextActor(Request $request, Actor $actor, ?Actor &$context_actor): EventResult
{
$ctx = $this->getGroupFromContext($request);
if (!\is_null($ctx)) {

View File

@@ -7,7 +7,7 @@
<h1>Settings</h1>
<ul>
<li>
{% set profile_tabs = [{'title': 'Personal Info', 'desc': 'Nickname, Homepage, Bio, Self Tags and more.', 'id': 'settings-personal-info', 'form': personal_info_form}] %}
{% set profile_tabs = [{'title': 'Personal Info', 'desc': 'Nickname, Homepage, Bio and more.', 'id': 'settings-personal-info', 'form': personal_info_form}] %}
{% set profile_tabs = profile_tabs|merge(handle_event('PopulateSettingsTabs', app.request, 'profile')) %}
{{ macros.settings_details_container('Profile', 'Personal Information, Avatar and Profile', 'settings-profile-details', profile_tabs, _context) }}
</li>

View File

@@ -21,7 +21,7 @@ declare(strict_types = 1);
namespace Component\Group\tests\Entity;
use App\Core\DB\DB;
use App\Core\DB;
use App\Entity\Actor;
use App\Util\GNUsocialTestCase;
use Component\Group\Entity\LocalGroup;

View File

@@ -25,7 +25,7 @@ namespace Component\Language\Controller;
use App\Core\Cache;
use App\Core\Controller;
use App\Core\DB\DB;
use App\Core\DB;
use App\Core\Form;
use function App\Core\I18n\_m;
use App\Util\Common;
@@ -100,6 +100,8 @@ class Language extends Controller
* @throws NoLoggedInUser
* @throws RedirectException
* @throws ServerException
*
* @return ControllerResultType
*/
public function sortLanguages(Request $request): array
{

View File

@@ -24,7 +24,7 @@ declare(strict_types = 1);
namespace Component\Language\Entity;
use App\Core\Cache;
use App\Core\DB\DB;
use App\Core\DB;
use App\Core\Entity;
use App\Entity\Actor;
use App\Entity\LocalUser;
@@ -119,6 +119,9 @@ class ActorLanguage extends Entity
) ?: [Language::getByLocale(Common::config('site', 'language'))];
}
/**
* @return int[]
*/
public static function getActorRelatedLanguagesIds(Actor $actor): array
{
return Cache::getList(

View File

@@ -24,7 +24,7 @@ declare(strict_types = 1);
namespace Component\Language\Entity;
use App\Core\Cache;
use App\Core\DB\DB;
use App\Core\DB;
use App\Core\Entity;
use function App\Core\I18n\_m;
use App\Entity\Actor;
@@ -116,7 +116,7 @@ class Language extends Entity
return Cache::getHashMapKey(
map_key: 'languages-id',
key: (string) $id,
calculate_map: fn () => F\reindex(DB::dql('select l from language l'), fn (self $l) => (string) $l->getId()),
calculate_map: fn () => F\reindex(DB::dql('SELECT l FROM \Component\Language\Entity\Language AS l'), fn (self $l) => (string) $l->getId()),
);
}
@@ -125,7 +125,7 @@ class Language extends Entity
return Cache::getHashMapKey(
'languages',
$locale,
calculate_map: fn () => F\reindex(DB::dql('select l from language l'), fn (self $l) => $l->getLocale()),
calculate_map: fn () => F\reindex(DB::dql('SELECT l FROM \Component\Language\Entity\Language AS l'), fn (self $l) => $l->getLocale()),
);
}
@@ -134,16 +134,21 @@ class Language extends Entity
return self::getById($note->getLanguageId());
}
/**
* @return array<string, string>
*/
public static function getLanguageChoices(): array
{
$langs = Cache::getHashMap(
'languages',
fn () => F\reindex(DB::dql('select l from language l'), fn (self $l) => $l->getLocale()),
fn () => F\reindex(DB::dql('SELECT l FROM \Component\Language\Entity\Language AS l'), fn (self $l) => $l->getLocale()),
);
return array_merge(...F\map(array_values($langs), fn ($l) => $l->toChoiceFormat()));
}
/**
* @return array<string, string> */
public function toChoiceFormat(): array
{
return [_m($this->getLongDisplay()) => $this->getLocale()];
@@ -152,6 +157,8 @@ class Language extends Entity
/**
* Get all the available languages as well as the languages $actor
* prefers and are appropriate for posting in/to $context_actor
*
* @return array{array<string, string>, array<string, string>}
*/
public static function getSortedLanguageChoices(?Actor $actor, ?Actor $context_actor, ?bool $use_short_display): array
{

View File

@@ -23,7 +23,7 @@ namespace Component\Language;
use App\Core\Event;
use App\Core\Modules\Component;
use App\Core\Router\RouteLoader;
use App\Core\Router;
use App\Entity\Actor;
use App\Entity\Note;
use App\Util\Formatting;
@@ -33,18 +33,22 @@ use Component\Language\Entity\ActorLanguage;
use Doctrine\Common\Collections\ExpressionBuilder;
use Doctrine\ORM\Query\Expr;
use Doctrine\ORM\QueryBuilder;
use EventResult;
use Functional as F;
use Symfony\Component\HttpFoundation\Request;
class Language extends Component
{
public function onAddRoute(RouteLoader $r): bool
public function onAddRoute(Router $r): EventResult
{
$r->connect('settings_sort_languages', '/settings/sort_languages', [C\Language::class, 'sortLanguages']);
return Event::next;
}
public function onFilterNoteList(?Actor $actor, array &$notes, Request $request): bool
/**
* @param Note[] $notes
*/
public function onFilterNoteList(?Actor $actor, array &$notes, Request $request): EventResult
{
if (\is_null($actor)) {
return Event::next;
@@ -59,8 +63,11 @@ class Language extends Component
/**
* Populate $note_expr or $actor_expr with an expression to match a language
*
* @param mixed $note_expr
* @param mixed $actor_expr
*/
public function onCollectionQueryCreateExpression(ExpressionBuilder $eb, string $term, ?string $locale, ?Actor $actor, &$note_expr, &$actor_expr): bool
public function onCollectionQueryCreateExpression(ExpressionBuilder $eb, string $term, ?string $locale, ?Actor $actor, &$note_expr, &$actor_expr): EventResult
{
$search_term = str_contains($term, ':') ? explode(':', $term)[1] : $term;
@@ -103,7 +110,7 @@ class Language extends Component
return Event::next;
}
public function onCollectionQueryAddJoins(QueryBuilder &$note_qb, QueryBuilder &$actor_qb): bool
public function onCollectionQueryAddJoins(QueryBuilder &$note_qb, QueryBuilder &$actor_qb): EventResult
{
$note_aliases = $note_qb->getAllAliases();
if (!\in_array('note_language', $note_aliases)) {
@@ -116,7 +123,7 @@ class Language extends Component
$note_qb->leftJoin('Component\Language\Entity\Language', 'note_actor_language', Expr\Join::WITH, 'note_actor_language.id = actor_language.language_id');
}
$actor_aliases = $note_qb->getAllAliases();
$actor_aliases = $actor_qb->getAllAliases();
if (!\in_array('actor_language', $actor_aliases)) {
$actor_qb->leftJoin('Component\Language\Entity\ActorLanguage', 'actor_language', Expr\Join::WITH, 'actor.id = actor_language.actor_id');
}

View File

@@ -25,10 +25,10 @@ namespace Component\LeftPanel\Controller;
use App\Core\Cache;
use App\Core\Controller;
use App\Core\DB\DB;
use App\Core\DB;
use App\Core\Form;
use function App\Core\I18n\_m;
use App\Core\Router\Router;
use App\Core\Router;
use App\Entity\Feed;
use App\Util\Common;
use App\Util\Exception\ClientException;
@@ -104,7 +104,6 @@ class EditFeeds extends Controller
$feed->setUrl($fd[$md5 . '-url']);
$feed->setOrdering($fd[$md5 . '-order']);
$feed->setTitle($fd[$md5 . '-title']);
DB::merge($feed);
}
DB::flush();
Cache::delete($key);
@@ -119,7 +118,6 @@ class EditFeeds extends Controller
/** @var SubmitButton $remove_button */
$remove_button = $form->get($remove_id);
if ($remove_button->isClicked()) {
// @phpstan-ignore-next-line -- Doesn't quite understand that _this_ $opts for the current $form_definitions does have 'data'
DB::remove(DB::getReference('feed', ['actor_id' => $user->getId(), 'url' => $opts['data']]));
DB::flush();
Cache::delete($key);

View File

@@ -22,32 +22,34 @@ declare(strict_types = 1);
namespace Component\LeftPanel;
use App\Core\Cache;
use App\Core\DB\DB;
use App\Core\DB;
use App\Core\Event;
use function App\Core\I18n\_m;
use App\Core\Modules\Component;
use App\Core\Router\RouteLoader;
use App\Core\Router\Router;
use App\Core\Router;
use App\Entity\Actor;
use App\Entity\Feed;
use App\Util\Exception\ClientException;
use App\Util\Exception\NotFoundException;
use Component\LeftPanel\Controller as C;
use EventResult;
class LeftPanel extends Component
{
public function onAddRoute(RouteLoader $r): bool
public function onAddRoute(Router $r): EventResult
{
$r->connect('edit_feeds', '/edit-feeds', C\EditFeeds::class);
return Event::next;
}
/**
* @param array<string, string> $route_params
*
* @throws \App\Util\Exception\DuplicateFoundException
* @throws \App\Util\Exception\ServerException
* @throws ClientException
*/
public function onAppendFeed(Actor $actor, string $title, string $route, array $route_params): bool
public function onAppendFeed(Actor $actor, string $title, string $route, array $route_params): EventResult
{
$cache_key = Feed::cacheKey($actor);
$feeds = Feed::getFeeds($actor);

View File

@@ -21,7 +21,7 @@ declare(strict_types = 1);
namespace Component\Link\Entity;
use App\Core\DB\DB;
use App\Core\DB;
use App\Core\Entity;
use App\Core\Event;
use App\Core\GSFile;

View File

@@ -21,7 +21,7 @@ declare(strict_types = 1);
namespace Component\Link\Entity;
use App\Core\DB\DB;
use App\Core\DB;
use App\Core\Entity;
use App\Core\Event;
use DateTimeInterface;
@@ -85,15 +85,15 @@ class NoteToLink extends Entity
* properties of $obj with the associative array $args. Doesn't
* persist the result
*
* @param null|mixed $obj
* @param (array{link_id: int, note_id: int} & array<string, mixed>) $args
*/
public static function create(array $args, $obj = null)
public static function create(array $args, bool $_delegated_call = false): static
{
$link = DB::find('link', ['id' => $args['link_id']]);
$note = DB::find('note', ['id' => $args['note_id']]);
Event::handle('NewLinkFromNote', [$link, $note]);
$obj = new self();
return parent::create($args, $obj);
return parent::createOrUpdate(obj: $obj, args: $args);
}
public static function removeWhereNoteId(int $note_id): mixed

View File

@@ -23,7 +23,7 @@ declare(strict_types = 1);
namespace Component\Link;
use App\Core\DB\DB;
use App\Core\DB;
use App\Core\Event;
use App\Core\Modules\Component;
use App\Entity\Actor;
@@ -31,14 +31,33 @@ use App\Entity\Note;
use App\Util\Common;
use App\Util\HTML;
use Component\Link\Entity\NoteToLink;
use EventResult;
use InvalidArgumentException;
class Link extends Component
{
/**
* Extract URLs from $content and create the appropriate Link and NoteToLink entities
* Note that this persists both a Link and a NoteToLink
*
* @return array{ link: ?Entity\Link, note_to_link: ?NoteToLink }
*/
public function onProcessNoteContent(Note $note, string $content, string $content_type, array $process_note_content_extra_args = []): bool
public static function maybeCreateLink(string $url, int $note_id): array
{
try {
$link = Entity\Link::getOrCreate($url);
DB::persist($note_link = NoteToLink::create(['link_id' => $link->getId(), 'note_id' => $note_id]));
return ['link' => $link, 'note_to_link' => $note_link];
} catch (InvalidArgumentException) {
return ['link' => null, 'note_to_link' => null];
}
}
/**
* Extract URLs from $content and create the appropriate Link and NoteToLink entities
*
* @param array{ignoreLinks?: string[]} $process_note_content_extra_args
*/
public function onProcessNoteContent(Note $note, string $content, string $content_type, array $process_note_content_extra_args = []): EventResult
{
$ignore = $process_note_content_extra_args['ignoreLinks'] ?? [];
if (Common::config('attachments', 'process_links')) {
@@ -49,18 +68,13 @@ class Link extends Component
if (\in_array($match, $ignore)) {
continue;
}
try {
$link_id = Entity\Link::getOrCreate($match)->getId();
DB::persist(NoteToLink::create(['link_id' => $link_id, 'note_id' => $note->getId()]));
} catch (InvalidArgumentException) {
continue;
}
self::maybeCreateLink($match, $note->getId());
}
}
return Event::next;
}
public function onRenderPlainTextNoteContent(string &$text): bool
public function onRenderPlainTextNoteContent(string &$text): EventResult
{
$text = $this->replaceURLs($text);
return Event::next;
@@ -138,7 +152,12 @@ class Link extends Component
public const URL_SCHEME_NO_DOMAIN = 4;
public const URL_SCHEME_COLON_COORDINATES = 8;
public function URLSchemes($filter = null)
/**
* @param self::URL_SCHEME_COLON_COORDINATES|self::URL_SCHEME_COLON_DOUBLE_SLASH|self::URL_SCHEME_NO_DOMAIN|self::URL_SCHEME_SINGLE_COLON $filter
*
* @return string[]
*/
public function URLSchemes(?int $filter = null): array
{
// TODO: move these to config
$schemes = [
@@ -185,6 +204,7 @@ class Link extends Component
* Intermediate callback for `replaceURLs()`, which helps resolve some
* ambiguous link forms before passing on to the final callback.
*
* @param string[] $matches
* @param callable(string $text): string $callback: return replacement text
*/
private function callbackHelper(array $matches, callable $callback): string
@@ -264,7 +284,7 @@ class Link extends Component
return HTML::html(['a' => ['attrs' => $attrs, $url]], options: ['indent' => false]);
}
public function onNoteDeleteRelated(Note &$note, Actor $actor): bool
public function onNoteDeleteRelated(Note &$note, Actor $actor): EventResult
{
DB::wrapInTransaction(fn () => NoteToLink::removeWhereNoteId($note->getId()));
return Event::next;

View File

@@ -35,7 +35,7 @@ declare(strict_types = 1);
namespace Component\Notification\Controller;
use App\Core\Controller;
use App\Core\DB\DB;
use App\Core\DB;
use function App\Core\I18n\_m;
use App\Util\Common;
use Symfony\Component\HttpFoundation\Request;
@@ -44,6 +44,8 @@ class Feed extends Controller
{
/**
* Everything with attention to current user
*
* @return ControllerResultType
*/
public function notifications(Request $request): array
{
@@ -53,9 +55,9 @@ class Feed extends Controller
WHERE n.id IN (
SELECT act.object_id FROM \App\Entity\Activity AS act
WHERE act.object_type = 'note' AND act.id IN
(SELECT att.activity_id FROM \Component\Notification\Entity\Notification AS att WHERE att.target_id = :id)
(SELECT att.activity_id FROM \Component\Notification\Entity\Notification AS att WHERE att.target_id = :target_id)
)
EOF, ['id' => $user->getId()]);
EOF, [':target_id' => $user->getId()]);
return [
'_template' => 'collection/notes.html.twig',
'page_title' => _m('Notifications'),

View File

@@ -24,31 +24,51 @@ namespace Component\Notification\Entity;
use App\Core\Entity;
/**
* Entity for note attentions
* Entity for object attentions
*
* An attention is a form of persistent notification.
* It exists together and for as long as the object it belongs to.
* Creating an attention requires creating a Notification.
*
* @category DB
* @package GNUsocial
*
* @author Zach Copley <zach@status.net>
* @copyright 2010 StatusNet Inc.
* @author Mikael Nordfeldth <mmn@hethane.se>
* @copyright 2009-2014 Free Software Foundation, Inc http://www.fsf.org
* @author Diogo Peralta Cordeiro <@diogo.site>
* @copyright 2022 Free Software Foundation, Inc http://www.fsf.org
* @copyright 2021-2022 Free Software Foundation, Inc http://www.fsf.org
* @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
*/
class Attention extends Entity
{
// {{{ Autocode
// @codeCoverageIgnoreStart
private int $note_id;
private string $object_type;
private int $object_id;
private int $target_id;
public function setNoteId(int $note_id): self
public function setObjectType(string $object_type): self
{
$this->note_id = $note_id;
$this->object_type = mb_substr($object_type, 0, 32);
return $this;
}
public function getNoteId(): int
public function getObjectType(): string
{
return $this->note_id;
return $this->object_type;
}
public function setObjectId(int $object_id): self
{
$this->object_id = $object_id;
return $this;
}
public function getObjectId(): int
{
return $this->object_id;
}
public function setTargetId(int $target_id): self
@@ -68,15 +88,16 @@ class Attention extends Entity
public static function schemaDef(): array
{
return [
'name' => 'note_attention',
'description' => 'Note attentions to actors (that are not a mention)',
'name' => 'attention',
'description' => 'Attentions to actors (these are not mentions)',
'fields' => [
'note_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Note.id', 'multiplicity' => 'one to one', 'not null' => true, 'description' => 'note_id to give attention'],
'target_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Actor.id', 'multiplicity' => 'one to one', 'not null' => true, 'description' => 'actor_id for feed receiver'],
'object_type' => ['type' => 'varchar', 'length' => 32, 'not null' => true, 'description' => 'the name of the table this object refers to'],
'object_id' => ['type' => 'int', 'not null' => true, 'description' => 'id in the referenced table'],
'target_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Actor.id', 'multiplicity' => 'one to one', 'not null' => true, 'description' => 'actor_id for feed receiver'],
],
'primary key' => ['note_id', 'target_id'],
'primary key' => ['object_type', 'object_id', 'target_id'],
'indexes' => [
'attention_note_id_idx' => ['note_id'],
'attention_object_id_idx' => ['object_id'],
'attention_target_id_idx' => ['target_id'],
],
];

View File

@@ -21,24 +21,24 @@ declare(strict_types = 1);
namespace Component\Notification\Entity;
use App\Core\DB\DB;
use App\Core\DB;
use App\Core\Entity;
use App\Entity\Activity;
use App\Entity\Actor;
use DateTimeInterface;
/**
* Entity for attentions
* Entity for Notifications
*
* A Notification when isolated is a form of transient notification.
* When together with a persistent form of notification such as attentions or mentions,
* it records that the target was notified - which avoids re-notifying upon objects reconstructions.
*
* @category DB
* @package GNUsocial
*
* @author Zach Copley <zach@status.net>
* @copyright 2010 StatusNet Inc.
* @author Mikael Nordfeldth <mmn@hethane.se>
* @copyright 2009-2014 Free Software Foundation, Inc http://www.fsf.org
* @author Hugo Sales <hugo@hsal.es>
* @copyright 2020-2021 Free Software Foundation, Inc http://www.fsf.org
* @author Diogo Peralta Cordeiro <@diogo.site>
* @copyright 2021-2022 Free Software Foundation, Inc http://www.fsf.org
* @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
*/
class Notification extends Entity
@@ -117,7 +117,7 @@ class Notification extends Entity
/**
* Pull the complete list of known activity context notifications for this activity.
*
* @return array of integer actor ids (also group profiles)
* @return int[] actor ids (also group profiles)
*/
public static function getNotificationTargetIdsByActivity(int|Activity $activity_id): array
{
@@ -129,11 +129,17 @@ class Notification extends Entity
return $targets;
}
/**
* @return int[]
*/
public function getNotificationTargetsByActivity(int|Activity $activity_id): array
{
return DB::findBy(Actor::class, ['id' => $this->getNotificationTargetIdsByActivity($activity_id)]);
}
/**
* @return int[]
*/
public static function getAllActivitiesTargetedAtActor(Actor $actor): array
{
return DB::dql(<<<'EOF'

View File

@@ -21,26 +21,26 @@ declare(strict_types = 1);
namespace Component\Notification;
use App\Core\DB\DB;
use App\Core\DB;
use App\Core\Event;
use function App\Core\I18n\_m;
use App\Core\Log;
use App\Core\Modules\Component;
use App\Core\Queue\Queue;
use App\Core\Router\RouteLoader;
use App\Core\Router\Router;
use App\Core\Queue;
use App\Core\Router;
use App\Entity\Activity;
use App\Entity\Actor;
use App\Entity\LocalUser;
use App\Util\Exception\ServerException;
use Component\FreeNetwork\FreeNetwork;
use Component\Notification\Controller\Feed;
use EventResult;
use Exception;
use Throwable;
class Notification extends Component
{
public function onAddRoute(RouteLoader $m): bool
public function onAddRoute(Router $m): EventResult
{
$m->connect('feed_notifications', '/feed/notifications', [Feed::class, 'notifications']);
return Event::next;
@@ -49,7 +49,7 @@ class Notification extends Component
/**
* @throws ServerException
*/
public function onCreateDefaultFeeds(int $actor_id, LocalUser $user, int &$ordering): bool
public function onCreateDefaultFeeds(int $actor_id, LocalUser $user, int &$ordering): EventResult
{
DB::persist(\App\Entity\Feed::create([
'actor_id' => $actor_id,
@@ -64,24 +64,59 @@ class Notification extends Component
/**
* Enqueues a notification for an Actor (such as person or group) which means
* it shows up in their home feed and such.
* WARNING: It's highly advisable to have flushed any relevant objects before triggering this event.
*
* $targets should be of the shape:
* (int|Actor)[] // Prefer Actor whenever possible
* Example of $targets:
* [42, $actor_alice, $actor_bob] // Avoid repeating actors or ids
*
* @param Actor $sender The one responsible for this activity, take care not to include it in targets
* @param Activity $activity The activity responsible for the object being given to known to targets
* @param non-empty-array<Actor|int> $targets Attentions, Mentions, any other source. Should never be empty, you usually want to register an attention to every $sender->getSubscribers()
* @param null|string $reason An optional reason explaining why this notification exists
*/
public function onNewNotification(Actor $sender, Activity $activity, array $ids_already_known = [], ?string $reason = null): bool
public function onNewNotification(Actor $sender, Activity $activity, array $targets, ?string $reason = null): EventResult
{
$targets = $activity->getNotificationTargets(ids_already_known: $ids_already_known, sender_id: $sender->getId());
if (Event::handle('NewNotificationWithTargets', [$sender, $activity, $targets, $reason]) === Event::next) {
self::notify($sender, $activity, $targets, $reason);
// Ensure targets are all actor objects and unique
$effective_targets = [];
foreach ($targets as $target) {
if (\is_int($target)) {
$target_id = $target;
$target_object = null;
} else {
$target_id = $target->getId();
$target_object = $target;
}
if (!\array_key_exists(key: $target_id, array: $effective_targets)) {
$target_object ??= Actor::getById($target_id);
$effective_targets[$target_id] = $target_object;
}
}
unset($targets);
if (Event::handle('NewNotificationStart', [$sender, $activity, $effective_targets, $reason]) === Event::next) {
self::notify($sender, $activity, $effective_targets, $reason);
}
Event::handle('NewNotificationEnd', [$sender, $activity, $effective_targets, $reason]);
return Event::next;
}
public function onQueueNotificationLocal(Actor $sender, Activity $activity, Actor $target, ?string $reason, array &$retry_args): bool
/**
* @param mixed[] $retry_args
*/
public function onQueueNotificationLocal(Actor $sender, Activity $activity, Actor $target, ?string $reason, array &$retry_args): EventResult
{
// TODO: use https://symfony.com/doc/current/notifier.html
return Event::stop;
}
public function onQueueNotificationRemote(Actor $sender, Activity $activity, array $targets, ?string $reason, array &$retry_args): bool
/**
* @param Actor[] $targets
* @param mixed[] $retry_args
*/
public function onQueueNotificationRemote(Actor $sender, Activity $activity, array $targets, ?string $reason, array &$retry_args): EventResult
{
if (FreeNetwork::notify($sender, $activity, $targets, $reason)) {
return Event::stop;
@@ -91,21 +126,24 @@ class Notification extends Component
}
/**
* Bring given Activity to Targets's attention
* Bring given Activity to Targets' knowledge.
* This will flush a Notification to DB.
*
* @return true if successful, false otherwise
* @param Actor[] $targets
*
* @return bool true if successful, false otherwise
*/
public static function notify(Actor $sender, Activity $activity, array $targets, ?string $reason = null): bool
{
$remote_targets = [];
foreach ($targets as $target) {
if ($target->getIsLocal()) {
if ($target->hasBlocked($activity->getActor())) {
Log::info("Not saving reply to actor {$target->getId()} from sender {$sender->getId()} because of a block.");
if ($target->hasBlocked($author = $activity->getActor())) {
Log::info("Not saving notification to actor {$target->getId()} from sender {$sender->getId()} because receiver blocked author {$author->getId()}.");
continue;
}
if (Event::handle('NewNotificationShould', [$activity, $target]) === Event::next) {
if ($sender->getId() === $target->getId()
if ($sender->getId() === $target->getId()
|| $activity->getActorId() === $target->getId()) {
// The target already knows about this, no need to bother with a notification
continue;
@@ -113,7 +151,7 @@ class Notification extends Component
}
Queue::enqueue(
payload: [$sender, $activity, $target, $reason],
queue: 'notification_local',
queue: 'NotificationLocal',
priority: true,
);
} else {
@@ -124,7 +162,7 @@ class Notification extends Component
}
// XXX: Unideal as in failures the rollback will leave behind a false notification,
// but most notifications (all) require flushing the objects first
// Should be okay as long as implementors bear this in mind
// Should be okay as long as implementations bear this in mind
try {
DB::wrapInTransaction(fn () => DB::persist(Entity\Notification::create([
'activity_id' => $activity->getId(),
@@ -132,7 +170,7 @@ class Notification extends Component
'reason' => $reason,
])));
} catch (Exception|Throwable $e) {
// We do our best not to record duplicated notifications, but it's not insane that can happen
// We do our best not to record duplicate notifications, but it's not insane that can happen
Log::error('It was attempted to record an invalid notification!', [$e]);
}
}
@@ -140,7 +178,7 @@ class Notification extends Component
if ($remote_targets !== []) {
Queue::enqueue(
payload: [$sender, $activity, $remote_targets, $reason],
queue: 'notification_remote',
queue: 'NotificationRemote',
priority: false,
);
}

View File

@@ -24,7 +24,7 @@ declare(strict_types = 1);
namespace Component\Person\Controller;
use function App\Core\I18n\_m;
use App\Core\Router\Router;
use App\Core\Router;
use App\Entity\Actor;
use App\Entity as E;
use App\Entity\LocalUser;
@@ -34,11 +34,16 @@ use App\Util\HTML\Heading;
use Component\Collection\Util\Controller\FeedController;
use Symfony\Component\HttpFoundation\Request;
/**
* @extends FeedController<\App\Entity\Note>
*/
class PersonFeed extends FeedController
{
/**
* @throws ClientException
* @throws ServerException
*
* @return ControllerResultType
*/
public function personViewId(Request $request, int $id): array
{
@@ -62,6 +67,8 @@ class PersonFeed extends FeedController
*
* @throws ClientException
* @throws ServerException
*
* @return ControllerResultType
*/
public function personViewNickname(Request $request, string $nickname): array
{
@@ -73,6 +80,9 @@ class PersonFeed extends FeedController
return $this->personView($request, $person);
}
/**
* @return ControllerResultType
*/
public function personView(Request $request, Actor $person): array
{
return [

View File

@@ -38,11 +38,12 @@ namespace Component\Person\Controller;
// {{{ Imports
use App\Core\Controller;
use App\Core\DB\DB;
use App\Core\DB;
use App\Core\Event;
use App\Core\Form;
use function App\Core\I18n\_m;
use App\Core\Log;
use App\Entity\Actor;
use App\Util\Common;
use App\Util\Exception\AuthenticationException;
use App\Util\Exception\NicknameEmptyException;
@@ -87,12 +88,15 @@ class PersonSettings extends Controller
* @throws NoLoggedInUser
* @throws RedirectException
* @throws ServerException
*
* @return ControllerResultType
*/
public function allSettings(Request $request, LanguageController $language): array
{
// Ensure the user is logged in and retrieve Actor object for given user
$user = Common::ensureLoggedIn();
$actor = $user->getActor();
$user = Common::ensureLoggedIn();
// Must be persisted
$actor = DB::findOneBy(Actor::class, ['id' => $user->getId()]);
$personal_form = ActorForms::personalInfo(request: $request, scope: $actor, target: $actor);
$email_form = self::email($request);
@@ -101,7 +105,7 @@ class PersonSettings extends Controller
$language_form = $language->settings($request);
return [
'_template' => 'settings/base.html.twig',
'_template' => 'person/settings.html.twig',
'personal_info_form' => $personal_form->createView(),
'email_form' => $email_form->createView(),
'password_form' => $password_form->createView(),
@@ -203,6 +207,8 @@ class PersonSettings extends Controller
* @throws \Doctrine\DBAL\Exception
* @throws NoLoggedInUser
* @throws ServerException
*
* @return ControllerResultType[]
*/
private static function notifications(Request $request): array
{
@@ -249,7 +255,7 @@ class PersonSettings extends Controller
// @codeCoverageIgnoreStart
Log::critical("Structure of table user_notification_prefs changed in a way not accounted to in notification settings ({$name}): " . $type_str);
throw new ServerException(_m('Internal server error'));
// @codeCoverageIgnoreEnd
// @codeCoverageIgnoreEnd
}
}
@@ -284,7 +290,7 @@ class PersonSettings extends Controller
$data = $form->getData();
unset($data['translation_domain']);
try {
[$entity, $is_update] = UserNotificationPrefs::createOrUpdate(
[$entity, $is_update] = UserNotificationPrefs::checkExistingAndCreateOrUpdate(
array_merge(['user_id' => $user->getId(), 'transport' => $transport_name], $data),
find_by_keys: ['user_id', 'transport'],
);

View File

@@ -23,13 +23,14 @@ namespace Component\Person;
use App\Core\Event;
use App\Core\Modules\Component;
use App\Core\Router\RouteLoader;
use App\Core\Router;
use App\Util\Nickname;
use Component\Person\Controller as C;
use EventResult;
class Person extends Component
{
public function onAddRoute(RouteLoader $r): bool
public function onAddRoute(Router $r): EventResult
{
$r->connect(id: 'person_actor_view_id', uri_path: '/person/{id<\d+>}', target: [C\PersonFeed::class, 'personViewId']);
$r->connect(id: 'person_actor_view_nickname', uri_path: '/@{nickname<' . Nickname::DISPLAY_FMT . '>}', target: [C\PersonFeed::class, 'personViewNickname'], options: ['is_system_path' => false]);

View File

@@ -33,6 +33,10 @@
<li>
{{ macros.settings_details_container('Notifications', 'Enable/disable notifications (Email, XMPP, Replies...)', 'notifications', tabbed_forms_notify, _context) }}
</li>
<li>
{% set other_tabs = handle_event('PopulateSettingsTabs', app.request, 'api') %}
{{ macros.settings_details_container('API', 'API settings', 'settings-other-details', other_tabs, _context) }}
</li>
</ul>
</nav>
{% endblock body %}

View File

@@ -23,8 +23,8 @@ declare(strict_types = 1);
namespace Component\Person\tests\Controller;
use App\Core\DB\DB;
use App\Core\Router\Router;
use App\Core\DB;
use App\Core\Router;
use App\Entity\LocalUser;
use App\Util\GNUsocialTestCase;
use Jchook\AssertThrows\AssertThrows;
@@ -33,10 +33,6 @@ class PersonSettingsTest extends GNUsocialTestCase
{
use AssertThrows;
/**
* @covers \App\Controller\PersonSettings::allSettings
* @covers \App\Controller\PersonSettings::personalInfo
*/
public function testPersonalInfo()
{
$client = static::createClient();
@@ -55,19 +51,15 @@ class PersonSettingsTest extends GNUsocialTestCase
]);
$changed_user = DB::findOneBy(LocalUser::class, ['id' => $user->getId()]);
$actor = $changed_user->getActor();
static::assertSame($changed_user->getNickname(), 'form_test_user_new_nickname');
static::assertSame($actor->getNickname(), 'form_test_user_new_nickname');
static::assertSame($actor->getFullName(), 'Form User');
static::assertSame($actor->getHomepage(), 'https://gnu.org');
static::assertSame($actor->getBio(), 'I was born at a very young age');
static::assertSame($actor->getLocation(), 'right here');
// static::assertSame($changed_user->getPhoneNumber()->getNationalNumber(), '908555842');
static::assertSame('form_test_user_new_nickname', $changed_user->getNickname());
static::assertSame('form_test_user_new_nickname', $actor->getNickname());
static::assertSame('Form User', $actor->getFullName());
static::assertSame('https://gnu.org', $actor->getHomepage());
static::assertSame('I was born at a very young age', $actor->getBio());
static::assertSame('right here', $actor->getLocation());
// static::assertSame('908555842', $changed_user->getPhoneNumber()->getNationalNumber());
}
/**
* @covers \App\Controller\PersonSettings::account
* @covers \App\Controller\PersonSettings::allSettings
*/
public function testEmail()
{
$client = static::createClient();
@@ -86,10 +78,6 @@ class PersonSettingsTest extends GNUsocialTestCase
static::assertSame($changed_user->getIncomingEmail(), 'incoming@provider.any');
}
/**
* @covers \App\Controller\PersonSettings::account
* @covers \App\Controller\PersonSettings::allSettings
*/
public function testCorrectPassword()
{
$client = static::createClient();
@@ -108,10 +96,6 @@ class PersonSettingsTest extends GNUsocialTestCase
static::assertTrue($changed_user->checkPassword('this is some test password'));
}
/**
* @covers \App\Controller\PersonSettings::account
* @covers \App\Controller\PersonSettings::allSettings
*/
public function testAccountWrongPassword()
{
$client = static::createClient();
@@ -129,11 +113,7 @@ class PersonSettingsTest extends GNUsocialTestCase
$this->assertSelectorTextContains('.stacktrace', 'AuthenticationException');
}
// TODO: First actually implement this functionality
// /**
// * @covers \App\Controller\PersonSettings::allSettings
// * @covers \App\Controller\PersonSettings::notifications
// */
// TODO: First actually implement this functionality
// public function testNotifications()
// {
// $client = static::createClient();

View File

@@ -52,7 +52,7 @@ class Posting extends Controller
content_type: $data['content_type'],
locale: $data['language'],
scope: VisibilityScope::from($data['visibility']),
targets: isset($target) ? [$target] : [],
attentions: isset($target) ? [$target] : [],
reply_to: \array_key_exists('reply_to_id', $data) ? $data['reply_to_id'] : null,
attachments: $data['attachments'],
process_note_content_extra_args: $extra_args,
@@ -61,9 +61,9 @@ class Posting extends Controller
return Core\Form::forceRedirect($form, $request);
}
} catch (FormSizeFileException $e) {
throw new ClientException(_m('Invalid file size given'), previous: $e);
throw new ClientException(_m('Invalid file size given.'), previous: $e);
}
}
throw new ClientException(_m('Invalid form submission'));
throw new ClientException(_m('Invalid form submission.'));
}
}

View File

@@ -8,7 +8,7 @@ use App\Core\ActorLocalRoles;
use App\Core\Event;
use App\Core\Form;
use function App\Core\I18n\_m;
use App\Core\Router\Router;
use App\Core\Router;
use App\Core\VisibilityScope;
use App\Entity\Actor;
use App\Util\Common;

View File

@@ -24,16 +24,16 @@ declare(strict_types = 1);
namespace Component\Posting;
use App\Core\Cache;
use App\Core\DB\DB;
use App\Core\DB;
use App\Core\Event;
use App\Core\GSFile;
use function App\Core\I18n\_m;
use App\Core\Modules\Component;
use App\Core\Router\RouteLoader;
use App\Core\Router\Router;
use App\Core\Router;
use App\Core\VisibilityScope;
use App\Entity\Activity;
use App\Entity\Actor;
use App\Entity\LocalUser;
use App\Entity\Note;
use App\Util\Common;
use App\Util\Exception\BugFoundException;
@@ -44,11 +44,13 @@ use App\Util\Exception\ServerException;
use App\Util\Formatting;
use App\Util\HTML;
use Component\Attachment\Entity\ActorToAttachment;
use Component\Attachment\Entity\Attachment;
use Component\Attachment\Entity\AttachmentToNote;
use Component\Conversation\Conversation;
use Component\Language\Entity\Language;
use Component\Notification\Entity\Attention;
use Functional as F;
use EventResult;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\Request;
@@ -56,7 +58,7 @@ class Posting extends Component
{
public const route = 'posting_form_action';
public function onAddRoute(RouteLoader $r): bool
public function onAddRoute(Router $r): EventResult
{
$r->connect(self::route, '/form/posting', Controller\Posting::class);
return Event::next;
@@ -66,13 +68,15 @@ class Posting extends Component
* HTML render event handler responsible for adding and handling
* the result of adding the note submission form, only if a user is logged in
*
* @param array{post_form?: FormInterface} $res
*
* @throws BugFoundException
* @throws ClientException
* @throws DuplicateFoundException
* @throws RedirectException
* @throws ServerException
*/
public function onAddMainRightPanelBlock(Request $request, array &$res): bool
public function onAddMainRightPanelBlock(Request $request, array &$res): EventResult
{
if (\is_null($user = Common::user()) || preg_match('(feed|conversation|group|view)', $request->get('_route')) === 0) {
return Event::next;
@@ -84,17 +88,33 @@ class Posting extends Component
}
/**
* @param Actor $actor The Actor responsible for the creation of this Note
* @param null|string $content The raw text content
* @param string $content_type Indicating one of the various supported content format (Plain Text, Markdown, LaTeX...)
* @param null|string $locale Note's written text language, set by the default Actor language or upon filling
* @param null|VisibilityScope $scope The visibility of this Note
* @param Actor[]|int[] $attentions Actor|int[]: In Group/To Person or Bot, registers an attention between note and target
* @param null|int|Note $reply_to The soon-to-be Note parent's id, if it's a Reply itself
* @param UploadedFile[] $attachments UploadedFile[] to be stored as GSFiles associated to this note
* @param array<array{Attachment, string}> $processed_attachments Array of [Attachment, Attachment's name][] to be associated to this $actor and Note
* @param array{note?: Note, content?: string, content_type?: string, extra_args?: array<string, mixed>} $process_note_content_extra_args Extra arguments for the event ProcessNoteContent
* @param bool $flush_and_notify True if the newly created Note activity should be passed on as a Notification
* @param null|string $rendered The Note's content post RenderNoteContent event, which sanitizes and processes the raw content sent
* @param string $source The source of this Note
*
* @throws ClientException
* @throws DuplicateFoundException
* @throws ServerException
*
* @return array{\App\Entity\Activity, \App\Entity\Note, array<int, \App\Entity\Actor>}
*/
public static function storeLocalPage(
public static function storeLocalArticle(
Actor $actor,
?string $content,
string $content_type,
?string $locale = null,
?VisibilityScope $scope = null,
array $targets = [],
array $attentions = [],
null|int|Note $reply_to = null,
array $attachments = [],
array $processed_attachments = [],
@@ -104,13 +124,13 @@ class Posting extends Component
string $source = 'web',
?string $title = null,
): array {
[$activity, $note, $attention_ids] = self::storeLocalNote(
[$activity, $note, $effective_attentions] = self::storeLocalNote(
actor: $actor,
content: $content,
content_type: $content_type,
locale: $locale,
scope: $scope,
targets: $targets,
attentions: $attentions,
reply_to: $reply_to,
attachments: $attachments,
processed_attachments: $processed_attachments,
@@ -119,16 +139,24 @@ class Posting extends Component
rendered: $rendered,
source: $source,
);
$note->setType('page');
$note->setType('article');
$note->setTitle($title);
if ($flush_and_notify) {
// Flush before notification
DB::flush();
Event::handle('NewNotification', [$actor, $activity, ['object' => $attention_ids], _m('{nickname} created a page {note_id}.', ['{nickname}' => $actor->getNickname(), '{note_id}' => $activity->getObjectId()])]);
Event::handle('NewNotification', [
$actor,
$activity,
$effective_attentions,
_m('Actor {actor_id} created article {note_id}.', [
'{actor_id}' => $actor->getId(),
'{note_id}' => $activity->getObjectId(),
]),
]);
}
return [$activity, $note, $attention_ids];
return [$activity, $note, $effective_attentions];
}
/**
@@ -136,25 +164,25 @@ class Posting extends Component
* $actor_id, possibly as a reply to note $reply_to and with flag
* $is_local. Sanitizes $content and $attachments
*
* @param Actor $actor The Actor responsible for the creation of this Note
* @param null|string $content The raw text content
* @param string $content_type Indicating one of the various supported content format (Plain Text, Markdown, LaTeX...)
* @param null|string $locale Note's written text language, set by the default Actor language or upon filling
* @param null|VisibilityScope $scope The visibility of this Note
* @param array $targets Actor|int[]: In Group/To Person or Bot, registers an attention between note and target
* @param null|int|Note $reply_to The soon-to-be Note parent's id, if it's a Reply itself
* @param array $attachments 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 bool $flush_and_notify True if the newly created Note activity should be passed on as a Notification
* @param null|string $rendered The Note's content post RenderNoteContent event, which sanitizes and processes the raw content sent
* @param string $source The source of this Note
* @param Actor $actor The Actor responsible for the creation of this Note
* @param null|string $content The raw text content
* @param string $content_type Indicating one of the various supported content format (Plain Text, Markdown, LaTeX...)
* @param null|string $locale Note's written text language, set by the default Actor language or upon filling
* @param null|VisibilityScope $scope The visibility of this Note
* @param Actor[]|int[] $attentions Actor|int[]: In Group/To Person or Bot, registers an attention between note and targte
* @param null|int|Note $reply_to The soon-to-be Note parent's id, if it's a Reply itself
* @param UploadedFile[] $attachments UploadedFile[] to be stored as GSFiles associated to this note
* @param array<array{Attachment, string}> $processed_attachments Array of [Attachment, Attachment's name][] to be associated to this $actor and Note
* @param array{note?: Note, content?: string, content_type?: string, extra_args?: array<string, mixed>} $process_note_content_extra_args Extra arguments for the event ProcessNoteContent
* @param bool $flush_and_notify True if the newly created Note activity should be passed on as a Notification
* @param null|string $rendered The Note's content post RenderNoteContent event, which sanitizes and processes the raw content sent
* @param string $source The source of this Note
*
* @throws ClientException
* @throws DuplicateFoundException
* @throws ServerException
*
* @return array [Activity, Note, int[]] Activity, Note, Attention Ids
* @return array{\App\Entity\Activity, \App\Entity\Note, array<int, \App\Entity\Actor>}
*/
public static function storeLocalNote(
Actor $actor,
@@ -162,7 +190,7 @@ class Posting extends Component
string $content_type,
?string $locale = null,
?VisibilityScope $scope = null,
array $targets = [],
array $attentions = [],
null|int|Note $reply_to = null,
array $attachments = [],
array $processed_attachments = [],
@@ -173,7 +201,9 @@ class Posting extends Component
): array {
$scope ??= VisibilityScope::EVERYWHERE; // TODO: If site is private, default to LOCAL
$reply_to_id = \is_null($reply_to) ? null : (\is_int($reply_to) ? $reply_to : $reply_to->getId());
$mentions = [];
/** @var array<int, array{ mentioned?: array<int, Actor|LocalUser> }> $mentions */
$mentions = [];
if (\is_null($rendered) && !empty($content)) {
Event::handle('RenderNoteContent', [$content, $content_type, &$rendered, $actor, $locale, &$mentions]);
}
@@ -209,7 +239,7 @@ class Posting extends Component
if (!\is_null($reply_to_id)) {
Cache::incr(Note::cacheKeys($reply_to_id)['replies-count']);
// Not having them cached doesn't mean replies don't exist, but don't push it to the
// list, as that means they need to be refetched, or some would be missed
// list, as that means they need to be re-fetched, or some would be missed
if (Cache::exists(Note::cacheKeys($reply_to_id)['replies'])) {
Cache::listPushRight(Note::cacheKeys($reply_to_id)['replies'], $note);
}
@@ -221,12 +251,12 @@ class Posting extends Component
Event::handle('ProcessNoteContent', [$note, $content, $content_type, $process_note_content_extra_args]);
}
// These are note attachments now, and not just attachments, ensure these relations are ensured
// These are note attachments now, and not just attachments, ensure these relations are respected
if ($processed_attachments !== []) {
foreach ($processed_attachments as [$a, $fname]) {
// Most attachments should already be associated with its author, but maybe it didn't make sense
//for this attachment, or it's simply a repost of an attachment by a different actor
if (DB::count('actor_to_attachment', $args = ['attachment_id' => $a->getId(), 'actor_id' => $actor->getId()]) === 0) {
if (DB::count(ActorToAttachment::class, $args = ['attachment_id' => $a->getId(), 'actor_id' => $actor->getId()]) === 0) {
DB::persist(ActorToAttachment::create($args));
}
DB::persist(AttachmentToNote::create(['attachment_id' => $a->getId(), 'note_id' => $note->getId(), 'title' => $fname]));
@@ -242,13 +272,38 @@ class Posting extends Component
]);
DB::persist($activity);
$attention_ids = [];
foreach ($targets as $target) {
$target_id = \is_int($target) ? $target : $target->getId();
DB::persist(Attention::create(['note_id' => $note->getId(), 'target_id' => $target_id]));
$attention_ids[$target_id] = true;
$effective_attentions = [];
foreach ($attentions as $target) {
if (\is_int($target)) {
$target_id = $target;
$add = !\array_key_exists($target_id, $effective_attentions);
$effective_attentions[$target_id] = $target;
} else {
$target_id = $target->getId();
if ($add = !\array_key_exists($target_id, $effective_attentions)) {
$effective_attentions[$target_id] = $target_id;
}
}
if ($add) {
DB::persist(Attention::create(['object_type' => Note::schemaName(), 'object_id' => $note->getId(), 'target_id' => $target_id]));
}
}
foreach ($mentions as $m) {
foreach ($m['mentioned'] ?? [] as $mentioned) {
$target_id = $mentioned->getId();
if (!\array_key_exists($target_id, $effective_attentions)) {
DB::persist(Attention::create(['object_type' => Note::schemaName(), 'object_id' => $note->getId(), 'target_id' => $target_id]));
}
$effective_attentions[$target_id] = $mentioned;
}
}
foreach ($actor->getSubscribers() as $subscriber) {
$target_id = $subscriber->getId();
DB::persist(Attention::create(['object_type' => Activity::schemaName(), 'object_id' => $activity->getId(), 'target_id' => $target_id]));
$effective_attentions[$target_id] = $subscriber;
}
$attention_ids = array_keys($attention_ids);
if ($flush_and_notify) {
// Flush before notification
@@ -256,21 +311,21 @@ class Posting extends Component
Event::handle('NewNotification', [
$actor,
$activity,
[
'note-attention' => $attention_ids,
'object' => F\unique(F\flat_map($mentions, fn (array $m) => F\map($m['mentioned'] ?? [], fn (Actor $a) => $a->getId()))),
],
_m('{nickname} created a note {note_id}.', [
'{nickname}' => $actor->getNickname(),
$effective_attentions,
_m('Actor {actor_id} created note {note_id}.', [
'{actor_id}' => $actor->getId(),
'{note_id}' => $activity->getObjectId(),
]),
]);
}
return [$activity, $note, $attention_ids];
return [$activity, $note, $effective_attentions];
}
public function onRenderNoteContent(string $content, string $content_type, ?string &$rendered, Actor $author, ?string $language = null, array &$mentions = [])
/**
* @param array<int, \App\Entity\Actor> $mentions
*/
public function onRenderNoteContent(string $content, string $content_type, ?string &$rendered, Actor $author, ?string $language = null, array &$mentions = []): EventResult
{
switch ($content_type) {
case 'text/plain':

View File

@@ -38,12 +38,17 @@ use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\HttpFoundation\Request;
/**
* @extends FeedController<\App\Entity\Note>
*/
class Search extends FeedController
{
/**
* Handle a search query
*
* @return ControllerResultType
*/
public function handle(Request $request)
public function handle(Request $request): array
{
$actor = Common::actor();
$language = !\is_null($actor) ? $actor->getTopLanguage()->getLocale() : null;

View File

@@ -27,10 +27,12 @@ use App\Core\Event;
use App\Core\Form;
use function App\Core\I18n\_m;
use App\Core\Modules\Component;
use App\Core\Router;
use App\Util\Common;
use App\Util\Exception\ClientException;
use App\Util\Exception\RedirectException;
use App\Util\Formatting;
use EventResult;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormView;
@@ -39,9 +41,10 @@ use Symfony\Component\HttpFoundation\Request;
class Search extends Component
{
public function onAddRoute($r)
public function onAddRoute(Router $r): EventResult
{
$r->connect('search', '/search', Controller\Search::class);
return EventResult::next;
}
/**
@@ -131,9 +134,11 @@ class Search extends Component
/**
* Add the search form to the site header
*
* @param string[] $elements
*
* @throws RedirectException
*/
public function onPrependRightPanelBlock(Request $request, array &$elements): bool
public function onPrependRightPanelBlock(Request $request, array &$elements): EventResult
{
$elements[] = Formatting::twigRenderFile('cards/search/view.html.twig', ['search' => self::searchForm($request)]);
return Event::next;
@@ -142,11 +147,9 @@ class Search extends Component
/**
* Output our dedicated stylesheet
*
* @param array $styles stylesheets path
*
* @return bool hook value; true means continue processing, false means stop
* @param string[] $styles stylesheets path
*/
public function onEndShowStyles(array &$styles, string $route): bool
public function onEndShowStyles(array &$styles, string $route): EventResult
{
$styles[] = 'components/Search/assets/css/view.css';
return Event::next;

View File

@@ -23,11 +23,11 @@ declare(strict_types = 1);
namespace Component\Subscription\Controller;
use App\Core\DB\DB;
use App\Core\DB;
use App\Core\Form;
use function App\Core\I18n\_m;
use App\Core\Log;
use App\Core\Router\Router;
use App\Core\Router;
use App\Entity\Actor;
use App\Util\Common;
use App\Util\Exception\ClientException;
@@ -45,6 +45,8 @@ class Subscribers extends CircleController
{
/**
* @throws ServerException
*
* @return ControllerResultType
*/
public function subscribersByActor(Request $request, Actor $actor): array
{
@@ -61,6 +63,8 @@ class Subscribers extends CircleController
/**
* @throws ClientException
* @throws ServerException
*
* @return ControllerResultType
*/
public function subscribersByActorId(Request $request, int $id): array
{
@@ -78,6 +82,8 @@ class Subscribers extends CircleController
* @throws \App\Util\Exception\ServerException
* @throws ClientException
* @throws RedirectException
*
* @return ControllerResultType
*/
public function subscribersAdd(Request $request, int $object_id): array
{
@@ -126,6 +132,8 @@ class Subscribers extends CircleController
* @throws \App\Util\Exception\ServerException
* @throws ClientException
* @throws RedirectException
*
* @return ControllerResultType
*/
public function subscribersRemove(Request $request, int $object_id): array
{

View File

@@ -38,6 +38,8 @@ class Subscriptions extends CircleController
/**
* @throws ClientException
* @throws ServerException
*
* @return ControllerResultType
*/
public function subscriptionsByActorId(Request $request, int $id): array
{
@@ -48,7 +50,10 @@ class Subscriptions extends CircleController
return $this->subscriptionsByActor($request, $actor);
}
public function subscriptionsByActor(Request $request, Actor $actor)
/**
* @return ControllerResultType
*/
public function subscriptionsByActor(Request $request, Actor $actor): array
{
return [
'_template' => 'collection/actors.html.twig',

View File

@@ -114,27 +114,6 @@ class ActorSubscription extends Entity
];
}
/**
* @see Entity->getNotificationTargetIds
*/
public function getNotificationTargetIds(array $ids_already_known = [], ?int $sender_id = null, bool $include_additional = true): array
{
if (!\array_key_exists('object', $ids_already_known)) {
$target_ids = [$this->getSubscribedId()]; // The object of any subscription is the one subscribed (or unsubscribed)
} else {
$target_ids = $ids_already_known['object'];
}
// Additional actors that should know about this
if ($include_additional && \array_key_exists('additional', $ids_already_known)) {
array_push($target_ids, ...$ids_already_known['additional']);
} else {
return $target_ids;
}
return array_unique($target_ids);
}
public static function schemaDef(): array
{
return [

View File

@@ -24,12 +24,11 @@ declare(strict_types = 1);
namespace Component\Subscription;
use App\Core\Cache;
use App\Core\DB\DB;
use App\Core\DB;
use App\Core\Event;
use function App\Core\I18n\_m;
use App\Core\Modules\Component;
use App\Core\Router\RouteLoader;
use App\Core\Router\Router;
use App\Core\Router;
use App\Entity\Activity;
use App\Entity\Actor;
use App\Entity\LocalUser;
@@ -37,14 +36,15 @@ use App\Util\Common;
use App\Util\Exception\DuplicateFoundException;
use App\Util\Exception\NotFoundException;
use App\Util\Exception\ServerException;
use Component\Notification\Entity\Attention;
use Component\Subscription\Controller\Subscribers as SubscribersController;
use Component\Subscription\Controller\Subscriptions as SubscriptionsController;
use EventResult;
use Symfony\Component\HttpFoundation\Request;
class Subscription extends Component
{
public function onAddRoute(RouteLoader $r): bool
public function onAddRoute(Router $r): EventResult
{
$r->connect(id: 'actor_subscribe_add', uri_path: '/actor/subscribe/{object_id<\d+>}', target: [SubscribersController::class, 'subscribersAdd']);
$r->connect(id: 'actor_subscribe_remove', uri_path: '/actor/unsubscribe/{object_id<\d+>}', target: [SubscribersController::class, 'subscribersRemove']);
@@ -58,6 +58,8 @@ class Subscription extends Component
*
* @param Actor|int|LocalUser $subject The Actor who subscribed or unsubscribed
* @param Actor|int|LocalUser $object The Actor who was subscribed or unsubscribed from
*
* @return array{bool, bool}
*/
public static function refreshSubscriptionCount(int|Actor|LocalUser $subject, int|Actor|LocalUser $object): array
{
@@ -97,22 +99,24 @@ class Subscription extends Component
$subscription = DB::findOneBy(table: Entity\ActorSubscription::class, criteria: $opts, return_null: true);
$activity = null;
if (\is_null($subscription)) {
DB::persist(Entity\ActorSubscription::create($opts));
DB::persist($subscription = Entity\ActorSubscription::create($opts));
$activity = Activity::create([
'actor_id' => $subscriber_id,
'verb' => 'subscribe',
'object_type' => 'actor',
'object_type' => Actor::schemaName(),
'object_id' => $subscribed_id,
'source' => $source,
]);
DB::persist($activity);
DB::persist(Attention::create(['object_type' => Activity::schemaName(), 'object_id' => $activity->getId(), 'target_id' => $subscribed_id]));
Event::handle('NewNotification', [
\is_int($subject) ? $subject : Actor::getById($subscriber_id),
$activity,
['object' => [$activity->getObjectId()]],
_m('{subject} subscribed to {object}.', ['{subject}' => $activity->getActorId(), '{object}' => $activity->getObjectId()]),
[$subscribed_id],
$reason = _m('{subject} subscribed to {object}.', ['{subject}' => $activity->getActorId(), '{object}' => $activity->getObjectId()]),
]);
Event::handle('NewSubscriptionEnd', [$subject, $activity, $object, $reason]);
}
return $activity;
}
@@ -146,21 +150,22 @@ class Subscription extends Component
if (!\is_null($subscription)) {
// Remove Subscription
DB::remove($subscription);
$previous_follow_activity = DB::findBy('activity', ['verb' => 'subscribe', 'object_type' => 'actor', 'object_id' => $subscribed_id], order_by: ['created' => 'DESC'])[0];
$previous_follow_activity = DB::findBy(Activity::class, ['verb' => 'subscribe', 'object_type' => Actor::schemaName(), 'object_id' => $subscribed_id], order_by: ['created' => 'DESC'])[0];
// Store Activity
$activity = Activity::create([
'actor_id' => $subscriber_id,
'verb' => 'undo',
'object_type' => 'activity',
'object_type' => Activity::schemaName(),
'object_id' => $previous_follow_activity->getId(),
'source' => $source,
]);
DB::persist($activity);
DB::persist(Attention::create(['object_type' => Activity::schemaName(), 'object_id' => $activity->getId(), 'target_id' => $subscribed_id]));
Event::handle('NewNotification', [
\is_int($subject) ? $subject : Actor::getById($subscriber_id),
$activity,
['object' => [$previous_follow_activity->getObjectId()]],
[$subscribed_id],
_m('{subject} unsubscribed from {object}.', ['{subject}' => $activity->getActorId(), '{object}' => $previous_follow_activity->getObjectId()]),
]);
}
@@ -174,17 +179,16 @@ class Subscription extends Component
* In the case of ``\App\Component\Subscription``, the action added allows a **LocalUser** to **subscribe** or
* **unsubscribe** a given **Actor**.
*
* @param Actor $object The Actor on which the action is to be performed
* @param array $actions An array containing all actions added to the
* current profile, this event adds an action to it
* @param Actor $object The Actor on which the action is to be performed
* @param array<array{url: string, title: string, classes: string, id: string}> $actions
* An array containing all actions added to the
* current profile, this event adds an action to it
*
* @throws DuplicateFoundException
* @throws NotFoundException
* @throws ServerException
*
* @return bool EventHook
*/
public function onAddProfileActions(Request $request, Actor $object, array &$actions): bool
public function onAddProfileActions(Request $request, Actor $object, array &$actions): EventResult
{
// Action requires a user to be logged in
// We know it's a LocalUser, which has the same id as Actor

View File

@@ -15,7 +15,12 @@ class Tag extends Controller
// TODO: Use Feed::query
// TODO: If ?canonical=something, respect
// TODO: Allow to set locale of tag being selected
private function process(null|string|array $tag_single_or_multi, string $key, string $query, string $template, bool $include_locale = false)
/**
* @param (null|string|string[]) $tag_single_or_multi
*
* @return ControllerResultType
*/
private function process(null|string|array $tag_single_or_multi, string $key, string $query, string $template, bool $include_locale = false): array
{
$actor = Common::actor();
$page = $this->int('page') ?: 1;
@@ -46,7 +51,10 @@ class Tag extends Controller
];
}
public function single_note_tag(string $tag)
/**
* @return ControllerResultType
*/
public function single_note_tag(string $tag): array
{
return $this->process(
tag_single_or_multi: $tag,
@@ -57,7 +65,10 @@ class Tag extends Controller
);
}
public function multi_note_tags(string $tags)
/**
* @return ControllerResultType
*/
public function multi_note_tags(string $tags): array
{
return $this->process(
tag_single_or_multi: explode(',', $tags),

View File

@@ -22,9 +22,9 @@ declare(strict_types = 1);
namespace Component\Tag\Entity;
use App\Core\Cache;
use App\Core\DB\DB;
use App\Core\DB;
use App\Core\Entity;
use App\Core\Router\Router;
use App\Core\Router;
use App\Entity\Actor;
use App\Entity\Note;
use Component\Language\Entity\Language;
@@ -134,6 +134,9 @@ class NoteTag extends Entity
return "note-tags-{$note_id}";
}
/**
* @return NoteTag[]
*/
public static function getByNoteId(int $note_id): array
{
return Cache::getList(self::cacheKey($note_id), fn () => DB::dql('SELECT nt FROM note_tag AS nt JOIN note AS n WITH n.id = nt.note_id WHERE n.id = :id', ['id' => $note_id]));

View File

@@ -22,7 +22,7 @@ declare(strict_types = 1);
namespace Component\Tag\Entity;
use App\Core\Cache;
use App\Core\DB\DB;
use App\Core\DB;
use App\Core\Entity;
use Component\Tag\Tag;
use DateTimeInterface;
@@ -119,6 +119,8 @@ class NoteTagBlock extends Entity
/**
* Check whether $note_tag is considered blocked by one of
* $note_tag_blocks
*
* @param NoteTagBlock[] $note_tag_blocks
*/
public static function checkBlocksNoteTag(NoteTag $note_tag, array $note_tag_blocks): bool
{

View File

@@ -24,11 +24,11 @@ declare(strict_types = 1);
namespace Component\Tag;
use App\Core\Cache;
use App\Core\DB\DB;
use App\Core\DB;
use App\Core\Event;
use function App\Core\I18n\_m;
use App\Core\Modules\Component;
use App\Core\Router\Router;
use App\Core\Router;
use App\Entity\Actor;
use App\Entity\Note;
use App\Util\Common;
@@ -42,6 +42,7 @@ use Component\Tag\Entity\NoteTag;
use Doctrine\Common\Collections\ExpressionBuilder;
use Doctrine\ORM\Query\Expr;
use Doctrine\ORM\QueryBuilder;
use EventResult;
use Functional as F;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\HttpFoundation\Request;
@@ -60,7 +61,7 @@ class Tag extends Component
public const TAG_REGEX = '/(^|\\s)(#[\\pL\\pN_\\-]{1,64})/u'; // Brion Vibber 2011-02-23 v2:classes/Notice.php:367 function saveTags
public const TAG_SLUG_REGEX = '[A-Za-z0-9]{1,64}';
public function onAddRoute($r): bool
public function onAddRoute(Router $r): EventResult
{
$r->connect('single_note_tag', '/note-tag/{tag<' . self::TAG_SLUG_REGEX . '>}', [Controller\Tag::class, 'single_note_tag']);
$r->connect('multi_note_tags', '/note-tags/{tags<(' . self::TAG_SLUG_REGEX . ',)+' . self::TAG_SLUG_REGEX . '>}', [Controller\Tag::class, 'multi_note_tags']);
@@ -68,9 +69,62 @@ class Tag extends Component
}
/**
* Process note by extracting any tags present
* @param array{tag_use_canonical?: bool} $extra_args
*/
public function onProcessNoteContent(Note $note, string $content, string $content_type, array $extra_args): bool
public static function maybeCreateTag(string $tag, int $note_id, ?int $lang_id, array $extra_args = []): ?NoteTag
{
if (!self::validate($tag)) {
return null; // Ignore invalid tag candidates
}
$canonical_tag = self::canonicalTag($tag, \is_null($lang_id) ? null : Language::getById($lang_id)->getLocale());
DB::persist($note_tag = NoteTag::create([
'tag' => $tag,
'canonical' => $canonical_tag,
'note_id' => $note_id,
'use_canonical' => $extra_args['tag_use_canonical'] ?? false,
'language_id' => $lang_id,
]));
foreach (self::cacheKeys($canonical_tag) as $key) {
Cache::delete($key);
}
return $note_tag;
}
/**
* @return NoteTag[]
*/
public static function getNoteTags(int $actor_id, ?string $note_type): array
{
$query = <<<'EOF'
select nt from \App\Entity\Note n
join \Component\Tag\Entity\NoteTag nt with n.id = nt.note_id
where n.actor_id = :id
EOF;
if (\is_null($note_type)) {
return Cache::getList(
Actor::cacheKeys($actor_id, 'any')['note-tags'],
fn () => DB::dql(
$query,
['id' => $actor_id],
),
);
} else {
return Cache::getList(
Actor::cacheKeys($actor_id, $note_type)['note-tags'],
fn () => DB::dql(
$query . ' and n.type = :type',
['id' => $actor_id, 'type' => $note_type],
),
);
}
}
/**
* Process note by extracting any tags present
*
* @param array{TagProcessed?: bool} $extra_args
*/
public function onProcessNoteContent(Note $note, string $content, string $content_type, array $extra_args): EventResult
{
if ($extra_args['TagProcessed'] ?? false) {
return Event::next;
@@ -82,26 +136,12 @@ class Tag extends Component
$matched_tags = array_unique(F\map($matched_tags, fn ($m) => $m[2]));
foreach ($matched_tags as $match) {
$tag = self::extract($match);
if (!self::validate($tag)) {
continue; // Ignore invalid tag candidates
}
$canonical_tag = self::canonicalTag($tag, \is_null($lang_id = $note->getLanguageId()) ? null : Language::getById($lang_id)->getLocale());
DB::persist(NoteTag::create([
'tag' => $tag,
'canonical' => $canonical_tag,
'note_id' => $note->getId(),
'use_canonical' => $extra_args['tag_use_canonical'] ?? false,
'language_id' => $lang_id,
]));
Cache::listPushLeft("tag-{$canonical_tag}", $note);
foreach (self::cacheKeys($canonical_tag) as $key) {
Cache::delete($key);
}
self::maybeCreateTag(tag: $tag, note_id: $note->getId(), lang_id: $note->getLanguageId());
}
return Event::next;
}
public function onRenderPlainTextNoteContent(string &$text, ?string $locale = null): bool
public function onRenderPlainTextNoteContent(string &$text, ?string $locale = null): EventResult
{
$text = preg_replace_callback(self::TAG_REGEX, fn ($m) => $m[1] . self::tagLink($m[2], $locale), $text);
return Event::next;
@@ -178,8 +218,11 @@ class Tag extends Component
* Populate $note_expr with an expression to match a tag, if the term looks like a tag
*
* $term /^(note|tag|people|actor)/ means we want to match only either a note or an actor
*
* @param mixed $note_expr
* @param mixed $actor_expr
*/
public function onCollectionQueryCreateExpression(ExpressionBuilder $eb, string $term, ?string $locale, ?Actor $actor, &$note_expr, &$actor_expr): bool
public function onCollectionQueryCreateExpression(ExpressionBuilder $eb, string $term, ?string $locale, ?Actor $actor, &$note_expr, &$actor_expr): EventResult
{
if (!str_contains($term, ':')) {
return Event::next;
@@ -218,7 +261,7 @@ class Tag extends Component
return Event::next;
}
public function onCollectionQueryAddJoins(QueryBuilder &$note_qb, QueryBuilder &$actor_qb): bool
public function onCollectionQueryAddJoins(QueryBuilder &$note_qb, QueryBuilder &$actor_qb): EventResult
{
if (!\in_array('note_tag', $note_qb->getAllAliases())) {
$note_qb->leftJoin(NoteTag::class, 'note_tag', Expr\Join::WITH, 'note_tag.note_id = note.id');
@@ -229,13 +272,20 @@ class Tag extends Component
return Event::next;
}
public function onPostingAddFormEntries(Request $request, Actor $actor, array &$form_params): bool
/**
* @param array{string, class-string, array<string, mixed>} $form_params
*/
public function onPostingAddFormEntries(Request $request, Actor $actor, array &$form_params): EventResult
{
$form_params[] = ['tag_use_canonical', CheckboxType::class, ['required' => false, 'data' => true, 'label' => _m('Make note tags canonical'), 'help' => _m('Canonical tags will be treated as a version of an existing tag with the same root/stem (e.g. \'#great_tag\' will be considered as a version of \'#great\', if it already exists)')]];
return Event::next;
}
public function onAddExtraArgsToNoteContent(Request $request, Actor $actor, array $data, array &$extra_args): bool
/**
* @param array{tag_use_canonical?: bool} $data
* @param array{tag_use_canonical?: bool} $extra_args
*/
public function onAddExtraArgsToNoteContent(Request $request, Actor $actor, array $data, array &$extra_args): EventResult
{
if (!isset($data['tag_use_canonical'])) {
throw new ClientException(_m('Missing Use Canonical preference for Tags.'));

View File

@@ -75,7 +75,7 @@
"friendsofphp/php-cs-fixer": "^3.2.1",
"jchook/phpunit-assert-throws": "^1.0",
"niels-de-blaauw/php-doc-check": "^0.2.2",
"phpstan/phpstan": "dev-master",
"phpstan/phpstan": "1.9.x-dev",
"phpunit/phpunit": "^9.5",
"symfony/browser-kit": "^6.0",
"symfony/css-selector": "^6.0",
@@ -103,6 +103,9 @@
"files": [
"src/Core/I18n/I18n.php"
],
"classmap": [
"src/Core/Event/EventResult.php"
],
"psr-4": {
"App\\": "src/",
"Plugin\\": "plugins/",
@@ -112,7 +115,8 @@
},
"autoload-dev": {
"psr-4": {
"App\\Tests\\": "tests/"
"App\\Test\\Fixtures\\": "tests/fixtures/",
"App\\Test\\": "tests/"
}
},
"replace": {

2673
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -20,15 +20,13 @@ security:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
oauth:
pattern: ^/oauth
security: false
main:
lazy: true
provider: local_user
form_login:
login_path: security_login
check_path: security_login
default_target_path: root
logout:
path: security_logout
# where to redirect after logout

View File

@@ -13,7 +13,10 @@ services:
# this creates a service per class whose id is the fully-qualified class name
App\:
resource: '../src/*'
exclude: '../src/{DependencyInjection,Entity,Migrations,Tests,Kernel.php,Routes}'
exclude: '../src/{DependencyInjection,Entity,Migrations,Tests,Kernel.php,Routes,Core/Event/EventResult.php}'
App\Test\Fixtures\:
resource: '../tests/fixtures/*'
# controllers are imported separately to make sure services can be injected
# as action arguments even if you don't extend any base controller class
@@ -21,7 +24,7 @@ services:
resource: '../src/Controller'
tags: ['controller.service_arguments']
App\Core\Router\RouteLoader:
App\Core\Router:
tags: ['routing.loader']
# Wrapper around Doctrine's StaticPHP metadata driver

View File

@@ -93,7 +93,7 @@ services:
- ./docker/php/entrypoint.sh:/entrypoint.sh
- ./docker/db/wait_for_db.sh:/wait_for_db.sh
- ./docker/social/install.sh:/var/entrypoint.d/social_install.sh
- ./docker/social/worker.sh:/var/entrypoint.d/social_worker.sh
- ./docker/worker/worker.sh:/var/entrypoint.d/social_worker.sh
# Main files
- .:/var/www/social
- /var/www/social/docker # exclude docker folder

View File

@@ -1,3 +1,18 @@
server {
# Listen only on port 81 for localhost, and nothing else.
server_name 127.0.0.1;
listen 127.0.0.1:81 default_server;
charset utf-8;
# Certbot's folder used for the ACME challenge response.
location ^~ /.well-known/acme-challenge {
default_type text/plain;
root /var/www/certbot;
try_files $uri =404;
}
}
server {
listen [::]:80;
@@ -5,6 +20,10 @@ server {
server_name %hostname%;
location '/.well-known/acme-challenge' {
proxy_pass http://localhost:81;
}
# redirect all traffic to HTTPS
rewrite ^ https://$host$request_uri? permanent;
}
@@ -35,6 +54,13 @@ server {
root /var/www/social;
}
location /.well-known/acme-challenge/ {
allow all;
root /var/www/certbot;
try_files $uri =404;
break;
}
# PHP
location ~ ^/(index|install)\.php(/.*)?$ {
include fastcgi_params;

View File

@@ -2,8 +2,7 @@
case "${DBMS}" in
'postgres')
PGPASSWORD="${POSTGRES_PASSWORD}" psql -ltq -Upostgres -hdb | \
cut -d '|' -f1 | grep -Fwq "${SOCIAL_DB}"
test "$(PGPASSWORD="${POSTGRES_PASSWORD}" psql -Upostgres -hdb -tAc "select 1 from pg_database where datname='${SOCIAL_DB}'")" = "1"
DB_EXISTS=$?
;;
'mariadb')
@@ -28,7 +27,8 @@ if [ ${DB_EXISTS} -ne 0 ]; then
chmod g+w -R .
chown -R :www-data .
php bin/console doctrine:database:create || exit 1
php bin/console doctrine:database:drop -f
php bin/console doctrine:database:create
php bin/console doctrine:schema:create || exit 1
php bin/console app:populate_initial_values || exit 1

View File

@@ -3,8 +3,10 @@
cd /var/www/social || exit 1
printf "Cleaning Redis cache: " && echo "FLUSHALL" | nc redis 6379
yes yes | php bin/console doctrine:fixtures:load || exit 1
php bin/console app:populate_initial_values # since loading fixtures purges the DB
bin/console doctrine:database:drop --force || exit 1
bin/console doctrine:database:create || exit 1
bin/console doctrine:schema:update --force || exit 1
yes yes | bin/console doctrine:fixtures:load || exit 1
if [ "$#" -eq 0 ] || [ -z "$*" ]; then
vendor/bin/simple-phpunit -vvv --coverage-html .test_coverage_report

View File

@@ -45,10 +45,10 @@ Example 1: Adding elements to the core UI
* @param array $vars Input from the caller/emitter
* @param array $res I/O parameter used to accumulate or return values from the listener to the emitter
*
* @return bool true if not handled or if the handling should be accumulated with other listeners,
* @return \EventResult true if not handled or if the handling should be accumulated with other listeners,
* false if handled well enough and no other listeners are needed
*/
public function onViewAttachmentImage(array $vars, array &$res): bool
public function onViewAttachmentImage(array $vars, array &$res): \EventResult
{
$res[] = Formatting::twigRenderFile('imageEncoder/imageEncoderView.html.twig', ['attachment' => $vars['attachment'], 'thumbnail_parameters' => $vars['thumbnail_parameters']]);
return Event::stop;
@@ -74,11 +74,25 @@ Event::handle('ResizerAvailable', [&$event_map]);
/**
* @param array $event_map output
*
* @return bool event hook
* @return \EventResult event hook
*/
public function onResizerAvailable(array &$event_map): bool
public function onResizerAvailable(array &$event_map): \EventResult
{
$event_map['image'] = 'ResizeImagePath';
return Event::next;
}
```
```
Example 3: Default action
-----
An event can be emited to perform an action, but still have a fallback as such:
> Event emitter
```php
if (Event::handle('EventName', $args) !== Event::stop): \EventResult
{
// Do default action, as no-one claimed authority on handling this event
}
```

View File

@@ -1,5 +1,7 @@
parameters:
level: 4
level: 6
tmpDir: /var/www/social/var/cache/phpstan
inferPrivatePropertyTypeFromConstructor: true
bootstrapFiles:
- config/bootstrap.php
paths:
@@ -15,6 +17,13 @@ parameters:
earlyTerminatingMethodCalls:
App\Core\Log:
- unexpected_exception
typeAliases:
ControllerResultType: '(array{_template: string} | array{_redirect: string}) & array<string, mixed>'
CacheKeysType: 'array<string, string>'
SettingsTabsType: 'array<array{title: string, desc: string, id: string, controller: ControllerResultType}>'
OrderByType: "'ASC' | 'DESC' | 'asc' | 'desc'"
ModuleVersionType: 'array{name: string, version: string, author: string, rawdescription: string}'
ignoreErrors:
-
message: '/Access to an undefined property App\\Util\\Bitmap::\$\w+/'
@@ -35,10 +44,41 @@ parameters:
paths:
- *
# -
# message: '/has no return typehint specified/'
# paths:
# - tests/*
-
message: '/::onCollectionQueryCreateExpression\(\) has parameter \$(actor|note)_expr with no type specified\./'
paths:
- *
-
message: '/::schemaDef\(\) return type has no value type specified in iterable type array\./'
paths:
- *
-
message: '/has no return type specified\./'
paths:
- *
-
message: '/with no value type specified in iterable type (array|iterable)\.|type has no value type specified in iterable type (array|iterable)\./'
paths:
- *
-
message: '/never returns array{_redirect: string} so it can be removed from the return type\./'
paths:
- *
-
message: '/but returns array<string, array<int, mixed>\|string>\./'
paths:
- plugins/AttachmentCollections/Controller/AttachmentCollections.php
- plugins/Bundles/Controller/BundleCollection.php
-
message: '/has parameter \$.+ with no type specified./'
paths:
- tests/*
services:
-

View File

@@ -34,15 +34,14 @@ namespace Plugin\ActivityPub;
use ActivityPhp\Type;
use ActivityPhp\Type\AbstractObject;
use App\Core\DB\DB;
use App\Core\DB;
use App\Core\Event;
use App\Core\HTTPClient;
use function App\Core\I18n\_m;
use App\Core\Log;
use App\Core\Modules\Plugin;
use App\Core\Queue\Queue;
use App\Core\Router\RouteLoader;
use App\Core\Router\Router;
use App\Core\Queue;
use App\Core\Router;
use App\Entity\Activity;
use App\Entity\Actor;
use App\Entity\Note;
@@ -51,6 +50,7 @@ use App\Util\Exception\BugFoundException;
use Component\Collection\Util\Controller\OrderedCollection;
use Component\FreeNetwork\Entity\FreeNetworkActorProtocol;
use Component\FreeNetwork\Util\Discovery;
use EventResult;
use Exception;
use InvalidArgumentException;
use Plugin\ActivityPub\Controller\Inbox;
@@ -104,7 +104,7 @@ class ActivityPub extends Plugin
'User-Agent' => 'GNUsocialBot ' . GNUSOCIAL_VERSION . ' - ' . GNUSOCIAL_PROJECT_URL,
];
public function version(): string
public static function version(): string
{
return '3.0.0';
}
@@ -123,14 +123,14 @@ class ActivityPub extends Plugin
],
];
public function onInitializePlugin(): bool
public function onInitializePlugin(): EventResult
{
Event::handle('ActivityStreamsTwoContext', [&self::$activity_streams_two_context]);
self::$activity_streams_two_context = array_unique(self::$activity_streams_two_context, \SORT_REGULAR);
return Event::next;
}
public function onQueueActivitypubInbox(ActivitypubActor $ap_actor, Actor $actor, string|AbstractObject $type): bool
public function onQueueActivitypubInbox(ActivitypubActor $ap_actor, Actor $actor, string|AbstractObject $type): EventResult
{
// TODO: Check if Actor has authority over payload
@@ -141,14 +141,12 @@ class ActivityPub extends Plugin
$ap_actor->getActorId(),
Discovery::normalize($actor->getNickname() . '@' . parse_url($ap_actor->getInboxUri(), \PHP_URL_HOST)),
);
$already_known_ids = [];
if (!empty($ap_act->_object_mention_ids)) {
$already_known_ids = $ap_act->_object_mention_ids;
}
DB::flush();
if (Event::handle('ActivityPubNewNotification', [$actor, $ap_act->getActivity(), $already_known_ids, _m('{nickname} attentioned you.', ['{nickname}' => $actor->getNickname()])]) === Event::next) {
Event::handle('NewNotification', [$actor, $ap_act->getActivity(), $already_known_ids, _m('{nickname} attentioned you.', ['{nickname}' => $actor->getNickname()])]);
if (($att_targets = $ap_act->getAttentionTargets()) !== []) {
if (Event::handle('ActivityPubNewNotification', [$actor, ($act = $ap_act->getActivity()), $att_targets, _m('{actor_id} triggered a notification via ActivityPub.', ['{actor_id}' => $actor->getId()])]) === Event::next) {
Event::handle('NewNotification', [$actor, $act, $att_targets, _m('{actor_id} triggered a notification via ActivityPub.', ['{nickname}' => $actor->getId()])]);
}
}
return Event::stop;
@@ -158,9 +156,9 @@ class ActivityPub extends Plugin
* This code executes when GNU social creates the page routing, and we hook
* on this event to add our Inbox and Outbox handler for ActivityPub.
*
* @param RouteLoader $r the router that was initialized
* @param Router $r the router that was initialized
*/
public function onAddRoute(RouteLoader $r): bool
public function onAddRoute(Router $r): EventResult
{
$r->connect(
'activitypub_inbox',
@@ -186,7 +184,7 @@ class ActivityPub extends Plugin
/**
* Fill Actor->getUrl() calls with correct URL coming from ActivityPub
*/
public function onStartGetActorUri(Actor $actor, int $type, ?string &$url): bool
public function onStartGetActorUri(Actor $actor, int $type, ?string &$url): EventResult
{
if (
// Is remote?
@@ -206,7 +204,7 @@ class ActivityPub extends Plugin
/**
* Fill Actor->canAdmin() for Actors that came from ActivityPub
*/
public function onFreeNetworkActorCanAdmin(Actor $actor, Actor $other, bool &$canAdmin): bool
public function onFreeNetworkActorCanAdmin(Actor $actor, Actor $other, bool &$canAdmin): EventResult
{
// Are both in AP?
if (
@@ -226,7 +224,7 @@ class ActivityPub extends Plugin
*
* @throws Exception
*/
public function onControllerResponseInFormat(string $route, array $accept_header, array $vars, ?TypeResponse &$response = null): bool
public function onControllerResponseInFormat(string $route, array $accept_header, array $vars, ?TypeResponse &$response = null): EventResult
{
if (\count(array_intersect(self::$accept_headers, $accept_header)) === 0) {
return Event::next;
@@ -265,7 +263,7 @@ class ActivityPub extends Plugin
/**
* Add ActivityStreams 2 Extensions
*/
public function onActivityPubValidateActivityStreamsTwoData(string $type_name, array &$validators): bool
public function onActivityPubValidateActivityStreamsTwoData(string $type_name, array &$validators): EventResult
{
switch ($type_name) {
case 'Person':
@@ -283,9 +281,11 @@ class ActivityPub extends Plugin
/**
* Let FreeNetwork Component know we exist and which class to use to call the freeNetworkDistribute method
*/
public function onAddFreeNetworkProtocol(array &$protocols): bool
public function onAddFreeNetworkProtocol(array &$protocols): EventResult
{
$protocols[] = '\Plugin\ActivityPub\ActivityPub';
if (!\in_array('\Plugin\ActivityPub\ActivityPub', $protocols)) {
$protocols[] = '\Plugin\ActivityPub\ActivityPub';
}
return Event::next;
}
@@ -296,11 +296,11 @@ class ActivityPub extends Plugin
*
* @return bool true if imported, false otherwise
*/
public static function freeNetworkGrabRemote(string $uri): bool
public static function freeNetworkGrabRemote(string $uri, ?Actor $on_behalf_of = null): bool
{
if (Common::isValidHttpUrl($uri)) {
try {
$object = self::getObjectByUri($uri);
$object = self::getObjectByUri($uri, try_online: true, on_behalf_of: $on_behalf_of);
if (!\is_null($object)) {
if ($object instanceof Type\AbstractObject) {
if (\in_array($object->get('type'), array_keys(Model\Actor::$_as2_actor_type_to_gs_actor_type))) {
@@ -324,15 +324,24 @@ class ActivityPub extends Plugin
string $inbox,
array $to_actors,
array &$retry_args,
): bool
{
): EventResult {
try {
$data = Model::toJson($activity);
if ($sender->isGroup()) {
// When the sender is a group, we have to wrap it in an Announce activity
$data = Type::create('Announce', ['object' => $data])->toJson();
$data = Model::toType($activity);
if ($sender->isGroup()) { // When the sender is a group,
if ($activity->getVerb() === 'subscribe') {
// Regular postman happens
} elseif ($activity->getVerb() === 'undo' && $data->get('object')->get('type') === 'Follow') {
// Regular postman happens
} else {
// For every other activity sent by a Group, we have to wrap it in a transient Announce activity
$data = Type::create('Announce', [
'@context' => 'https:\/\/www.w3.org\/ns\/activitystreams',
'actor' => $sender->getUri(type: Router::ABSOLUTE_URL),
'object' => $data,
]);
}
}
$res = self::postman($sender, $data, $inbox);
$res = self::postman($sender, $data->toJson(), $inbox);
// accumulate errors for later use, if needed
$status_code = $res->getStatusCode();
@@ -380,6 +389,7 @@ class ActivityPub extends Plugin
// the actor, that could for example mean that OStatus handled this actor while we were deactivated
// On next interaction this should be resolved, for now continue
if (\is_null($ap_target = DB::findOneBy(ActivitypubActor::class, ['actor_id' => $actor->getId()], return_null: true))) {
Log::info('FreeNetwork wrongly told ActivityPub that it can handle actor id: ' . $actor->getId() . ' you might want to keep an eye on it.');
continue;
}
$to_addr[$ap_target->getInboxSharedUri() ?? $ap_target->getInboxUri()][] = $actor;
@@ -391,7 +401,7 @@ class ActivityPub extends Plugin
foreach ($to_addr as $inbox => $to_actors) {
Queue::enqueue(
payload: [$sender, $activity, $inbox, $to_actors],
queue: 'activitypub_postman',
queue: 'ActivitypubPostman',
priority: false,
);
}
@@ -419,7 +429,7 @@ class ActivityPub extends Plugin
/**
* Add activity+json mimetype to WebFinger
*/
public function onEndWebFingerProfileLinks(XML_XRD $xrd, Actor $object): bool
public function onEndWebFingerProfileLinks(XML_XRD $xrd, Actor $object): EventResult
{
if ($object->isPerson()) {
$link = new XML_XRD_Element_Link(
@@ -435,7 +445,7 @@ class ActivityPub extends Plugin
/**
* When FreeNetwork component asks us to help with identifying Actors from XRDs
*/
public function onFreeNetworkFoundXrd(XML_XRD $xrd, ?Actor &$actor = null): bool
public function onFreeNetworkFoundXrd(XML_XRD $xrd, ?Actor &$actor = null): EventResult
{
$addr = null;
foreach ($xrd->aliases as $alias) {
@@ -466,7 +476,7 @@ class ActivityPub extends Plugin
/**
* When FreeNetwork component asks us to help with identifying Actors from URIs
*/
public function onFreeNetworkFindMentions(string $target, ?Actor &$actor = null): bool
public function onFreeNetworkFindMentions(string $target, ?Actor &$actor = null): EventResult
{
try {
if (FreeNetworkActorProtocol::canIAddr('activitypub', $addr = Discovery::normalize($target))) {
@@ -530,7 +540,7 @@ class ActivityPub extends Plugin
*
* @return null|Actor|mixed|Note got from URI
*/
public static function getObjectByUri(string $resource, bool $try_online = true)
public static function getObjectByUri(string $resource, bool $try_online = true, ?Actor $on_behalf_of = null): mixed
{
// Try known object
$known_object = DB::findOneBy(ActivitypubObject::class, ['object_uri' => $resource], return_null: true);
@@ -544,39 +554,57 @@ class ActivityPub extends Plugin
return $known_activity->getActivity();
}
// Try local Note
if (Common::isValidHttpUrl($resource)) {
$resource_parts = parse_url($resource);
// TODO: Use URLMatcher
if ($resource_parts['host'] === Common::config('site', 'server')) {
$local_note = DB::findOneBy('note', ['url' => $resource], return_null: true);
if (!\is_null($local_note)) {
return $local_note;
}
}
}
// Try Actor
try {
return Explorer::getOneFromUri($resource, try_online: false);
return Explorer::getOneFromUri($resource, try_online: false, on_behalf_of: $on_behalf_of);
} catch (\Exception) {
// Ignore, this is brute forcing, it's okay not to find
}
// Try remote
if (!$try_online) {
return;
// Is it a HTTP URL?
if (Common::isValidHttpUrl($resource)) {
$resource_parts = parse_url($resource);
// If it is local
if ($resource_parts['host'] === Common::config('site', 'server')) {
// Try Local Note
$local_note = DB::findOneBy(Note::class, ['url' => $resource], return_null: true);
if (!\is_null($local_note)) {
return $local_note;
}
// Try local Activity
try {
$match = Router::match($resource_parts['path']);
$local_activity = DB::findOneBy(Activity::class, ['id' => $match['id']], return_null: true);
if (!\is_null($local_activity)) {
return $local_activity;
} else {
throw new InvalidArgumentException('Tried to retrieve a non-existent local activity.');
}
} catch (\Exception) {
// Ignore, this is brute forcing, it's okay not to find
}
throw new BugFoundException('ActivityPub failed to retrieve local resource: "' . $resource . '". This is a big issue.');
} else {
// Then it's remote
if (!$try_online) {
throw new Exception("Remote resource {$resource} not found without online resources.");
}
$response = Explorer::get($resource, $on_behalf_of);
// If it was deleted
if ($response->getStatusCode() == 410) {
//$obj = Type::create('Tombstone', ['id' => $resource]);
return null;
} elseif (!HTTPClient::statusCodeIsOkay($response)) { // If it is unavailable
throw new Exception('Non Ok Status Code for given Object id.');
} else {
return Model::jsonToType($response->getContent());
}
}
}
$response = HTTPClient::get($resource, ['headers' => self::HTTP_CLIENT_HEADERS]);
// If it was deleted
if ($response->getStatusCode() == 410) {
//$obj = Type::create('Tombstone', ['id' => $resource]);
return;
} elseif (!HTTPClient::statusCodeIsOkay($response)) { // If it is unavailable
throw new Exception('Non Ok Status Code for given Object id.');
} else {
return Model::jsonToType($response->getContent());
}
return null;
}
}

View File

@@ -32,12 +32,13 @@ declare(strict_types = 1);
namespace Plugin\ActivityPub\Controller;
use ActivityPhp\Type\AbstractObject;
use App\Core\Controller;
use App\Core\DB\DB;
use App\Core\DB;
use function App\Core\I18n\_m;
use App\Core\Log;
use App\Core\Queue\Queue;
use App\Core\Router\Router;
use App\Core\Queue;
use App\Core\Router;
use App\Entity\Actor;
use App\Util\Common;
use App\Util\Exception\ClientException;
@@ -96,10 +97,12 @@ class Inbox extends Controller
return $error('Actor not found in the request.');
}
$to_actor = $this->deriveActivityTo($type);
try {
$resource_parts = parse_url($type->get('actor'));
if ($resource_parts['host'] !== Common::config('site', 'server')) {
$actor = DB::wrapInTransaction(fn () => Explorer::getOneFromUri($type->get('actor')));
$actor = DB::wrapInTransaction(fn () => Explorer::getOneFromUri($type->get('actor'), try_online: true, on_behalf_of: $to_actor));
$ap_actor = DB::findOneBy(ActivitypubActor::class, ['actor_id' => $actor->getId()]);
} else {
throw new Exception('Only remote actors can use this endpoint.');
@@ -137,7 +140,7 @@ class Inbox extends Controller
// If the signature fails verification the first time, update profile as it might have changed public key
if ($verified !== 1) {
try {
$res = Explorer::getRemoteActorActivity($ap_actor->getUri());
$res = Explorer::getRemoteActorActivity($ap_actor->getUri(), $to_actor);
if (\is_null($res)) {
return $error('Invalid remote actor (null response).');
}
@@ -164,10 +167,74 @@ class Inbox extends Controller
Queue::enqueue(
payload: [$ap_actor, $actor, $type],
queue: 'activitypub_inbox',
queue: 'ActivitypubInbox',
priority: false,
);
return new TypeResponse($type, status: 202);
}
/**
* Poke at the given AbstractObject to find out who it is 'to'.
* Function will check through the 'to', 'cc', and 'object' fields
* of the given type (in that order) to check if if points to anyone
* on our instance. The first found candidate will be returned.
*
* @param AbstractObject $type
*
* @return Actor|null The discovered actor, if found. null otherwise.
*
* @throws Exception
*/
private function deriveActivityTo(AbstractObject $type): Actor|null
{
foreach (['to', 'cc'] as $field) {
foreach ((array) $type->get($field) as $uri) {
$actor = self::uriToMaybeLocalActor($uri);
if (!\is_null($actor)) {
return $actor;
}
}
}
// if it's not to or cc anyone we have to dive deeper
if ($type->has('object')) {
// the 'object' field might just be a uri of one
// of our Actors, if this is a follow or block
$object = $type->get('object');
if (\is_string($object)) {
$actor = self::uriToMaybeLocalActor($object);
if (!\is_null($actor)) {
return $actor;
}
} else if ($object instanceof AbstractObject) {
// if the inner object is also a Type, repeat the process
return $this->deriveActivityTo($object);
}
}
return null;
}
/**
* Get local actor that owns or corresponds to given uri.
*
* @param string $uri
*
* @return Actor|null
*/
private static function uriToMaybeLocalActor(string $uri): Actor|null
{
$parsed = parse_url($uri);
// check if this uri belongs to us
if ($parsed['host'] === Common::config('site', 'server')) {
// it is our uri so we should be able to get
// the actor without making any remote calls
$actor = Explorer::getLocalActorForPath($parsed['path']);
if (!\is_null($actor)) {
return $actor;
}
}
return null;
}
}

View File

@@ -34,7 +34,7 @@ namespace Plugin\ActivityPub\Controller;
use function App\Core\I18n\_m;
use App\Core\Log;
use App\Core\Router\Router;
use App\Core\Router;
use App\Entity\Activity;
use App\Entity\Actor;
use App\Util\Exception\ClientException;

View File

@@ -32,7 +32,7 @@ declare(strict_types = 1);
namespace Plugin\ActivityPub\Entity;
use App\Core\DB\DB;
use App\Core\DB;
use App\Core\Entity;
use App\Entity\Activity;
use DateTimeInterface;
@@ -101,27 +101,17 @@ class ActivitypubActivity extends Entity
public function getActivity(): Activity
{
return DB::findOneBy('activity', ['id' => $this->getActivityId()]);
return DB::findOneBy(Activity::class, ['id' => $this->getActivityId()]);
}
public array $_object_mention_ids = [];
public function setObjectMentionIds(array $mentions): self
public function getAttentionTargetIds(): array
{
$this->_object_mention_ids = $mentions;
return $this;
return $this->getActivity()->getAttentionTargetIds();
}
/**
* @see Entity->getNotificationTargetIds
*/
public function getNotificationTargetIds(array $ids_already_known = [], ?int $sender_id = null, bool $include_additional = true): array
public function getAttentionTargets(): array
{
// Additional actors that should know about this
if (\array_key_exists('additional', $ids_already_known)) {
return $ids_already_known['additional'];
} else {
return $this->_object_mention_ids;
}
return $this->getActivity()->getAttentionTargets();
}
public static function schemaDef(): array

View File

@@ -33,7 +33,7 @@ declare(strict_types = 1);
namespace Plugin\ActivityPub\Entity;
use App\Core\Cache;
use App\Core\DB\DB;
use App\Core\DB;
use App\Core\Entity;
use function App\Core\I18n\_m;
use App\Core\Log;

View File

@@ -32,7 +32,7 @@ declare(strict_types = 1);
namespace Plugin\ActivityPub\Entity;
use App\Core\DB\DB;
use App\Core\DB;
use App\Core\Entity;
use DateTimeInterface;
@@ -115,6 +115,16 @@ class ActivitypubObject extends Entity
return DB::findOneBy($this->getObjectType(), ['id' => $this->getObjectId()]);
}
public function getAttentionTargetIds(): array
{
return $this->getObject()->getAttentionTargetIds();
}
public function getAttentionTargets(): array
{
return $this->getObject()->getAttentionTargets();
}
public static function schemaDef(): array
{
return [

View File

@@ -32,7 +32,7 @@ declare(strict_types = 1);
namespace Plugin\ActivityPub\Entity;
use App\Core\DB\DB;
use App\Core\DB;
use App\Core\Entity;
use App\Core\Log;
use App\Entity\Actor;

View File

@@ -0,0 +1,70 @@
<?php
declare(strict_types = 1);
namespace Plugin\ActivityPub\Test\Fixtures;
use App\Core\DB;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;
use Plugin\ActivityPub\Util\Model\Activity;
use Plugin\ActivityPub\Util\Model\Actor;
use Plugin\ActivityPub\Util\Model\Note;
class ActivityPubFixtures extends Fixture
{
private static string $fixtures_path = __DIR__ . \DIRECTORY_SEPARATOR;
public static function fixturesPath(string $path, string $ontology = 'gnusocial'): string
{
return self::$fixtures_path . $ontology . \DIRECTORY_SEPARATOR . $path;
}
public function load(ObjectManager $manager)
{
/*
* Beware that it's important to Load Actors, Objects, Activities in this sequence
* because we're running offline tests here.
*/
$ontology = 'gnusocial';
// Load Actors
$person_path = self::fixturesPath('objects/person.jsonld', $ontology);
$person = Actor::fromJson(fread(fopen($person_path, 'r'), filesize($person_path)));
DB::flush();
$another_person_path = self::fixturesPath('objects/another_person.jsonld', $ontology);
$another_person = Actor::fromJson(fread(fopen($another_person_path, 'r'), filesize($another_person_path)));
DB::flush();
$group_path = self::fixturesPath('objects/group.jsonld', $ontology);
$group = Actor::fromJson(fread(fopen($group_path, 'r'), filesize($group_path)));
DB::flush();
// Load Objects
$note_path = self::fixturesPath('objects/note.jsonld', $ontology);
$note = Note::fromJson(fread(fopen($note_path, 'r'), filesize($note_path)));
DB::flush();
$article_path = self::fixturesPath('objects/article.jsonld', $ontology);
$article = Note::fromJson(fread(fopen($article_path, 'r'), filesize($article_path)));
DB::flush();
$reply_path = self::fixturesPath('objects/reply.jsonld', $ontology);
$reply = Note::fromJson(fread(fopen($reply_path, 'r'), filesize($reply_path)));
DB::flush();
$note_with_mention_path = self::fixturesPath('objects/note_with_mention.jsonld', $ontology);
$note_with_mention = Note::fromJson(fread(fopen($note_with_mention_path, 'r'), filesize($note_with_mention_path)));
DB::flush();
// Load Activities
$create_note_path = self::fixturesPath('activities/create_note.jsonld', $ontology);
$create_note = Activity::fromJson(fread(fopen($create_note_path, 'r'), filesize($create_note_path)));
DB::flush();
$create_article_path = self::fixturesPath('activities/create_article.jsonld', $ontology);
$create_article = Activity::fromJson(fread(fopen($create_article_path, 'r'), filesize($create_article_path)));
DB::flush();
$create_reply_path = self::fixturesPath('activities/create_reply.jsonld', $ontology);
$create_reply = Activity::fromJson(fread(fopen($create_reply_path, 'r'), filesize($create_reply_path)));
DB::flush();
$like_note_path = self::fixturesPath('activities/like_note.jsonld', $ontology);
$like_note = Activity::fromJson(fread(fopen($like_note_path, 'r'), filesize($like_note_path)));
DB::flush();
}
}

View File

@@ -0,0 +1,53 @@
{
"type": "Create",
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
{
"gs": "https://www.gnu.org/software/social/ns#"
},
{
"litepub": "http://litepub.social/ns#"
},
{
"chatMessage": "litepub:chatMessage"
},
{
"inConversation": {
"@id": "gs:inConversation",
"@type": "@id"
}
}
],
"id": "https://instance.gnusocial.test/activity/1338",
"published": "2022-03-17T23:30:26+00:00",
"actor": "https://instance.gnusocial.test/actor/42",
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc": [
"https://instance.gnusocial.test/actor/21"
],
"object": {
"type": "Article",
"id": "https://instance.gnusocial.test/object/note/1338",
"published": "2022-03-17T23:30:26+00:00",
"attributedTo": "https://instance.gnusocial.test/actor/42",
"name": "hello, world.",
"content": "<p>This is an interesting article.</p>",
"mediaType": "text/html",
"source": {
"content": "This is an interesting article.",
"mediaType": "text/markdown"
},
"attachment": [],
"tag": [],
"inConversation": "https://instance.gnusocial.test/conversation/1338",
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc": [
"https://instance.gnusocial.test/actor/21"
]
}
}

Some files were not shown because too many files have changed in this diff Show More