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' APP_SECRET='$ecretf0rt3st'
SYMFONY_DEPRECATIONS_HELPER=999999 SYMFONY_DEPRECATIONS_HELPER=999999
PANTHER_APP_ENV=panther 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. // 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, 'no_spaces_inside_parenthesis' => true,
// Removes `@param`, `@return` and `@var` tags that don't provide any useful information. // 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. // Remove trailing commas in list function calls.
'no_trailing_comma_in_list_call' => true, 'no_trailing_comma_in_list_call' => true,
// PHP single-line arrays should not have trailing comma. // PHP single-line arrays should not have trailing comma.

View File

@@ -22,10 +22,10 @@ declare(strict_types = 1);
namespace Component\Attachment; namespace Component\Attachment;
use App\Core\Cache; use App\Core\Cache;
use App\Core\DB\DB; use App\Core\DB;
use App\Core\Event; use App\Core\Event;
use App\Core\Modules\Component; use App\Core\Modules\Component;
use App\Core\Router\RouteLoader; use App\Core\Router;
use App\Entity\Actor; use App\Entity\Actor;
use App\Entity\Note; use App\Entity\Note;
use App\Util\Formatting; use App\Util\Formatting;
@@ -34,10 +34,11 @@ use Component\Attachment\Entity as E;
use Doctrine\Common\Collections\ExpressionBuilder; use Doctrine\Common\Collections\ExpressionBuilder;
use Doctrine\ORM\Query\Expr; use Doctrine\ORM\Query\Expr;
use Doctrine\ORM\QueryBuilder; use Doctrine\ORM\QueryBuilder;
use EventResult;
class Attachment extends Component 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_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']); $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 * 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); $out_hash = hash_file(E\Attachment::FILEHASH_ALGO, $filename);
return Event::stop; 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()}"); Cache::delete("note-attachments-{$note->getId()}");
foreach ($note->getAttachments() as $attachment) { foreach ($note->getAttachments() as $attachment) {
@@ -68,7 +69,7 @@ class Attachment extends Component
return Event::next; 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())) { if (!\in_array('attachment_to_note', $note_qb->getAllAliases())) {
$note_qb->leftJoin( $note_qb->leftJoin(
@@ -84,7 +85,7 @@ class Attachment extends Component
/** /**
* Populate $note_expr with the criteria for looking for notes with attachments * 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; $include_term = str_contains($term, ':') ? explode(':', $term)[1] : $term;
if (Formatting::startsWith($term, ['note-types:', 'notes-incude:', 'note-filter:'])) { if (Formatting::startsWith($term, ['note-types:', 'notes-incude:', 'note-filter:'])) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -21,10 +21,10 @@ declare(strict_types = 1);
namespace Component\Attachment\tests\Entity; namespace Component\Attachment\tests\Entity;
use App\Core\DB\DB; use App\Core\DB;
use App\Core\Event; use App\Core\Event;
use App\Core\GSFile; use App\Core\GSFile;
use App\Core\Router\Router; use App\Core\Router;
use App\Entity\Note; use App\Entity\Note;
use App\Util\GNUsocialTestCase; use App\Util\GNUsocialTestCase;
use App\Util\TemporaryFile; use App\Util\TemporaryFile;

View File

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

View File

@@ -22,26 +22,27 @@ declare(strict_types = 1);
namespace Component\Avatar; namespace Component\Avatar;
use App\Core\Cache; use App\Core\Cache;
use App\Core\DB\DB; use App\Core\DB;
use App\Core\Event; use App\Core\Event;
use App\Core\GSFile; use App\Core\GSFile;
use App\Core\Modules\Component; use App\Core\Modules\Component;
use App\Core\Router\RouteLoader; use App\Core\Router;
use App\Core\Router\Router;
use App\Util\Common; use App\Util\Common;
use Component\Attachment\Entity\Attachment; use Component\Attachment\Entity\Attachment;
use Component\Attachment\Entity\AttachmentThumbnail; use Component\Attachment\Entity\AttachmentThumbnail;
use Component\Avatar\Controller as C; use Component\Avatar\Controller as C;
use Component\Avatar\Exception\NoAvatarException; use Component\Avatar\Exception\NoAvatarException;
use EventResult;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
class Avatar extends Component 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_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']); $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 * @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') { if ($section === 'profile') {
$tabs[] = [ $tabs[] = [
@@ -65,7 +68,7 @@ class Avatar extends Component
return Event::next; return Event::next;
} }
public function onAvatarUpdate(int $actor_id): bool public function onAvatarUpdate(int $actor_id): EventResult
{ {
Cache::delete("avatar-{$actor_id}"); Cache::delete("avatar-{$actor_id}");
foreach (['full', 'big', 'medium', 'small'] as $size) { 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. * Returns the avatar file's hash, mimetype, title and path.
* Ensures exactly one cached value exists * 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 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; namespace Component\Avatar\Controller;
use App\Core\Controller; use App\Core\Controller;
use App\Core\DB\DB; use App\Core\DB;
use App\Core\Event; use App\Core\Event;
use App\Core\Form; use App\Core\Form;
use App\Core\GSFile; use App\Core\GSFile;

View File

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

View File

@@ -24,16 +24,14 @@ declare(strict_types = 1);
namespace Component\Circle; namespace Component\Circle;
use App\Core\Cache; use App\Core\Cache;
use App\Core\DB\DB; use App\Core\DB;
use App\Core\Event; use App\Core\Event;
use function App\Core\I18n\_m; use function App\Core\I18n\_m;
use App\Core\Modules\Component; use App\Core\Modules\Component;
use App\Core\Router\RouteLoader; use App\Core\Router;
use App\Core\Router\Router;
use App\Entity\Actor; use App\Entity\Actor;
use App\Entity\Feed; use App\Entity\Feed;
use App\Entity\LocalUser; use App\Entity\LocalUser;
use App\Util\Common;
use App\Util\Nickname; use App\Util\Nickname;
use Component\Circle\Controller as CircleController; use Component\Circle\Controller as CircleController;
use Component\Circle\Entity\ActorCircle; use Component\Circle\Entity\ActorCircle;
@@ -41,6 +39,7 @@ use Component\Circle\Entity\ActorCircleSubscription;
use Component\Circle\Entity\ActorTag; use Component\Circle\Entity\ActorTag;
use Component\Collection\Util\MetaCollectionTrait; use Component\Collection\Util\MetaCollectionTrait;
use Component\Tag\Tag; use Component\Tag\Tag;
use EventResult;
use Functional as F; use Functional as F;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
@@ -55,12 +54,13 @@ use Symfony\Component\HttpFoundation\Request;
*/ */
class Circle extends Component class Circle extends Component
{ {
/** @phpstan-use MetaCollectionTrait<ActorCircle> */
use MetaCollectionTrait; use MetaCollectionTrait;
public const TAG_CIRCLE_REGEX = '/' . Nickname::BEFORE_MENTIONS . '@#([\pL\pN_\-\.]{1,64})/'; public const TAG_CIRCLE_REGEX = '/' . Nickname::BEFORE_MENTIONS . '@#([\pL\pN_\-\.]{1,64})/';
protected const SLUG = 'circle'; protected const SLUG = 'circle';
protected const PLURAL_SLUG = 'circles'; 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']); $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 // 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'])) { if ($section === 'profile' && \in_array($request->get('_route'), ['person_actor_settings', 'group_actor_settings'])) {
$tabs[] = [ $tabs[] = [
'title' => 'Self tags', 'title' => _m('Self tags'),
'desc' => 'Add or remove tags on yourself', 'desc' => _m('Add or remove tags to this actor'),
'id' => 'settings-self-tags', '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; 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(); $circles = $actor->getCircles();
foreach ($circles as $circle) { foreach ($circles as $circle) {
@@ -120,6 +123,9 @@ class Circle extends Component
// Meta Collection ------------------------------------------------------------------- // Meta Collection -------------------------------------------------------------------
/**
* @param array<string, mixed> $vars
*/
private function getActorIdFromVars(array $vars): int private function getActorIdFromVars(array $vars): int
{ {
$id = $vars['request']->get('id', null); $id = $vars['request']->get('id', null);
@@ -131,7 +137,7 @@ class Circle extends Component
return $user->getId(); 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(); $tagger_id = \is_int($tagger_id) ? $tagger_id : $tagger_id->getId();
$circle = ActorCircle::create([ $circle = ActorCircle::create([
@@ -147,7 +153,10 @@ class Circle extends Component
return $circle->getId(); 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); $this->createCircle($owner, $name);
DB::persist(ActorTag::create([ 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(); $tagger_id = $owner->getId();
$tagged_id = $this->getActorIdFromVars($vars); $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]); DB::removeBy(ActorTag::class, ['tagger' => $tagger_id, 'tagged' => $tagged_id, 'tag' => $tag]);
} }
Cache::delete(Actor::cacheKeys($tagger_id)['circles']); 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(); $tagger_id = $owner->getId();
$tagged_id = $this->getActorIdFromVars($vars); $tagged_id = $this->getActorIdFromVars($vars);
@@ -189,8 +209,10 @@ class Circle extends Component
/** /**
* @see MetaCollectionPlugin->shouldAddToRightPanel * @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']); return \in_array($vars['path'], ['actor_view_nickname', 'actor_view_id']);
} }
@@ -203,8 +225,10 @@ class Circle extends Component
* itself, and from every Actor that is a part of its ActorCircle. * 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 Actor $owner the Actor, and by extension its own circle of Actors
* @param null|array $vars Page vars sent by AppendRightPanelBlock event * @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 * @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 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; 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([ DB::persist(Feed::create([
'actor_id' => $actor_id, 'actor_id' => $actor_id,

View File

@@ -38,6 +38,8 @@ class Circle extends CircleController
* *
* @throws \App\Util\Exception\ServerException * @throws \App\Util\Exception\ServerException
* @throws ClientException * @throws ClientException
*
* @return ControllerResultType
*/ */
public function circleById(int|ActorCircle $circle_id): array 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 public function circleByTaggerIdAndTag(int $tagger_id, string $tag): array
{ {
return $this->circleById(ActorCircle::getByPK(['tagger' => $tagger_id, 'tag' => $tag])); return $this->circleById(ActorCircle::getByPK(['tagger' => $tagger_id, 'tag' => $tag]));
} }
/**
* @return ControllerResultType
*/
public function circleByTaggerNicknameAndTag(string $tagger_nickname, string $tag): array public function circleByTaggerNicknameAndTag(string $tagger_nickname, string $tag): array
{ {
return $this->circleById(ActorCircle::getByPK(['tagger' => LocalUser::getByNickname($tagger_nickname)->getId(), 'tag' => $tag])); 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; namespace Component\Circle\Controller;
use App\Core\Cache; use App\Core\Cache;
use App\Core\DB\DB; use App\Core\DB;
use App\Core\Router\Router; use App\Core\Router;
use App\Entity\Actor; use App\Entity\Actor;
use App\Entity\LocalUser; use App\Entity\LocalUser;
use Component\Circle\Entity\ActorCircle; use Component\Circle\Entity\ActorCircle;
use Component\Collection\Util\Controller\MetaCollectionController; use Component\Collection\Util\Controller\MetaCollectionController;
/**
* @extends MetaCollectionController<Circles>
*/
class Circles extends MetaCollectionController class Circles extends MetaCollectionController
{ {
protected const SLUG = 'circle'; protected const SLUG = 'circle';
protected const PLURAL_SLUG = 'circles'; protected const PLURAL_SLUG = 'circles';
protected string $page_title = 'Actor 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 public function getCollectionUrl(int $owner_id, ?string $owner_nickname, int $collection_id): string
{ {
return Router::url( 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 []; // TODO
return [
'_template' => 'collection/notes.html.twig',
'notes' => $notes,
];
} }
/**
* @return Circles[]
*/
public function feedByCircleId(int $circle_id) public function feedByCircleId(int $circle_id)
{ {
// Owner id isn't used // Owner id isn't used
return $this->getCollectionItems(0, $circle_id); return $this->getCollectionItems(0, $circle_id);
} }
/**
* @return Circles[]
*/
public function feedByTaggerIdAndTag(int $tagger_id, string $tag) public function feedByTaggerIdAndTag(int $tagger_id, string $tag)
{ {
// Owner id isn't used // Owner id isn't used
@@ -71,6 +80,9 @@ class Circles extends MetaCollectionController
return $this->getCollectionItems($tagger_id, $circle_id); return $this->getCollectionItems($tagger_id, $circle_id);
} }
/**
* @return Circles[]
*/
public function feedByTaggerNicknameAndTag(string $tagger_nickname, string $tag) public function feedByTaggerNicknameAndTag(string $tagger_nickname, string $tag)
{ {
$tagger_id = LocalUser::getByNickname($tagger_nickname)->getId(); $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']); 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]); 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) { foreach ($collection->getActorTags(db_reference: true) as $at) {
$at->setTag($name); $at->setTag($name);
@@ -96,7 +109,7 @@ class Circles extends MetaCollectionController
Cache::delete(Actor::cacheKeys($actor_id)['circles']); 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) { foreach ($collection->getActorTags(db_reference: true) as $at) {
DB::remove($at); DB::remove($at);

View File

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

View File

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

View File

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

View File

@@ -7,14 +7,18 @@ namespace Component\Circle\Form;
use App\Core\Form; use App\Core\Form;
use function App\Core\I18n\_m; use function App\Core\I18n\_m;
use App\Util\Form\ArrayTransformer; 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\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
abstract class SelfTagsForm 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( public static function handleTags(
Request $request, Request $request,
@@ -34,7 +38,7 @@ abstract class SelfTagsForm
$existing_form = !empty($form_definition) ? Form::create($form_definition) : null; $existing_form = !empty($form_definition) ? Form::create($form_definition) : null;
$add_form = Form::create([ $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]], [$add_form_name = 'new-tags-add', SubmitType::class, ['label' => $add_label]],
]); ]);

View File

@@ -4,7 +4,7 @@ declare(strict_types = 1);
namespace Component\Collection; namespace Component\Collection;
use App\Core\DB\DB; use App\Core\DB;
use App\Core\Event; use App\Core\Event;
use App\Core\Modules\Component; use App\Core\Modules\Component;
use App\Entity\Actor; use App\Entity\Actor;
@@ -14,6 +14,7 @@ use Component\Subscription\Entity\ActorSubscription;
use Doctrine\Common\Collections\ExpressionBuilder; use Doctrine\Common\Collections\ExpressionBuilder;
use Doctrine\ORM\Query\Expr; use Doctrine\ORM\Query\Expr;
use Doctrine\ORM\QueryBuilder; use Doctrine\ORM\QueryBuilder;
use EventResult;
class Collection extends Component 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 * Supports a variety of query terms and is used both in feeds and
* in search. Uses query builders to allow for extension * 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 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]; 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(); $note_aliases = $note_qb->getAllAliases();
if (!\in_array('subscription', $note_aliases)) { if (!\in_array('subscription', $note_aliases)) {
@@ -79,8 +85,11 @@ class Collection extends Component
/** /**
* Convert $term to $note_expr and $actor_expr, search criteria. Handles searching for text * 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 * 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, ':')) { if (str_contains($term, ':')) {
$term = explode(':', $term); $term = explode(':', $term);

View File

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

View File

@@ -6,15 +6,25 @@ namespace Component\Collection\Util\Controller;
use App\Core\Controller; use App\Core\Controller;
use App\Entity\Actor; use App\Entity\Actor;
use App\Entity\Note;
use App\Util\Common; 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 public function query(string $query, ?string $locale = null, ?Actor $actor = null, array $note_order_by = [], array $actor_order_by = []): array
{ {
$actor ??= Common::actor(); $actor ??= Common::actor();
$locale ??= Common::currentLanguage()->getLocale(); $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 App\Util\Common;
use Functional as F; use Functional as F;
/**
* @template T
*
* @extends OrderedCollection<T>
*/
abstract class FeedController extends OrderedCollection abstract class FeedController extends OrderedCollection
{ {
/** /**
* Post-processing of the result of a feed controller, to remove any * Post-processing of the result of a feed controller, to remove any
* notes or actors the user specified, as well as format the raw * notes or actors the user specified, as well as format the raw
* list of notes into a usable format * list of notes into a usable format
*
* @template NA of Note|Actor
*
* @param NA[] $result
*
* @return NA[]
*/ */
protected function postProcess(array $result): array protected function postProcess(array $result): array
{ {
@@ -58,6 +69,9 @@ abstract class FeedController extends OrderedCollection
return $result; return $result;
} }
/**
* @param Note[] $notes
*/
private static function enforceScope(array &$notes, ?Actor $actor, ?Actor $in = null): void private static function enforceScope(array &$notes, ?Actor $actor, ?Actor $in = null): void
{ {
$notes = F\select($notes, fn (Note $n) => $n->isVisibleTo($actor, $in)); $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; namespace Component\Collection\Util\Controller;
use App\Core\DB\DB; use App\Core\DB;
use App\Core\Form; use App\Core\Form;
use function App\Core\I18n\_m; use function App\Core\I18n\_m;
use App\Entity\LocalUser; use App\Entity\LocalUser;
@@ -39,8 +39,14 @@ use App\Util\Common;
use App\Util\Exception\RedirectException; use App\Util\Exception\RedirectException;
use Symfony\Component\Form\Extension\Core\Type\SubmitType; use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormView;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
/**
* @template T of object
*
* @extends FeedController<T>
*/
abstract class MetaCollectionController extends FeedController abstract class MetaCollectionController extends FeedController
{ {
protected const SLUG = 'collectionsEntry'; protected const SLUG = 'collectionsEntry';
@@ -48,17 +54,36 @@ abstract class MetaCollectionController extends FeedController
protected string $page_title = 'Collections'; protected string $page_title = 'Collections';
abstract public function getCollectionUrl(int $owner_id, string $owner_nickname, int $collection_id): string; 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 public function collectionsViewByActorNickname(Request $request, string $nickname): array
{ {
$user = DB::findOneBy(LocalUser::class, ['nickname' => $nickname]); $user = DB::findOneBy(LocalUser::class, ['nickname' => $nickname]);
return self::collectionsView($request, $user->getId(), $nickname); return self::collectionsView($request, $user->getId(), $nickname);
} }
/**
* @return ControllerResultType
*/
public function collectionsViewByActorId(Request $request, int $id): array public function collectionsViewByActorId(Request $request, int $id): array
{ {
return self::collectionsView($request, $id, null); return self::collectionsView($request, $id, null);
@@ -70,7 +95,7 @@ abstract class MetaCollectionController extends FeedController
* @param int $id actor id * @param int $id actor id
* @param ?string $nickname actor nickname * @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 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. // 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 // 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) { $fn = new class($id, $nickname, $request, $this, static::SLUG) {
private $id; public function __construct(private int $id, private string $nickname, private Request $request, private object $parent, private string $slug)
private $nick;
private $request;
private $parent;
private $slug;
public function __construct($id, $nickname, $request, $parent, $slug)
{ {
$this->id = $id;
$this->nick = $nickname;
$this->request = $request;
$this->parent = $parent;
$this->slug = $slug;
} }
// there's already an injected function called path, // there's already an injected function called path,
// that maps to Router::url(name, args), but since // that maps to Router::url(name, args), but since
// I want to preserve nicknames, I think it's better // I want to preserve nicknames, I think it's better
// to use that getUrl function // 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 // There are many collections in this page and we need two
// forms for each one of them: one form to edit the collection's // forms for each one of them: one form to edit the collection's
// name and another to remove the collection. // name and another to remove the collection.
// creating the edit form // creating the edit form
public function editForm($collection) public function editForm(object $collection): FormView
{ {
$edit = Form::create([ $edit = Form::create([
['name', TextType::class, [ ['name', TextType::class, [
@@ -159,7 +173,7 @@ abstract class MetaCollectionController extends FeedController
]); ]);
$edit->handleRequest($this->request); $edit->handleRequest($this->request);
if ($edit->isSubmitted() && $edit->isValid()) { 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(); DB::flush();
throw new RedirectException(); throw new RedirectException();
} }
@@ -167,7 +181,7 @@ abstract class MetaCollectionController extends FeedController
} }
// creating the remove form // creating the remove form
public function rmForm($collection) public function rmForm(object $collection): FormView
{ {
$rm = Form::create([ $rm = Form::create([
['remove_' . $collection->getId(), SubmitType::class, [ ['remove_' . $collection->getId(), SubmitType::class, [
@@ -180,7 +194,7 @@ abstract class MetaCollectionController extends FeedController
]); ]);
$rm->handleRequest($this->request); $rm->handleRequest($this->request);
if ($rm->isSubmitted()) { if ($rm->isSubmitted()) {
$this->parent->removeCollection($this->id, $this->nick, $collection); $this->parent->removeCollection($this->id, $this->nickname, $collection);
DB::flush(); DB::flush();
throw new RedirectException(); throw new RedirectException();
} }
@@ -198,12 +212,18 @@ abstract class MetaCollectionController extends FeedController
]; ];
} }
/**
* @return ControllerResultType
*/
public function collectionsEntryViewNotesByNickname(Request $request, string $nickname, int $cid): array public function collectionsEntryViewNotesByNickname(Request $request, string $nickname, int $cid): array
{ {
$user = DB::findOneBy(LocalUser::class, ['nickname' => $nickname]); $user = DB::findOneBy(LocalUser::class, ['nickname' => $nickname]);
return self::collectionsEntryViewNotesByActorId($request, $user->getId(), $cid); return self::collectionsEntryViewNotesByActorId($request, $user->getId(), $cid);
} }
/**
* @return ControllerResultType
*/
public function collectionsEntryViewNotesByActorId(Request $request, int $id, int $cid): array public function collectionsEntryViewNotesByActorId(Request $request, int $id, int $cid): array
{ {
$collection = $this->getCollectionBy($id, $cid); $collection = $this->getCollectionBy($id, $cid);

View File

@@ -4,6 +4,11 @@ declare(strict_types = 1);
namespace Component\Collection\Util\Controller; 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; namespace Component\Collection\Util;
use App\Core\DB\DB; use App\Core\DB;
use App\Core\Event; use App\Core\Event;
use App\Core\Form; use App\Core\Form;
use function App\Core\I18n\_m; use function App\Core\I18n\_m;
@@ -39,11 +39,15 @@ use App\Entity\Actor;
use App\Util\Common; use App\Util\Common;
use App\Util\Exception\RedirectException; use App\Util\Exception\RedirectException;
use App\Util\Formatting; use App\Util\Formatting;
use EventResult;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType; use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
/**
* @template T
* */
trait MetaCollectionTrait trait MetaCollectionTrait
{ {
//protected const SLUG = 'collection'; //protected const SLUG = 'collection';
@@ -53,39 +57,43 @@ trait MetaCollectionTrait
* create a collection owned by Actor $owner. * create a collection owned by Actor $owner.
* *
* @param Actor $owner The collection's owner * @param Actor $owner The collection's owner
* @param array $vars Page vars sent by AppendRightPanelBlock event * @param array<string, mixed> $vars Page vars sent by AppendRightPanelBlock event
* @param string $name Collection's name * @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. * remove item from collections.
* *
* @param Actor $owner Current user * @param Actor $owner Current user
* @param array $vars Page vars sent by AppendRightPanelBlock event * @param array<string, mixed> $vars Page vars sent by AppendRightPanelBlock event
* @param array $items Array of collections's ids to remove the current item from * @param int[] $items Array of collections's ids to remove the current item from
* @param array $collections List of ids of collections owned by $owner * @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. * add item to collections.
* *
* @param Actor $owner Current user * @param Actor $owner Current user
* @param array $vars Page vars sent by AppendRightPanelBlock event * @param array<string, mixed> $vars Page vars sent by AppendRightPanelBlock event
* @param array $items Array of collections's ids to add the current item to * @param int[] $items Array of collections's ids to add the current item to
* @param array $collections List of ids of collections owned by $owner * @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 * 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 * Get array of collections's owned by $actor
* *
* @param Actor $owner Collection's owner * @param Actor $owner Collection's owner
* @param ?array $vars Page vars sent by AppendRightPanelBlock event * @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 * @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; 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. * Append Collections widget to the right panel.
* It's compose of two forms: one to select collections to add * It's compose of two forms: one to select collections to add
* the current item to, and another to create a new collection. * 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(); $user = Common::actor();
if (\is_null($user)) { if (\is_null($user)) {
@@ -186,7 +197,10 @@ trait MetaCollectionTrait
return Event::next; 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/widget.css';
$styles[] = 'components/Collection/assets/css/pages.css'; $styles[] = 'components/Collection/assets/css/pages.css';

View File

@@ -32,6 +32,9 @@ abstract class Parser
{ {
/** /**
* Merge $parts into $criteria_arr * 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 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; namespace Component\Conversation\Controller;
use App\Core\Cache; use App\Core\Cache;
use App\Core\DB\DB; use App\Core\DB;
use App\Core\Form; use App\Core\Form;
use function App\Core\I18n\_m; use function App\Core\I18n\_m;
use App\Core\Log; use App\Core\Log;
use App\Core\Router\Router; use App\Core\Router;
use App\Entity\Note; use App\Entity\Note;
use App\Util\Common; use App\Util\Common;
use App\Util\Exception\ClientException; 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\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
/**
* @extends FeedController<\App\Entity\Note>
*/
class Conversation extends FeedController class Conversation extends FeedController
{ {
/** /**
@@ -55,7 +58,10 @@ class Conversation extends FeedController
* *
* @throws \App\Util\Exception\ServerException * @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 public function showConversation(Request $request, int $conversation_id): array
{ {
@@ -83,7 +89,7 @@ class Conversation extends FeedController
* @throws NoSuchNoteException * @throws NoSuchNoteException
* @throws ServerException * @throws ServerException
* *
* @return array * @return ControllerResultType
*/ */
public function addReply(Request $request) public function addReply(Request $request)
{ {
@@ -103,7 +109,7 @@ class Conversation extends FeedController
* @throws \App\Util\Exception\RedirectException * @throws \App\Util\Exception\RedirectException
* @throws \App\Util\Exception\ServerException * @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) public function muteConversation(Request $request, int $conversation_id)
{ {

View File

@@ -28,12 +28,11 @@ declare(strict_types = 1);
namespace Component\Conversation; namespace Component\Conversation;
use App\Core\Cache; use App\Core\Cache;
use App\Core\DB\DB; use App\Core\DB;
use App\Core\Event; use App\Core\Event;
use function App\Core\I18n\_m; use function App\Core\I18n\_m;
use App\Core\Modules\Component; use App\Core\Modules\Component;
use App\Core\Router\RouteLoader; use App\Core\Router;
use App\Core\Router\Router;
use App\Entity\Activity; use App\Entity\Activity;
use App\Entity\Actor; use App\Entity\Actor;
use App\Entity\Note; use App\Entity\Note;
@@ -41,12 +40,13 @@ use App\Util\Common;
use App\Util\Formatting; use App\Util\Formatting;
use Component\Conversation\Entity\Conversation as ConversationEntity; use Component\Conversation\Entity\Conversation as ConversationEntity;
use Component\Conversation\Entity\ConversationMute; use Component\Conversation\Entity\ConversationMute;
use EventResult;
use Functional as F; use Functional as F;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
class Conversation extends Component 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', '/conversation/{conversation_id<\d+>}', [Controller\Conversation::class, 'showConversation']);
$r->connect('conversation_mute', '/conversation/{conversation_id<\d+>}/mute', [Controller\Conversation::class, 'muteConversation']); $r->connect('conversation_mute', '/conversation/{conversation_id<\d+>}/mute', [Controller\Conversation::class, 'muteConversation']);
@@ -96,16 +96,16 @@ class Conversation extends Component
* action, if a user is logged in. * action, if a user is logged in.
* *
* @param \App\Entity\Note $note The Note being rendered * @param \App\Entity\Note $note The Note being rendered
* @param array $actions Contains keys 'url' (linking 'conversation_reply_to' * @param array{url: string, title: string, classes: string, id: string} $actions
* route), 'title' (used as title for aforementioned url), * Contains keys 'url' (linking 'conversation_reply_to' route),
* 'classes' (CSS styling classes used to visually inform the user of action context), * 'title' (used as title for aforementioned url), 'classes' (CSS styling
* 'id' (HTML markup id used to redirect user to this anchor upon performing the action) * 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 * @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())) { if (\is_null(Common::user())) {
return Event::next; return Event::next;
@@ -140,12 +140,13 @@ class Conversation extends Component
/** /**
* Append on note information about user actions. * Append on note information about user actions.
* *
* @param array $vars Contains information related to Note currently being rendered * @param array<string, mixed> $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']) * @param array{actors: Actor[], action: string} $result
* *cContains keys 'actors', and 'action'. Needed to construct a string,
* @return bool EventHook * 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')) { if (str_contains($vars['request']->getPathInfo(), 'conversation')) {
return Event::next; 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 \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 * @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); $to_note_id = $this->getReplyToIdFromRequest($request);
if (!\is_null($to_note_id)) { 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 * 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\ClientException
* @throws \App\Util\Exception\NoSuchNoteException * @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); $to_note_id = $this->getReplyToIdFromRequest($request);
if (!\is_null($to_note_id)) { if (!\is_null($to_note_id)) {
@@ -232,8 +229,10 @@ class Conversation extends Component
/** /**
* Add minimal Note card to RightPanel template * 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'))]); $elements[] = Formatting::twigRenderFile('cards/blocks/note_compact_wrapper.html.twig', ['note' => Note::getById((int) $request->query->get('reply_to_id'))]);
return Event::next; return Event::next;
@@ -245,10 +244,8 @@ class Conversation extends Component
* *
* @param \App\Entity\Note $note Note being deleted * @param \App\Entity\Note $note Note being deleted
* @param \App\Entity\Actor $actor Actor that performed the delete action * @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 // Ensure we have the most up to date replies
Cache::delete(Note::cacheKeys($note->getId())['replies']); Cache::delete(Note::cacheKeys($note->getId())['replies']);
@@ -261,13 +258,13 @@ class Conversation extends Component
* Adds extra actions related to Conversation Component, that act upon/from the given Note. * Adds extra actions related to Conversation Component, that act upon/from the given Note.
* *
* @param \App\Entity\Note $note Current Note being rendered * @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 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 * @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())) { if (\is_null($user = Common::user())) {
return Event::next; return Event::next;
@@ -300,10 +297,8 @@ class Conversation extends Component
* Prevents new Notifications to appear for muted conversations * Prevents new Notifications to appear for muted conversations
* *
* @param Activity $activity Notification Activity * @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)) { if ($activity->getObjectType() === 'note' && ConversationMute::isMuted($activity, $actor)) {
return Event::stop; return Event::stop;

View File

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

View File

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

View File

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

View File

@@ -25,12 +25,13 @@ namespace Component\Feed;
use App\Core\Event; use App\Core\Event;
use App\Core\Modules\Component; use App\Core\Modules\Component;
use App\Core\Router\RouteLoader; use App\Core\Router;
use Component\Feed\Controller as C; use Component\Feed\Controller as C;
use EventResult;
class Feed extends Component 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_public', '/feed/public', [C\Feeds::class, 'public']);
$r->connect('feed_home', '/feed/home', [C\Feeds::class, 'home']); $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; namespace Component\Feed\tests\Controller;
use App\Core\Router\Router; use App\Core\Router;
use App\Util\GNUsocialTestCase; use App\Util\GNUsocialTestCase;
use Component\Feed\Controller\Feeds; use Component\Feed\Controller\Feeds;
use Jchook\AssertThrows\AssertThrows; use Jchook\AssertThrows\AssertThrows;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -24,8 +24,7 @@ namespace Component\Group;
use App\Core\Event; use App\Core\Event;
use function App\Core\I18n\_m; use function App\Core\I18n\_m;
use App\Core\Modules\Component; use App\Core\Modules\Component;
use App\Core\Router\RouteLoader; use App\Core\Router;
use App\Core\Router\Router;
use App\Entity\Activity; use App\Entity\Activity;
use App\Entity\Actor; use App\Entity\Actor;
use App\Util\Common; use App\Util\Common;
@@ -34,11 +33,12 @@ use App\Util\Nickname;
use Component\Group\Controller as C; use Component\Group\Controller as C;
use Component\Group\Entity\LocalGroup; use Component\Group\Entity\LocalGroup;
use Component\Notification\Notification; use Component\Notification\Notification;
use EventResult;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
class Group extends Component 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_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']); $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 * Enqueues a notification for an Actor (such as person or group) which means
* it shows up in their home feed and such. * 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) { foreach ($targets as $target) {
if ($target->isGroup()) { if ($target->isGroup()) {
// The Group announces to its subscribers // 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 * 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(); $actor = Common::actor();
$group = $vars['actor']; $group = $vars['actor'];
@@ -75,7 +85,6 @@ class Group extends Component
$url = Router::url('group_actor_settings', ['id' => $group->getId()]); $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' => $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; return Event::next;
} }
@@ -99,13 +108,16 @@ class Group extends Component
case 'group_actor_view_nickname': case 'group_actor_view_nickname':
return LocalGroup::getActorByNickname($identifier); return LocalGroup::getActorByNickname($identifier);
case 'group_actor_view_id': case 'group_actor_view_id':
return Actor::getById($identifier); return Actor::getById((int) $identifier);
} }
} }
return null; 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); $group = $this->getGroupFromContext($request);
if (!\is_null($group)) { 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 * @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); $ctx = $this->getGroupFromContext($request);
if (!\is_null($ctx)) { if (!\is_null($ctx)) {

View File

@@ -7,7 +7,7 @@
<h1>Settings</h1> <h1>Settings</h1>
<ul> <ul>
<li> <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')) %} {% 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) }} {{ macros.settings_details_container('Profile', 'Personal Information, Avatar and Profile', 'settings-profile-details', profile_tabs, _context) }}
</li> </li>

View File

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

View File

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

View File

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

View File

@@ -24,7 +24,7 @@ declare(strict_types = 1);
namespace Component\Language\Entity; namespace Component\Language\Entity;
use App\Core\Cache; use App\Core\Cache;
use App\Core\DB\DB; use App\Core\DB;
use App\Core\Entity; use App\Core\Entity;
use function App\Core\I18n\_m; use function App\Core\I18n\_m;
use App\Entity\Actor; use App\Entity\Actor;
@@ -116,7 +116,7 @@ class Language extends Entity
return Cache::getHashMapKey( return Cache::getHashMapKey(
map_key: 'languages-id', map_key: 'languages-id',
key: (string) $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( return Cache::getHashMapKey(
'languages', 'languages',
$locale, $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 self::getById($note->getLanguageId());
} }
/**
* @return array<string, string>
*/
public static function getLanguageChoices(): array public static function getLanguageChoices(): array
{ {
$langs = Cache::getHashMap( $langs = Cache::getHashMap(
'languages', '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_merge(...F\map(array_values($langs), fn ($l) => $l->toChoiceFormat()));
} }
/**
* @return array<string, string> */
public function toChoiceFormat(): array public function toChoiceFormat(): array
{ {
return [_m($this->getLongDisplay()) => $this->getLocale()]; 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 * Get all the available languages as well as the languages $actor
* prefers and are appropriate for posting in/to $context_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 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\Event;
use App\Core\Modules\Component; use App\Core\Modules\Component;
use App\Core\Router\RouteLoader; use App\Core\Router;
use App\Entity\Actor; use App\Entity\Actor;
use App\Entity\Note; use App\Entity\Note;
use App\Util\Formatting; use App\Util\Formatting;
@@ -33,18 +33,22 @@ use Component\Language\Entity\ActorLanguage;
use Doctrine\Common\Collections\ExpressionBuilder; use Doctrine\Common\Collections\ExpressionBuilder;
use Doctrine\ORM\Query\Expr; use Doctrine\ORM\Query\Expr;
use Doctrine\ORM\QueryBuilder; use Doctrine\ORM\QueryBuilder;
use EventResult;
use Functional as F; use Functional as F;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
class Language extends Component 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']); $r->connect('settings_sort_languages', '/settings/sort_languages', [C\Language::class, 'sortLanguages']);
return Event::next; 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)) { if (\is_null($actor)) {
return Event::next; return Event::next;
@@ -59,8 +63,11 @@ class Language extends Component
/** /**
* Populate $note_expr or $actor_expr with an expression to match a language * 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; $search_term = str_contains($term, ':') ? explode(':', $term)[1] : $term;
@@ -103,7 +110,7 @@ class Language extends Component
return Event::next; 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(); $note_aliases = $note_qb->getAllAliases();
if (!\in_array('note_language', $note_aliases)) { 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'); $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)) { 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'); $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\Cache;
use App\Core\Controller; use App\Core\Controller;
use App\Core\DB\DB; use App\Core\DB;
use App\Core\Form; use App\Core\Form;
use function App\Core\I18n\_m; use function App\Core\I18n\_m;
use App\Core\Router\Router; use App\Core\Router;
use App\Entity\Feed; use App\Entity\Feed;
use App\Util\Common; use App\Util\Common;
use App\Util\Exception\ClientException; use App\Util\Exception\ClientException;
@@ -104,7 +104,6 @@ class EditFeeds extends Controller
$feed->setUrl($fd[$md5 . '-url']); $feed->setUrl($fd[$md5 . '-url']);
$feed->setOrdering($fd[$md5 . '-order']); $feed->setOrdering($fd[$md5 . '-order']);
$feed->setTitle($fd[$md5 . '-title']); $feed->setTitle($fd[$md5 . '-title']);
DB::merge($feed);
} }
DB::flush(); DB::flush();
Cache::delete($key); Cache::delete($key);
@@ -119,7 +118,6 @@ class EditFeeds extends Controller
/** @var SubmitButton $remove_button */ /** @var SubmitButton $remove_button */
$remove_button = $form->get($remove_id); $remove_button = $form->get($remove_id);
if ($remove_button->isClicked()) { 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::remove(DB::getReference('feed', ['actor_id' => $user->getId(), 'url' => $opts['data']]));
DB::flush(); DB::flush();
Cache::delete($key); Cache::delete($key);

View File

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

View File

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

View File

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

View File

@@ -23,7 +23,7 @@ declare(strict_types = 1);
namespace Component\Link; namespace Component\Link;
use App\Core\DB\DB; use App\Core\DB;
use App\Core\Event; use App\Core\Event;
use App\Core\Modules\Component; use App\Core\Modules\Component;
use App\Entity\Actor; use App\Entity\Actor;
@@ -31,14 +31,33 @@ use App\Entity\Note;
use App\Util\Common; use App\Util\Common;
use App\Util\HTML; use App\Util\HTML;
use Component\Link\Entity\NoteToLink; use Component\Link\Entity\NoteToLink;
use EventResult;
use InvalidArgumentException; use InvalidArgumentException;
class Link extends Component 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'] ?? []; $ignore = $process_note_content_extra_args['ignoreLinks'] ?? [];
if (Common::config('attachments', 'process_links')) { if (Common::config('attachments', 'process_links')) {
@@ -49,18 +68,13 @@ class Link extends Component
if (\in_array($match, $ignore)) { if (\in_array($match, $ignore)) {
continue; continue;
} }
try { self::maybeCreateLink($match, $note->getId());
$link_id = Entity\Link::getOrCreate($match)->getId();
DB::persist(NoteToLink::create(['link_id' => $link_id, 'note_id' => $note->getId()]));
} catch (InvalidArgumentException) {
continue;
}
} }
} }
return Event::next; return Event::next;
} }
public function onRenderPlainTextNoteContent(string &$text): bool public function onRenderPlainTextNoteContent(string &$text): EventResult
{ {
$text = $this->replaceURLs($text); $text = $this->replaceURLs($text);
return Event::next; return Event::next;
@@ -138,7 +152,12 @@ class Link extends Component
public const URL_SCHEME_NO_DOMAIN = 4; public const URL_SCHEME_NO_DOMAIN = 4;
public const URL_SCHEME_COLON_COORDINATES = 8; 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 // TODO: move these to config
$schemes = [ $schemes = [
@@ -185,6 +204,7 @@ class Link extends Component
* Intermediate callback for `replaceURLs()`, which helps resolve some * Intermediate callback for `replaceURLs()`, which helps resolve some
* ambiguous link forms before passing on to the final callback. * ambiguous link forms before passing on to the final callback.
* *
* @param string[] $matches
* @param callable(string $text): string $callback: return replacement text * @param callable(string $text): string $callback: return replacement text
*/ */
private function callbackHelper(array $matches, callable $callback): string 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]); 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())); DB::wrapInTransaction(fn () => NoteToLink::removeWhereNoteId($note->getId()));
return Event::next; return Event::next;

View File

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

View File

@@ -24,31 +24,51 @@ namespace Component\Notification\Entity;
use App\Core\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 * @category DB
* @package GNUsocial * @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> * @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 * @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
*/ */
class Attention extends Entity class Attention extends Entity
{ {
// {{{ Autocode // {{{ Autocode
// @codeCoverageIgnoreStart // @codeCoverageIgnoreStart
private int $note_id; private string $object_type;
private int $object_id;
private int $target_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; 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 public function setTargetId(int $target_id): self
@@ -68,15 +88,16 @@ class Attention extends Entity
public static function schemaDef(): array public static function schemaDef(): array
{ {
return [ return [
'name' => 'note_attention', 'name' => 'attention',
'description' => 'Note attentions to actors (that are not a mention)', 'description' => 'Attentions to actors (these are not mentions)',
'fields' => [ 'fields' => [
'note_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Note.id', 'multiplicity' => 'one to one', 'not null' => true, 'description' => 'note_id to give attention'], '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'], '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' => [ 'indexes' => [
'attention_note_id_idx' => ['note_id'], 'attention_object_id_idx' => ['object_id'],
'attention_target_id_idx' => ['target_id'], 'attention_target_id_idx' => ['target_id'],
], ],
]; ];

View File

@@ -21,24 +21,24 @@ declare(strict_types = 1);
namespace Component\Notification\Entity; namespace Component\Notification\Entity;
use App\Core\DB\DB; use App\Core\DB;
use App\Core\Entity; use App\Core\Entity;
use App\Entity\Activity; use App\Entity\Activity;
use App\Entity\Actor; use App\Entity\Actor;
use DateTimeInterface; 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 * @category DB
* @package GNUsocial * @package GNUsocial
* *
* @author Zach Copley <zach@status.net> * @author Diogo Peralta Cordeiro <@diogo.site>
* @copyright 2010 StatusNet Inc. * @copyright 2021-2022 Free Software Foundation, Inc http://www.fsf.org
* @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
* @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later * @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
*/ */
class Notification extends Entity class Notification extends Entity
@@ -117,7 +117,7 @@ class Notification extends Entity
/** /**
* Pull the complete list of known activity context notifications for this activity. * 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 public static function getNotificationTargetIdsByActivity(int|Activity $activity_id): array
{ {
@@ -129,11 +129,17 @@ class Notification extends Entity
return $targets; return $targets;
} }
/**
* @return int[]
*/
public function getNotificationTargetsByActivity(int|Activity $activity_id): array public function getNotificationTargetsByActivity(int|Activity $activity_id): array
{ {
return DB::findBy(Actor::class, ['id' => $this->getNotificationTargetIdsByActivity($activity_id)]); return DB::findBy(Actor::class, ['id' => $this->getNotificationTargetIdsByActivity($activity_id)]);
} }
/**
* @return int[]
*/
public static function getAllActivitiesTargetedAtActor(Actor $actor): array public static function getAllActivitiesTargetedAtActor(Actor $actor): array
{ {
return DB::dql(<<<'EOF' return DB::dql(<<<'EOF'

View File

@@ -21,26 +21,26 @@ declare(strict_types = 1);
namespace Component\Notification; namespace Component\Notification;
use App\Core\DB\DB; use App\Core\DB;
use App\Core\Event; use App\Core\Event;
use function App\Core\I18n\_m; use function App\Core\I18n\_m;
use App\Core\Log; use App\Core\Log;
use App\Core\Modules\Component; use App\Core\Modules\Component;
use App\Core\Queue\Queue; use App\Core\Queue;
use App\Core\Router\RouteLoader; use App\Core\Router;
use App\Core\Router\Router;
use App\Entity\Activity; use App\Entity\Activity;
use App\Entity\Actor; use App\Entity\Actor;
use App\Entity\LocalUser; use App\Entity\LocalUser;
use App\Util\Exception\ServerException; use App\Util\Exception\ServerException;
use Component\FreeNetwork\FreeNetwork; use Component\FreeNetwork\FreeNetwork;
use Component\Notification\Controller\Feed; use Component\Notification\Controller\Feed;
use EventResult;
use Exception; use Exception;
use Throwable; use Throwable;
class Notification extends Component 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']); $m->connect('feed_notifications', '/feed/notifications', [Feed::class, 'notifications']);
return Event::next; return Event::next;
@@ -49,7 +49,7 @@ class Notification extends Component
/** /**
* @throws ServerException * @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([ DB::persist(\App\Entity\Feed::create([
'actor_id' => $actor_id, '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 * Enqueues a notification for an Actor (such as person or group) which means
* it shows up in their home feed and such. * 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()); // Ensure targets are all actor objects and unique
if (Event::handle('NewNotificationWithTargets', [$sender, $activity, $targets, $reason]) === Event::next) { $effective_targets = [];
self::notify($sender, $activity, $targets, $reason); 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; 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 // TODO: use https://symfony.com/doc/current/notifier.html
return Event::stop; 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)) { if (FreeNetwork::notify($sender, $activity, $targets, $reason)) {
return Event::stop; return Event::stop;
@@ -91,17 +126,20 @@ 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 public static function notify(Actor $sender, Activity $activity, array $targets, ?string $reason = null): bool
{ {
$remote_targets = []; $remote_targets = [];
foreach ($targets as $target) { foreach ($targets as $target) {
if ($target->getIsLocal()) { if ($target->getIsLocal()) {
if ($target->hasBlocked($activity->getActor())) { if ($target->hasBlocked($author = $activity->getActor())) {
Log::info("Not saving reply to actor {$target->getId()} from sender {$sender->getId()} because of a block."); Log::info("Not saving notification to actor {$target->getId()} from sender {$sender->getId()} because receiver blocked author {$author->getId()}.");
continue; continue;
} }
if (Event::handle('NewNotificationShould', [$activity, $target]) === Event::next) { if (Event::handle('NewNotificationShould', [$activity, $target]) === Event::next) {
@@ -113,7 +151,7 @@ class Notification extends Component
} }
Queue::enqueue( Queue::enqueue(
payload: [$sender, $activity, $target, $reason], payload: [$sender, $activity, $target, $reason],
queue: 'notification_local', queue: 'NotificationLocal',
priority: true, priority: true,
); );
} else { } else {
@@ -124,7 +162,7 @@ class Notification extends Component
} }
// XXX: Unideal as in failures the rollback will leave behind a false notification, // XXX: Unideal as in failures the rollback will leave behind a false notification,
// but most notifications (all) require flushing the objects first // 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 { try {
DB::wrapInTransaction(fn () => DB::persist(Entity\Notification::create([ DB::wrapInTransaction(fn () => DB::persist(Entity\Notification::create([
'activity_id' => $activity->getId(), 'activity_id' => $activity->getId(),
@@ -132,7 +170,7 @@ class Notification extends Component
'reason' => $reason, 'reason' => $reason,
]))); ])));
} catch (Exception|Throwable $e) { } 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]); Log::error('It was attempted to record an invalid notification!', [$e]);
} }
} }
@@ -140,7 +178,7 @@ class Notification extends Component
if ($remote_targets !== []) { if ($remote_targets !== []) {
Queue::enqueue( Queue::enqueue(
payload: [$sender, $activity, $remote_targets, $reason], payload: [$sender, $activity, $remote_targets, $reason],
queue: 'notification_remote', queue: 'NotificationRemote',
priority: false, priority: false,
); );
} }

View File

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

View File

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

View File

@@ -23,13 +23,14 @@ namespace Component\Person;
use App\Core\Event; use App\Core\Event;
use App\Core\Modules\Component; use App\Core\Modules\Component;
use App\Core\Router\RouteLoader; use App\Core\Router;
use App\Util\Nickname; use App\Util\Nickname;
use Component\Person\Controller as C; use Component\Person\Controller as C;
use EventResult;
class Person extends Component 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_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]); $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> <li>
{{ macros.settings_details_container('Notifications', 'Enable/disable notifications (Email, XMPP, Replies...)', 'notifications', tabbed_forms_notify, _context) }} {{ macros.settings_details_container('Notifications', 'Enable/disable notifications (Email, XMPP, Replies...)', 'notifications', tabbed_forms_notify, _context) }}
</li> </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> </ul>
</nav> </nav>
{% endblock body %} {% endblock body %}

View File

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

View File

@@ -52,7 +52,7 @@ class Posting extends Controller
content_type: $data['content_type'], content_type: $data['content_type'],
locale: $data['language'], locale: $data['language'],
scope: VisibilityScope::from($data['visibility']), 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, reply_to: \array_key_exists('reply_to_id', $data) ? $data['reply_to_id'] : null,
attachments: $data['attachments'], attachments: $data['attachments'],
process_note_content_extra_args: $extra_args, process_note_content_extra_args: $extra_args,
@@ -61,9 +61,9 @@ class Posting extends Controller
return Core\Form::forceRedirect($form, $request); return Core\Form::forceRedirect($form, $request);
} }
} catch (FormSizeFileException $e) { } 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\Event;
use App\Core\Form; use App\Core\Form;
use function App\Core\I18n\_m; use function App\Core\I18n\_m;
use App\Core\Router\Router; use App\Core\Router;
use App\Core\VisibilityScope; use App\Core\VisibilityScope;
use App\Entity\Actor; use App\Entity\Actor;
use App\Util\Common; use App\Util\Common;

View File

@@ -24,16 +24,16 @@ declare(strict_types = 1);
namespace Component\Posting; namespace Component\Posting;
use App\Core\Cache; use App\Core\Cache;
use App\Core\DB\DB; use App\Core\DB;
use App\Core\Event; use App\Core\Event;
use App\Core\GSFile; use App\Core\GSFile;
use function App\Core\I18n\_m; use function App\Core\I18n\_m;
use App\Core\Modules\Component; use App\Core\Modules\Component;
use App\Core\Router\RouteLoader; use App\Core\Router;
use App\Core\Router\Router;
use App\Core\VisibilityScope; use App\Core\VisibilityScope;
use App\Entity\Activity; use App\Entity\Activity;
use App\Entity\Actor; use App\Entity\Actor;
use App\Entity\LocalUser;
use App\Entity\Note; use App\Entity\Note;
use App\Util\Common; use App\Util\Common;
use App\Util\Exception\BugFoundException; use App\Util\Exception\BugFoundException;
@@ -44,11 +44,13 @@ use App\Util\Exception\ServerException;
use App\Util\Formatting; use App\Util\Formatting;
use App\Util\HTML; use App\Util\HTML;
use Component\Attachment\Entity\ActorToAttachment; use Component\Attachment\Entity\ActorToAttachment;
use Component\Attachment\Entity\Attachment;
use Component\Attachment\Entity\AttachmentToNote; use Component\Attachment\Entity\AttachmentToNote;
use Component\Conversation\Conversation; use Component\Conversation\Conversation;
use Component\Language\Entity\Language; use Component\Language\Entity\Language;
use Component\Notification\Entity\Attention; 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\File\UploadedFile;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
@@ -56,7 +58,7 @@ class Posting extends Component
{ {
public const route = 'posting_form_action'; 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); $r->connect(self::route, '/form/posting', Controller\Posting::class);
return Event::next; return Event::next;
@@ -66,13 +68,15 @@ class Posting extends Component
* HTML render event handler responsible for adding and handling * HTML render event handler responsible for adding and handling
* the result of adding the note submission form, only if a user is logged in * the result of adding the note submission form, only if a user is logged in
* *
* @param array{post_form?: FormInterface} $res
*
* @throws BugFoundException * @throws BugFoundException
* @throws ClientException * @throws ClientException
* @throws DuplicateFoundException * @throws DuplicateFoundException
* @throws RedirectException * @throws RedirectException
* @throws ServerException * @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) { if (\is_null($user = Common::user()) || preg_match('(feed|conversation|group|view)', $request->get('_route')) === 0) {
return Event::next; 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 ClientException
* @throws DuplicateFoundException * @throws DuplicateFoundException
* @throws ServerException * @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, Actor $actor,
?string $content, ?string $content,
string $content_type, string $content_type,
?string $locale = null, ?string $locale = null,
?VisibilityScope $scope = null, ?VisibilityScope $scope = null,
array $targets = [], array $attentions = [],
null|int|Note $reply_to = null, null|int|Note $reply_to = null,
array $attachments = [], array $attachments = [],
array $processed_attachments = [], array $processed_attachments = [],
@@ -104,13 +124,13 @@ class Posting extends Component
string $source = 'web', string $source = 'web',
?string $title = null, ?string $title = null,
): array { ): array {
[$activity, $note, $attention_ids] = self::storeLocalNote( [$activity, $note, $effective_attentions] = self::storeLocalNote(
actor: $actor, actor: $actor,
content: $content, content: $content,
content_type: $content_type, content_type: $content_type,
locale: $locale, locale: $locale,
scope: $scope, scope: $scope,
targets: $targets, attentions: $attentions,
reply_to: $reply_to, reply_to: $reply_to,
attachments: $attachments, attachments: $attachments,
processed_attachments: $processed_attachments, processed_attachments: $processed_attachments,
@@ -119,16 +139,24 @@ class Posting extends Component
rendered: $rendered, rendered: $rendered,
source: $source, source: $source,
); );
$note->setType('page'); $note->setType('article');
$note->setTitle($title); $note->setTitle($title);
if ($flush_and_notify) { if ($flush_and_notify) {
// Flush before notification // Flush before notification
DB::flush(); 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];
} }
/** /**
@@ -141,11 +169,11 @@ class Posting extends Component
* @param string $content_type Indicating one of the various supported content format (Plain Text, Markdown, LaTeX...) * @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|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 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 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 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 UploadedFile[] $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<array{Attachment, string}> $processed_attachments Array of [Attachment, Attachment's name][] to be associated to this $actor and Note
* @param array $process_note_content_extra_args Extra arguments for the event ProcessNoteContent * @param array{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 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 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 string $source The source of this Note
@@ -154,7 +182,7 @@ class Posting extends Component
* @throws DuplicateFoundException * @throws DuplicateFoundException
* @throws ServerException * @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( public static function storeLocalNote(
Actor $actor, Actor $actor,
@@ -162,7 +190,7 @@ class Posting extends Component
string $content_type, string $content_type,
?string $locale = null, ?string $locale = null,
?VisibilityScope $scope = null, ?VisibilityScope $scope = null,
array $targets = [], array $attentions = [],
null|int|Note $reply_to = null, null|int|Note $reply_to = null,
array $attachments = [], array $attachments = [],
array $processed_attachments = [], array $processed_attachments = [],
@@ -173,6 +201,8 @@ class Posting extends Component
): array { ): array {
$scope ??= VisibilityScope::EVERYWHERE; // TODO: If site is private, default to LOCAL $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()); $reply_to_id = \is_null($reply_to) ? null : (\is_int($reply_to) ? $reply_to : $reply_to->getId());
/** @var array<int, array{ mentioned?: array<int, Actor|LocalUser> }> $mentions */
$mentions = []; $mentions = [];
if (\is_null($rendered) && !empty($content)) { if (\is_null($rendered) && !empty($content)) {
Event::handle('RenderNoteContent', [$content, $content_type, &$rendered, $actor, $locale, &$mentions]); Event::handle('RenderNoteContent', [$content, $content_type, &$rendered, $actor, $locale, &$mentions]);
@@ -209,7 +239,7 @@ class Posting extends Component
if (!\is_null($reply_to_id)) { if (!\is_null($reply_to_id)) {
Cache::incr(Note::cacheKeys($reply_to_id)['replies-count']); 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 // 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'])) { if (Cache::exists(Note::cacheKeys($reply_to_id)['replies'])) {
Cache::listPushRight(Note::cacheKeys($reply_to_id)['replies'], $note); 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]); 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 !== []) { if ($processed_attachments !== []) {
foreach ($processed_attachments as [$a, $fname]) { foreach ($processed_attachments as [$a, $fname]) {
// Most attachments should already be associated with its author, but maybe it didn't make sense // 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 //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(ActorToAttachment::create($args));
} }
DB::persist(AttachmentToNote::create(['attachment_id' => $a->getId(), 'note_id' => $note->getId(), 'title' => $fname])); 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); DB::persist($activity);
$attention_ids = []; $effective_attentions = [];
foreach ($targets as $target) { foreach ($attentions as $target) {
$target_id = \is_int($target) ? $target : $target->getId(); if (\is_int($target)) {
DB::persist(Attention::create(['note_id' => $note->getId(), 'target_id' => $target_id])); $target_id = $target;
$attention_ids[$target_id] = true; $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) { if ($flush_and_notify) {
// Flush before notification // Flush before notification
@@ -256,21 +311,21 @@ class Posting extends Component
Event::handle('NewNotification', [ Event::handle('NewNotification', [
$actor, $actor,
$activity, $activity,
[ $effective_attentions,
'note-attention' => $attention_ids, _m('Actor {actor_id} created note {note_id}.', [
'object' => F\unique(F\flat_map($mentions, fn (array $m) => F\map($m['mentioned'] ?? [], fn (Actor $a) => $a->getId()))), '{actor_id}' => $actor->getId(),
],
_m('{nickname} created a note {note_id}.', [
'{nickname}' => $actor->getNickname(),
'{note_id}' => $activity->getObjectId(), '{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) { switch ($content_type) {
case 'text/plain': 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\Form\Extension\Core\Type\TextType;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
/**
* @extends FeedController<\App\Entity\Note>
*/
class Search extends FeedController class Search extends FeedController
{ {
/** /**
* Handle a search query * Handle a search query
*
* @return ControllerResultType
*/ */
public function handle(Request $request) public function handle(Request $request): array
{ {
$actor = Common::actor(); $actor = Common::actor();
$language = !\is_null($actor) ? $actor->getTopLanguage()->getLocale() : null; $language = !\is_null($actor) ? $actor->getTopLanguage()->getLocale() : null;

View File

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

View File

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

View File

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

View File

@@ -24,12 +24,11 @@ declare(strict_types = 1);
namespace Component\Subscription; namespace Component\Subscription;
use App\Core\Cache; use App\Core\Cache;
use App\Core\DB\DB; use App\Core\DB;
use App\Core\Event; use App\Core\Event;
use function App\Core\I18n\_m; use function App\Core\I18n\_m;
use App\Core\Modules\Component; use App\Core\Modules\Component;
use App\Core\Router\RouteLoader; use App\Core\Router;
use App\Core\Router\Router;
use App\Entity\Activity; use App\Entity\Activity;
use App\Entity\Actor; use App\Entity\Actor;
use App\Entity\LocalUser; use App\Entity\LocalUser;
@@ -37,14 +36,15 @@ use App\Util\Common;
use App\Util\Exception\DuplicateFoundException; use App\Util\Exception\DuplicateFoundException;
use App\Util\Exception\NotFoundException; use App\Util\Exception\NotFoundException;
use App\Util\Exception\ServerException; use App\Util\Exception\ServerException;
use Component\Notification\Entity\Attention;
use Component\Subscription\Controller\Subscribers as SubscribersController; use Component\Subscription\Controller\Subscribers as SubscribersController;
use Component\Subscription\Controller\Subscriptions as SubscriptionsController; use Component\Subscription\Controller\Subscriptions as SubscriptionsController;
use EventResult;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
class Subscription extends Component 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_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']); $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 $subject The Actor who subscribed or unsubscribed
* @param Actor|int|LocalUser $object The Actor who was subscribed or unsubscribed from * @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 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); $subscription = DB::findOneBy(table: Entity\ActorSubscription::class, criteria: $opts, return_null: true);
$activity = null; $activity = null;
if (\is_null($subscription)) { if (\is_null($subscription)) {
DB::persist(Entity\ActorSubscription::create($opts)); DB::persist($subscription = Entity\ActorSubscription::create($opts));
$activity = Activity::create([ $activity = Activity::create([
'actor_id' => $subscriber_id, 'actor_id' => $subscriber_id,
'verb' => 'subscribe', 'verb' => 'subscribe',
'object_type' => 'actor', 'object_type' => Actor::schemaName(),
'object_id' => $subscribed_id, 'object_id' => $subscribed_id,
'source' => $source, 'source' => $source,
]); ]);
DB::persist($activity); DB::persist($activity);
DB::persist(Attention::create(['object_type' => Activity::schemaName(), 'object_id' => $activity->getId(), 'target_id' => $subscribed_id]));
Event::handle('NewNotification', [ Event::handle('NewNotification', [
\is_int($subject) ? $subject : Actor::getById($subscriber_id), \is_int($subject) ? $subject : Actor::getById($subscriber_id),
$activity, $activity,
['object' => [$activity->getObjectId()]], [$subscribed_id],
_m('{subject} subscribed to {object}.', ['{subject}' => $activity->getActorId(), '{object}' => $activity->getObjectId()]), $reason = _m('{subject} subscribed to {object}.', ['{subject}' => $activity->getActorId(), '{object}' => $activity->getObjectId()]),
]); ]);
Event::handle('NewSubscriptionEnd', [$subject, $activity, $object, $reason]);
} }
return $activity; return $activity;
} }
@@ -146,21 +150,22 @@ class Subscription extends Component
if (!\is_null($subscription)) { if (!\is_null($subscription)) {
// Remove Subscription // Remove Subscription
DB::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 // Store Activity
$activity = Activity::create([ $activity = Activity::create([
'actor_id' => $subscriber_id, 'actor_id' => $subscriber_id,
'verb' => 'undo', 'verb' => 'undo',
'object_type' => 'activity', 'object_type' => Activity::schemaName(),
'object_id' => $previous_follow_activity->getId(), 'object_id' => $previous_follow_activity->getId(),
'source' => $source, 'source' => $source,
]); ]);
DB::persist($activity); DB::persist($activity);
DB::persist(Attention::create(['object_type' => Activity::schemaName(), 'object_id' => $activity->getId(), 'target_id' => $subscribed_id]));
Event::handle('NewNotification', [ Event::handle('NewNotification', [
\is_int($subject) ? $subject : Actor::getById($subscriber_id), \is_int($subject) ? $subject : Actor::getById($subscriber_id),
$activity, $activity,
['object' => [$previous_follow_activity->getObjectId()]], [$subscribed_id],
_m('{subject} unsubscribed from {object}.', ['{subject}' => $activity->getActorId(), '{object}' => $previous_follow_activity->getObjectId()]), _m('{subject} unsubscribed from {object}.', ['{subject}' => $activity->getActorId(), '{object}' => $previous_follow_activity->getObjectId()]),
]); ]);
} }
@@ -175,16 +180,15 @@ class Subscription extends Component
* **unsubscribe** a given **Actor**. * **unsubscribe** a given **Actor**.
* *
* @param Actor $object The Actor on which the action is to be performed * @param Actor $object The Actor on which the action is to be performed
* @param array $actions An array containing all actions added to the * @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 * current profile, this event adds an action to it
* *
* @throws DuplicateFoundException * @throws DuplicateFoundException
* @throws NotFoundException * @throws NotFoundException
* @throws ServerException * @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 // Action requires a user to be logged in
// We know it's a LocalUser, which has the same id as Actor // 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: Use Feed::query
// TODO: If ?canonical=something, respect // TODO: If ?canonical=something, respect
// TODO: Allow to set locale of tag being selected // 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(); $actor = Common::actor();
$page = $this->int('page') ?: 1; $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( return $this->process(
tag_single_or_multi: $tag, 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( return $this->process(
tag_single_or_multi: explode(',', $tags), tag_single_or_multi: explode(',', $tags),

View File

@@ -22,9 +22,9 @@ declare(strict_types = 1);
namespace Component\Tag\Entity; namespace Component\Tag\Entity;
use App\Core\Cache; use App\Core\Cache;
use App\Core\DB\DB; use App\Core\DB;
use App\Core\Entity; use App\Core\Entity;
use App\Core\Router\Router; use App\Core\Router;
use App\Entity\Actor; use App\Entity\Actor;
use App\Entity\Note; use App\Entity\Note;
use Component\Language\Entity\Language; use Component\Language\Entity\Language;
@@ -134,6 +134,9 @@ class NoteTag extends Entity
return "note-tags-{$note_id}"; return "note-tags-{$note_id}";
} }
/**
* @return NoteTag[]
*/
public static function getByNoteId(int $note_id): array 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])); 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; namespace Component\Tag\Entity;
use App\Core\Cache; use App\Core\Cache;
use App\Core\DB\DB; use App\Core\DB;
use App\Core\Entity; use App\Core\Entity;
use Component\Tag\Tag; use Component\Tag\Tag;
use DateTimeInterface; use DateTimeInterface;
@@ -119,6 +119,8 @@ class NoteTagBlock extends Entity
/** /**
* Check whether $note_tag is considered blocked by one of * Check whether $note_tag is considered blocked by one of
* $note_tag_blocks * $note_tag_blocks
*
* @param NoteTagBlock[] $note_tag_blocks
*/ */
public static function checkBlocksNoteTag(NoteTag $note_tag, array $note_tag_blocks): bool 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; namespace Component\Tag;
use App\Core\Cache; use App\Core\Cache;
use App\Core\DB\DB; use App\Core\DB;
use App\Core\Event; use App\Core\Event;
use function App\Core\I18n\_m; use function App\Core\I18n\_m;
use App\Core\Modules\Component; use App\Core\Modules\Component;
use App\Core\Router\Router; use App\Core\Router;
use App\Entity\Actor; use App\Entity\Actor;
use App\Entity\Note; use App\Entity\Note;
use App\Util\Common; use App\Util\Common;
@@ -42,6 +42,7 @@ use Component\Tag\Entity\NoteTag;
use Doctrine\Common\Collections\ExpressionBuilder; use Doctrine\Common\Collections\ExpressionBuilder;
use Doctrine\ORM\Query\Expr; use Doctrine\ORM\Query\Expr;
use Doctrine\ORM\QueryBuilder; use Doctrine\ORM\QueryBuilder;
use EventResult;
use Functional as F; use Functional as F;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType; use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\HttpFoundation\Request; 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_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 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('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']); $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) { if ($extra_args['TagProcessed'] ?? false) {
return Event::next; return Event::next;
@@ -82,26 +136,12 @@ class Tag extends Component
$matched_tags = array_unique(F\map($matched_tags, fn ($m) => $m[2])); $matched_tags = array_unique(F\map($matched_tags, fn ($m) => $m[2]));
foreach ($matched_tags as $match) { foreach ($matched_tags as $match) {
$tag = self::extract($match); $tag = self::extract($match);
if (!self::validate($tag)) { self::maybeCreateTag(tag: $tag, note_id: $note->getId(), lang_id: $note->getLanguageId());
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);
}
} }
return Event::next; 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); $text = preg_replace_callback(self::TAG_REGEX, fn ($m) => $m[1] . self::tagLink($m[2], $locale), $text);
return Event::next; 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 * 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 * $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, ':')) { if (!str_contains($term, ':')) {
return Event::next; return Event::next;
@@ -218,7 +261,7 @@ class Tag extends Component
return Event::next; 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())) { if (!\in_array('note_tag', $note_qb->getAllAliases())) {
$note_qb->leftJoin(NoteTag::class, 'note_tag', Expr\Join::WITH, 'note_tag.note_id = note.id'); $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; 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)')]]; $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; 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'])) { if (!isset($data['tag_use_canonical'])) {
throw new ClientException(_m('Missing Use Canonical preference for Tags.')); throw new ClientException(_m('Missing Use Canonical preference for Tags.'));

View File

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

2673
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -20,15 +20,13 @@ security:
dev: dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/ pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false security: false
oauth:
pattern: ^/oauth
security: false
main: main:
lazy: true lazy: true
provider: local_user provider: local_user
form_login: form_login:
login_path: security_login login_path: security_login
check_path: security_login check_path: security_login
default_target_path: root
logout: logout:
path: security_logout path: security_logout
# where to redirect after 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 # this creates a service per class whose id is the fully-qualified class name
App\: App\:
resource: '../src/*' 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 # controllers are imported separately to make sure services can be injected
# as action arguments even if you don't extend any base controller class # as action arguments even if you don't extend any base controller class
@@ -21,7 +24,7 @@ services:
resource: '../src/Controller' resource: '../src/Controller'
tags: ['controller.service_arguments'] tags: ['controller.service_arguments']
App\Core\Router\RouteLoader: App\Core\Router:
tags: ['routing.loader'] tags: ['routing.loader']
# Wrapper around Doctrine's StaticPHP metadata driver # Wrapper around Doctrine's StaticPHP metadata driver

View File

@@ -93,7 +93,7 @@ services:
- ./docker/php/entrypoint.sh:/entrypoint.sh - ./docker/php/entrypoint.sh:/entrypoint.sh
- ./docker/db/wait_for_db.sh:/wait_for_db.sh - ./docker/db/wait_for_db.sh:/wait_for_db.sh
- ./docker/social/install.sh:/var/entrypoint.d/social_install.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 # Main files
- .:/var/www/social - .:/var/www/social
- /var/www/social/docker # exclude docker folder - /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 { server {
listen [::]:80; listen [::]:80;
@@ -5,6 +20,10 @@ server {
server_name %hostname%; server_name %hostname%;
location '/.well-known/acme-challenge' {
proxy_pass http://localhost:81;
}
# redirect all traffic to HTTPS # redirect all traffic to HTTPS
rewrite ^ https://$host$request_uri? permanent; rewrite ^ https://$host$request_uri? permanent;
} }
@@ -35,6 +54,13 @@ server {
root /var/www/social; root /var/www/social;
} }
location /.well-known/acme-challenge/ {
allow all;
root /var/www/certbot;
try_files $uri =404;
break;
}
# PHP # PHP
location ~ ^/(index|install)\.php(/.*)?$ { location ~ ^/(index|install)\.php(/.*)?$ {
include fastcgi_params; include fastcgi_params;

View File

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

View File

@@ -3,8 +3,10 @@
cd /var/www/social || exit 1 cd /var/www/social || exit 1
printf "Cleaning Redis cache: " && echo "FLUSHALL" | nc redis 6379 printf "Cleaning Redis cache: " && echo "FLUSHALL" | nc redis 6379
yes yes | php bin/console doctrine:fixtures:load || exit 1 bin/console doctrine:database:drop --force || exit 1
php bin/console app:populate_initial_values # since loading fixtures purges the DB 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 if [ "$#" -eq 0 ] || [ -z "$*" ]; then
vendor/bin/simple-phpunit -vvv --coverage-html .test_coverage_report 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 $vars Input from the caller/emitter
* @param array $res I/O parameter used to accumulate or return values from the listener to the 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 * 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']]); $res[] = Formatting::twigRenderFile('imageEncoder/imageEncoderView.html.twig', ['attachment' => $vars['attachment'], 'thumbnail_parameters' => $vars['thumbnail_parameters']]);
return Event::stop; return Event::stop;
@@ -74,11 +74,25 @@ Event::handle('ResizerAvailable', [&$event_map]);
/** /**
* @param array $event_map output * @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'; $event_map['image'] = 'ResizeImagePath';
return Event::next; 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: parameters:
level: 4 level: 6
tmpDir: /var/www/social/var/cache/phpstan
inferPrivatePropertyTypeFromConstructor: true
bootstrapFiles: bootstrapFiles:
- config/bootstrap.php - config/bootstrap.php
paths: paths:
@@ -15,6 +17,13 @@ parameters:
earlyTerminatingMethodCalls: earlyTerminatingMethodCalls:
App\Core\Log: App\Core\Log:
- unexpected_exception - 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: ignoreErrors:
- -
message: '/Access to an undefined property App\\Util\\Bitmap::\$\w+/' message: '/Access to an undefined property App\\Util\\Bitmap::\$\w+/'
@@ -35,10 +44,41 @@ parameters:
paths: paths:
- * - *
# - -
# message: '/has no return typehint specified/' message: '/::onCollectionQueryCreateExpression\(\) has parameter \$(actor|note)_expr with no type specified\./'
# paths: paths:
# - tests/* - *
-
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: services:
- -

View File

@@ -34,15 +34,14 @@ namespace Plugin\ActivityPub;
use ActivityPhp\Type; use ActivityPhp\Type;
use ActivityPhp\Type\AbstractObject; use ActivityPhp\Type\AbstractObject;
use App\Core\DB\DB; use App\Core\DB;
use App\Core\Event; use App\Core\Event;
use App\Core\HTTPClient; use App\Core\HTTPClient;
use function App\Core\I18n\_m; use function App\Core\I18n\_m;
use App\Core\Log; use App\Core\Log;
use App\Core\Modules\Plugin; use App\Core\Modules\Plugin;
use App\Core\Queue\Queue; use App\Core\Queue;
use App\Core\Router\RouteLoader; use App\Core\Router;
use App\Core\Router\Router;
use App\Entity\Activity; use App\Entity\Activity;
use App\Entity\Actor; use App\Entity\Actor;
use App\Entity\Note; use App\Entity\Note;
@@ -51,6 +50,7 @@ use App\Util\Exception\BugFoundException;
use Component\Collection\Util\Controller\OrderedCollection; use Component\Collection\Util\Controller\OrderedCollection;
use Component\FreeNetwork\Entity\FreeNetworkActorProtocol; use Component\FreeNetwork\Entity\FreeNetworkActorProtocol;
use Component\FreeNetwork\Util\Discovery; use Component\FreeNetwork\Util\Discovery;
use EventResult;
use Exception; use Exception;
use InvalidArgumentException; use InvalidArgumentException;
use Plugin\ActivityPub\Controller\Inbox; use Plugin\ActivityPub\Controller\Inbox;
@@ -104,7 +104,7 @@ class ActivityPub extends Plugin
'User-Agent' => 'GNUsocialBot ' . GNUSOCIAL_VERSION . ' - ' . GNUSOCIAL_PROJECT_URL, 'User-Agent' => 'GNUsocialBot ' . GNUSOCIAL_VERSION . ' - ' . GNUSOCIAL_PROJECT_URL,
]; ];
public function version(): string public static function version(): string
{ {
return '3.0.0'; 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]); Event::handle('ActivityStreamsTwoContext', [&self::$activity_streams_two_context]);
self::$activity_streams_two_context = array_unique(self::$activity_streams_two_context, \SORT_REGULAR); self::$activity_streams_two_context = array_unique(self::$activity_streams_two_context, \SORT_REGULAR);
return Event::next; 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 // TODO: Check if Actor has authority over payload
@@ -141,14 +141,12 @@ class ActivityPub extends Plugin
$ap_actor->getActorId(), $ap_actor->getActorId(),
Discovery::normalize($actor->getNickname() . '@' . parse_url($ap_actor->getInboxUri(), \PHP_URL_HOST)), 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(); DB::flush();
if (Event::handle('ActivityPubNewNotification', [$actor, $ap_act->getActivity(), $already_known_ids, _m('{nickname} attentioned you.', ['{nickname}' => $actor->getNickname()])]) === Event::next) { if (($att_targets = $ap_act->getAttentionTargets()) !== []) {
Event::handle('NewNotification', [$actor, $ap_act->getActivity(), $already_known_ids, _m('{nickname} attentioned you.', ['{nickname}' => $actor->getNickname()])]); 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; return Event::stop;
@@ -158,9 +156,9 @@ class ActivityPub extends Plugin
* This code executes when GNU social creates the page routing, and we hook * 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. * 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( $r->connect(
'activitypub_inbox', 'activitypub_inbox',
@@ -186,7 +184,7 @@ class ActivityPub extends Plugin
/** /**
* Fill Actor->getUrl() calls with correct URL coming from ActivityPub * 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 ( if (
// Is remote? // Is remote?
@@ -206,7 +204,7 @@ class ActivityPub extends Plugin
/** /**
* Fill Actor->canAdmin() for Actors that came from ActivityPub * 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? // Are both in AP?
if ( if (
@@ -226,7 +224,7 @@ class ActivityPub extends Plugin
* *
* @throws Exception * @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) { if (\count(array_intersect(self::$accept_headers, $accept_header)) === 0) {
return Event::next; return Event::next;
@@ -265,7 +263,7 @@ class ActivityPub extends Plugin
/** /**
* Add ActivityStreams 2 Extensions * 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) { switch ($type_name) {
case 'Person': 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 * 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
{ {
if (!\in_array('\Plugin\ActivityPub\ActivityPub', $protocols)) {
$protocols[] = '\Plugin\ActivityPub\ActivityPub'; $protocols[] = '\Plugin\ActivityPub\ActivityPub';
}
return Event::next; return Event::next;
} }
@@ -296,11 +296,11 @@ class ActivityPub extends Plugin
* *
* @return bool true if imported, false otherwise * @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)) { if (Common::isValidHttpUrl($uri)) {
try { try {
$object = self::getObjectByUri($uri); $object = self::getObjectByUri($uri, try_online: true, on_behalf_of: $on_behalf_of);
if (!\is_null($object)) { if (!\is_null($object)) {
if ($object instanceof Type\AbstractObject) { if ($object instanceof Type\AbstractObject) {
if (\in_array($object->get('type'), array_keys(Model\Actor::$_as2_actor_type_to_gs_actor_type))) { 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, string $inbox,
array $to_actors, array $to_actors,
array &$retry_args, array &$retry_args,
): bool ): EventResult {
{
try { try {
$data = Model::toJson($activity); $data = Model::toType($activity);
if ($sender->isGroup()) { if ($sender->isGroup()) { // When the sender is a group,
// When the sender is a group, we have to wrap it in an Announce activity if ($activity->getVerb() === 'subscribe') {
$data = Type::create('Announce', ['object' => $data])->toJson(); // 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 // accumulate errors for later use, if needed
$status_code = $res->getStatusCode(); $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 // 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 // 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))) { 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; continue;
} }
$to_addr[$ap_target->getInboxSharedUri() ?? $ap_target->getInboxUri()][] = $actor; $to_addr[$ap_target->getInboxSharedUri() ?? $ap_target->getInboxUri()][] = $actor;
@@ -391,7 +401,7 @@ class ActivityPub extends Plugin
foreach ($to_addr as $inbox => $to_actors) { foreach ($to_addr as $inbox => $to_actors) {
Queue::enqueue( Queue::enqueue(
payload: [$sender, $activity, $inbox, $to_actors], payload: [$sender, $activity, $inbox, $to_actors],
queue: 'activitypub_postman', queue: 'ActivitypubPostman',
priority: false, priority: false,
); );
} }
@@ -419,7 +429,7 @@ class ActivityPub extends Plugin
/** /**
* Add activity+json mimetype to WebFinger * 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()) { if ($object->isPerson()) {
$link = new XML_XRD_Element_Link( $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 * 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; $addr = null;
foreach ($xrd->aliases as $alias) { 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 * 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 { try {
if (FreeNetworkActorProtocol::canIAddr('activitypub', $addr = Discovery::normalize($target))) { if (FreeNetworkActorProtocol::canIAddr('activitypub', $addr = Discovery::normalize($target))) {
@@ -530,7 +540,7 @@ class ActivityPub extends Plugin
* *
* @return null|Actor|mixed|Note got from URI * @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 // Try known object
$known_object = DB::findOneBy(ActivitypubObject::class, ['object_uri' => $resource], return_null: true); $known_object = DB::findOneBy(ActivitypubObject::class, ['object_uri' => $resource], return_null: true);
@@ -544,35 +554,49 @@ class ActivityPub extends Plugin
return $known_activity->getActivity(); 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 Actor
try { try {
return Explorer::getOneFromUri($resource, try_online: false); return Explorer::getOneFromUri($resource, try_online: false, on_behalf_of: $on_behalf_of);
} catch (\Exception) { } catch (\Exception) {
// Ignore, this is brute forcing, it's okay not to find // Ignore, this is brute forcing, it's okay not to find
} }
// Try remote // Is it a HTTP URL?
if (!$try_online) { if (Common::isValidHttpUrl($resource)) {
return; $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;
} }
$response = HTTPClient::get($resource, ['headers' => self::HTTP_CLIENT_HEADERS]); // 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 it was deleted
if ($response->getStatusCode() == 410) { if ($response->getStatusCode() == 410) {
//$obj = Type::create('Tombstone', ['id' => $resource]); //$obj = Type::create('Tombstone', ['id' => $resource]);
return; return null;
} elseif (!HTTPClient::statusCodeIsOkay($response)) { // If it is unavailable } elseif (!HTTPClient::statusCodeIsOkay($response)) { // If it is unavailable
throw new Exception('Non Ok Status Code for given Object id.'); throw new Exception('Non Ok Status Code for given Object id.');
} else { } else {
@@ -580,3 +604,7 @@ class ActivityPub extends Plugin
} }
} }
} }
return null;
}
}

View File

@@ -32,12 +32,13 @@ declare(strict_types = 1);
namespace Plugin\ActivityPub\Controller; namespace Plugin\ActivityPub\Controller;
use ActivityPhp\Type\AbstractObject;
use App\Core\Controller; use App\Core\Controller;
use App\Core\DB\DB; use App\Core\DB;
use function App\Core\I18n\_m; use function App\Core\I18n\_m;
use App\Core\Log; use App\Core\Log;
use App\Core\Queue\Queue; use App\Core\Queue;
use App\Core\Router\Router; use App\Core\Router;
use App\Entity\Actor; use App\Entity\Actor;
use App\Util\Common; use App\Util\Common;
use App\Util\Exception\ClientException; use App\Util\Exception\ClientException;
@@ -96,10 +97,12 @@ class Inbox extends Controller
return $error('Actor not found in the request.'); return $error('Actor not found in the request.');
} }
$to_actor = $this->deriveActivityTo($type);
try { try {
$resource_parts = parse_url($type->get('actor')); $resource_parts = parse_url($type->get('actor'));
if ($resource_parts['host'] !== Common::config('site', 'server')) { 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()]); $ap_actor = DB::findOneBy(ActivitypubActor::class, ['actor_id' => $actor->getId()]);
} else { } else {
throw new Exception('Only remote actors can use this endpoint.'); 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 the signature fails verification the first time, update profile as it might have changed public key
if ($verified !== 1) { if ($verified !== 1) {
try { try {
$res = Explorer::getRemoteActorActivity($ap_actor->getUri()); $res = Explorer::getRemoteActorActivity($ap_actor->getUri(), $to_actor);
if (\is_null($res)) { if (\is_null($res)) {
return $error('Invalid remote actor (null response).'); return $error('Invalid remote actor (null response).');
} }
@@ -164,10 +167,74 @@ class Inbox extends Controller
Queue::enqueue( Queue::enqueue(
payload: [$ap_actor, $actor, $type], payload: [$ap_actor, $actor, $type],
queue: 'activitypub_inbox', queue: 'ActivitypubInbox',
priority: false, priority: false,
); );
return new TypeResponse($type, status: 202); 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 function App\Core\I18n\_m;
use App\Core\Log; use App\Core\Log;
use App\Core\Router\Router; use App\Core\Router;
use App\Entity\Activity; use App\Entity\Activity;
use App\Entity\Actor; use App\Entity\Actor;
use App\Util\Exception\ClientException; use App\Util\Exception\ClientException;

View File

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

View File

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

View File

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

View File

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