Commit f72cff8c authored by swentel's avatar swentel

Issue #3166962: hello comments

parent 68c00c31
......@@ -17,13 +17,14 @@ Open an issue if you have successfully interacted with another platform!
- Enable ActivityPub per user
- Block remote domains from posting
- Map Activity types to content types
- discovery via Webfinger module
- outbox and followers endpoints
- Discovery via Webfinger module
- Outbox, Inbox and followers endpoints
- HttpSignature for authorization
- Accept follow requests, Undo (follow)
- Configure Activity types and properties
- Send posts via drush or cron
- Create comments from Create activities from remote users
- Map Activity types and properties to content types and comments and create
posts to send out to the Fediverse.
## Installation and configuration.
......@@ -46,8 +47,9 @@ select how to process those outbox activities. Then either run cron,
configure a crontab for the drush command or manually call it. The
outbox activities will be send then.
5. On admin/config/services/activitypub/activitypub-type you get an overview
of all ActivityPub Type configuration entities. Two are enabled (and locked)
when enabling this module, which are the 'Accept' and 'Delete' types.
of all ActivityPub Type configuration entities. Three are enabled (and locked)
when enabling this module, which are the 'Accept', 'Delete' and 'Inbox reply'
types.
6. Create your own configuration
- Select 'Type plugin', only dynamic types is currently available
- Select the content type which you want to map
......@@ -75,17 +77,38 @@ Footnotes:
1. Use Create when you want to send regular posts (e.g note, article or reply)
2. 'Announce' usually stands for the Boost/Retweet/Repeat response
## Configuring comments
When an Activity is received and has an inReplyTo property, it is possible to
create a comment if the target entity has comments enabled.
You have to create an entity reference field on your comment type which points
to an activity. The activity will be saved, the author and body will be saved
on the comment entity. The reference field does not render anything, so you can
hide it on the display of the comment. The entity reference field will only be
editable by the the owner of the activity or a user who can manage comments.
To enable this feature, go to /admin/config/services/activitypub.
The default comment format will be set to 'restricted_html'. This can be
changed via settings:
```
$settings['activitypub_comment_format'] = 'full_html';
```
## Plugins
ActivityPub types (activities, objects, etc) are managed by plugins. This module
ships with two types of plugins:
ActivityPub types (activities, objects, etc) are managed by plugins. This
module ships with two types of plugins:
- Dynamic types: map content types and properties
- Static types: manages the 'Accept', 'Follow' and 'Undo' activity type.
- Static types: manages the 'Accept', 'Follow' and 'Undo' activity type and
handles the comment creation queue.
**Important**: as this module is still alpha, the interface and methods will most
likely change, so be careful when implementing your own plugin. If a change
happens, the release notes of a new release will document those changes.
**Important**: as this module is still alpha, the interface and methods will
most likely change, so be careful when implementing your own plugin. If a
change happens, the release notes of a new release will document those changes.
## Public and Private keys
......@@ -106,7 +129,8 @@ $settings['activitypub_default_avatar_path'] = '/default/image.png';
## Inbox and outbox
Every user has an inbox and outbox where activities from remote users are stored.
Every user has an inbox and outbox where activities from remote users are
stored and outgoing posts to your followers.
'Accept', 'Undo' and handled by the 'Static types' plugin. All other activity
types will be stored, but do not have any impact.
......@@ -115,18 +139,25 @@ An overview can be found at user/x/activitypub.
## Sending posts to followers
Activities in the outbox are stored in a queue and send either by
On nodes and comments (if available), a fieldset will be available to send it
to followers. Activities in the outbox are stored in a queue and send either by
cron or drush. Configure this at /admin/config/services/activitypub
The drush command is activitypub:send-activities which has 3 parameters
- send: send activity request (defaults to 1)
The drush command is activitypub:process-outbox which has 3 parameters
- send: send request (defaults to 1)
- delete: remove queue item (defaults to 1)
- debug: show command line debug messages (defaults to 0)
## Processing inbox activities
A Create activity with inReplyTo will be added to the queue to try and create
a comment on the entity which is stored in the inReplyTo property.
## Drush commands
- activitypub:send-activities: send activities ready to be processed
- activitypub:add-to-queue: adds an outbox activity to the queue
- activitypub:process-outbox: process activities ready to be send out.
- activitypub:process-inbox: process activities (e.g. Create with reply)
- activitypub:add-to-queue: adds an activity to the queue
- activitypub:delete-activity: send a delete request
- activitypub:webfinger-info: test command to get info about a remote user
......
......@@ -11,6 +11,7 @@ use Drupal\Core\Site\Settings;
use Drupal\user\UserInterface;
define('ACTIVITYPUB_OUTBOX_QUEUE', 'activitypub_outbox');
define('ACTIVITYPUB_INBOX_QUEUE', 'activitypub_inbox');
/**
* Implements hook_user_delete().
......@@ -45,6 +46,20 @@ function activitypub_form_node_form_alter(&$form, FormStateInterface $form_state
}
}
/**
* Implements hook_form_FORM_BASE_ID_alter() for the comments form.
*
* @param $form
* @param \Drupal\Core\Form\FormStateInterface $form_state
*/
function activitypub_form_comment_form_alter(&$form, FormStateInterface $form_state) {
/** @var \Drupal\Core\Entity\EntityInterface $entity */
$entity = $form_state->getFormObject()->getEntity();
if ($entity) {
\Drupal::service('activitypub.form_alter')->alterCommentForm($form, $form_state, $entity);
}
}
/**
* Implements hook_nodeinfo_alter().
*
......@@ -58,8 +73,11 @@ function activitypub_nodeinfo_alter(&$data) {
* Implements hook_cron().
*/
function activitypub_cron() {
if (\Drupal::config('activitypub.settings')->get('send_outbox_handler') == 'cron') {
\Drupal::service('activitypub.outbox.client')->handleOutboxQueue();
if (\Drupal::config('activitypub.settings')->get('process_outbox_handler') == 'cron') {
\Drupal::service('activitypub.process.client')->handleOutboxQueue();
}
if (\Drupal::config('activitypub.settings')->get('process_inbox_handler') == 'cron') {
\Drupal::service('activitypub.process.client')->handleInboxQueue();
}
}
......
services:
activitypub.form_alter:
class: Drupal\activitypub\Services\ActivityPubFormAlter
arguments: ['@entity_type.manager', '@activitypub.keys', '@activitypub.utility', '@current_user', '@activitypub.outbox.client']
arguments: ['@entity_type.manager', '@activitypub.keys', '@activitypub.utility', '@current_user', '@activitypub.process.client']
activitypub_actor:
class: Drupal\activitypub\ParamConverter\ActivityPubActorConverter
arguments: ['@entity_type.manager']
......@@ -22,8 +22,8 @@ services:
plugin.manager.activitypub.type:
class: Drupal\activitypub\Services\Type\TypePluginManager
parent: default_plugin_manager
activitypub.outbox.client:
class: Drupal\activitypub\Services\ActivityPubOutboxClient
activitypub.process.client:
class: Drupal\activitypub\Services\ActivityPubProcessClient
arguments: ['@entity_type.manager', '@activitypub.utility', '@activitypub.keys', '@http_client', '@logger.channel.activitypub']
activitypub.http_middleware.format_setter:
class: Drupal\activitypub\StackMiddleware\FormatSetter
......
langcode: en
status: true
dependencies: { }
id: inbox_reply
label: Inbox reply
locked: true
plugin:
id: activitypub_static_types
configuration:
target_entity_type_id: node
target_bundle: ''
activity: ''
object: ''
avatar_user_style: 'thumbnail'
avatar_user_field: 'user_picture'
send_outbox_handler: ''
process_outbox_handler: ''
process_inbox_handler: ''
comment_create_enable: false
langcode: en
status: true
dependencies:
module:
- comment
- activitypub
id: comment.activitypub_activity
field_name: activitypub_activity
entity_type: comment
type: entity_reference
settings:
target_type: activitypub_activity
module: core
locked: false
cardinality: 1
translatable: true
indexes: { }
persist_with_no_fields: true
custom_storage: false
......@@ -72,6 +72,12 @@ activitypub.settings:
avatar_user_field:
type: string
label: 'Avatar user field'
send_outbox_handler:
process_outbox_handler:
type: string
label: 'Send outbox handler'
label: 'Process outbox handler'
process_inbox_handler:
type: string
label: 'Process inbox handler'
comment_create_enable:
type: boolean
label: 'Create comments on nodes'
......@@ -12,7 +12,7 @@ use Drush\Commands\DrushCommands;
class ActivityPubCommands extends DrushCommands {
/**
* Send activities.
* Process outbox.
*
* @param int $send
* Whether to send to activities to inboxes.
......@@ -21,11 +21,10 @@ class ActivityPubCommands extends DrushCommands {
* @param int $debug
* Whether to view debug statements.
*
* @command activitypub:send-activities
* @aliases asa,activitypub-send-activities
* @command activitypub:process-outbox
*/
public function sendActivities($send = 1, $remove_queue_item = 1, $debug = 0) {
if (\Drupal::config('activitypub.settings')->get('send_outbox_handler') == 'drush') {
public function processOutbox($send = 1, $remove_queue_item = 1, $debug = 0) {
if (\Drupal::config('activitypub.settings')->get('process_outbox_handler') == 'drush') {
// Make sure the host is not set to default.
if (\Drupal::request()->getHost() == 'default') {
......@@ -33,7 +32,30 @@ class ActivityPubCommands extends DrushCommands {
return;
}
\Drupal::service('activitypub.outbox.client')->handleOutboxQueue((bool) $send, (bool) $remove_queue_item, (bool) $debug);
\Drupal::service('activitypub.process.client')->handleOutboxQueue((bool) $send, (bool) $remove_queue_item, (bool) $debug);
}
}
/**
* Process inbox.
*
* @param int $remove_queue_item
* Delete item from queue.
* @param int $debug
* Whether to view debug statements.
*
* @command activitypub:process-inbox
*/
public function processInbox($remove_queue_item = 1, $debug = 0) {
if (\Drupal::config('activitypub.settings')->get('process_inbox_handler') == 'drush') {
// Make sure the host is not set to default.
if (\Drupal::request()->getHost() == 'default') {
$this->writeln("Please provide the -l or --uri parameter to set the host.");
return;
}
\Drupal::service('activitypub.process.client')->handleInboxQueue((bool) $remove_queue_item, (bool) $debug);
}
}
......@@ -56,7 +78,7 @@ class ActivityPubCommands extends DrushCommands {
->set('processed', FALSE)
->set('queued', TRUE)
->save();
\Drupal::service('activitypub.outbox.client')->createQueueItem($activity);
\Drupal::service('activitypub.process.client')->createQueueItem($activity);
$this->logger()->notice('Added to queue');
}
else {
......@@ -71,14 +93,14 @@ class ActivityPubCommands extends DrushCommands {
* @param $entity_id
* @param $object
* @param int $status
* @param string $target_entity_type_id
*
* @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
* @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
* @throws \Drupal\Core\Entity\EntityStorageException
* @command activitypub:delete-activity
*
*/
public function sendDeleteRequest($uid, $entity_id, $object, $status = 0) {
public function sendDeleteRequest($uid, $entity_id, $object, $status = 0, $target_entity_type_id = 'node') {
$actor = \Drupal::entityTypeManager()->getStorage('activitypub_actor')->loadActorByEntityIdAndType($uid, 'person');
......@@ -88,7 +110,7 @@ class ActivityPubCommands extends DrushCommands {
'config_id' => 'delete',
'uid' => $uid,
'actor' => \Drupal::service('activitypub.utility')->getActivityPubID($actor),
'entity_type_id' => 'node',
'entity_type_id' => $target_entity_type_id,
'entity_id' => $entity_id,
'processed' => FALSE,
'object' => $object,
......@@ -97,7 +119,7 @@ class ActivityPubCommands extends DrushCommands {
/** @var \Drupal\activitypub\Entity\ActivityPubActivityInterface $activity */
$activity = \Drupal::entityTypeManager()->getStorage('activitypub_activity')->create($values);
$activity->save();
\Drupal::service('activitypub.outbox.client')->createQueueItem($activity);
\Drupal::service('activitypub.process.client')->createQueueItem($activity);
}
/**
......
......@@ -3,7 +3,7 @@
namespace Drupal\activitypub\Controller;
use Drupal\activitypub\Entity\ActivityPubActivityInterface;
use Drupal\activitypub\Services\ActivityPubOutboxClientInterface;
use Drupal\activitypub\Services\ActivityPubProcessClientInterface;
use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Url;
use Symfony\Component\DependencyInjection\ContainerInterface;
......@@ -12,19 +12,19 @@ use Symfony\Component\HttpFoundation\RedirectResponse;
class ActivityController extends ControllerBase {
/**
* The ActivityPub outbox client service.
* The ActivityPub process client service.
*
* @var \Drupal\activitypub\Services\ActivityPubOutboxClientInterface
* @var \Drupal\activitypub\Services\ActivityPubProcessClientInterface
*/
protected $activityPubOutboxClient;
protected $activityPubProcessClient;
/**
* ActivityController constructor
*
* @param \Drupal\activitypub\Services\ActivityPubOutboxClientInterface $activitypub_outbox_client
* @param \Drupal\activitypub\Services\ActivityPubProcessClientInterface $activitypub_process_client
*/
public function __construct(ActivityPubOutboxClientInterface $activitypub_outbox_client) {
$this->activityPubOutboxClient = $activitypub_outbox_client;
public function __construct(ActivityPubProcessClientInterface $activitypub_process_client) {
$this->activityPubProcessClient = $activitypub_process_client;
}
/**
......@@ -32,7 +32,7 @@ class ActivityController extends ControllerBase {
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('activitypub.outbox.client')
$container->get('activitypub.process.client')
);
}
......@@ -59,18 +59,17 @@ class ActivityController extends ControllerBase {
* @throws \Drupal\Core\Entity\EntityStorageException
*/
public function queue(ActivityPubActivityInterface $activitypub_activity) {
if ($activitypub_activity->getCollection() == ActivityPubActivityInterface::OUTBOX) {
if ($activitypub_activity->canBeQueued()) {
$activitypub_activity
->set('processed', FALSE)
->set('queued', TRUE)
->save();
$this->activityPubOutboxClient->createQueueItem($activitypub_activity);
$this->activityPubProcessClient->createQueueItem($activitypub_activity);
$this->messenger()->addMessage($this->t('Activity @id has been added to the queue', ['@id' => $activitypub_activity->id()]));
}
else {
$this->messenger()->addWarning($this->t('The activity does not belong to the outbox collection'));
$this->messenger()->addWarning($this->t('The activity can not be added to the queue'));
}
return new RedirectResponse(Url::fromRoute('activitypub.user.activities', ['user' => $activitypub_activity->getOwnerId()])->toString());
}
......
......@@ -2,12 +2,9 @@
namespace Drupal\activitypub\Controller;
use ActivityPhp\Type;
use ActivityPhp\Type\TypeConfiguration;
use ActivityPhp\Type\Util;
use Drupal\activitypub\Entity\ActivityPubActivityInterface;
use Drupal\activitypub\Entity\ActivitypubActorInterface;
use Drupal\activitypub\Services\ActivityPubOutboxClientInterface;
use Drupal\activitypub\Services\ActivityPubProcessClientInterface;
use Drupal\activitypub\Services\ActivityPubUtilityInterface;
use Drupal\Core\Path\PathMatcherInterface;
use Drupal\user\UserInterface;
......@@ -32,23 +29,23 @@ class InboxController extends BaseController {
protected $activityPubUtility;
/**
* The ActivityPub outbox client service.
* The ActivityPub process client service.
*
* @var \Drupal\activitypub\Services\ActivityPubOutboxClientInterface
* @var \Drupal\activitypub\Services\ActivityPubProcessClientInterface
*/
protected $activityPubOutboxClient;
protected $activityPubProcessClient;
/**
* InboxController constructor
*
* @param \Drupal\Core\Path\PathMatcherInterface $path_matcher
* @param \Drupal\activitypub\Services\ActivityPubUtilityInterface $activitypub_utility
* @param \Drupal\activitypub\Services\ActivityPubOutboxClientInterface $activitypub_outbox_client
* @param \Drupal\activitypub\Services\ActivityPubProcessClientInterface $activitypub_process_client
*/
public function __construct(PathMatcherInterface $path_matcher, ActivityPubUtilityInterface $activitypub_utility, ActivityPubOutboxClientInterface $activitypub_outbox_client) {
public function __construct(PathMatcherInterface $path_matcher, ActivityPubUtilityInterface $activitypub_utility, ActivityPubProcessClientInterface $activitypub_process_client) {
$this->pathMatcher = $path_matcher;
$this->activityPubUtility = $activitypub_utility;
$this->activityPubOutboxClient = $activitypub_outbox_client;
$this->activityPubProcessClient = $activitypub_process_client;
}
/**
......@@ -58,7 +55,7 @@ class InboxController extends BaseController {
return new static(
$container->get('path.matcher'),
$container->get('activitypub.utility'),
$container->get('activitypub.outbox.client')
$container->get('activitypub.process.client')
);
}
......@@ -73,56 +70,57 @@ class InboxController extends BaseController {
*/
public function inbox(Request $request, UserInterface $user, ActivitypubActorInterface $activitypub_actor) {
$status = 400;
try {
$payload = Util::decodeJson((string)$request->getContent());
// Ignore undefined properties.
TypeConfiguration::set('undefined_properties', 'ignore');
// Cast as an ActivityStreams type.
$activityObject = Type::create($payload);
// We currently don't verify the signature if it's available since we only
// accept types which are useful for the user at the moment. The plugins
// handle them when necessary.
// Save when verified and the actor is not blocked.
if (!$this->domainIsBlocked($activitypub_actor->getBlockedDomains(), $activityObject->get('actor'))) {
$object = $activityObject->get('object');
if (is_array($object) && isset($object['object'])) {
$object = $object['object'];
$payload = @json_decode((string)$request->getContent(), TRUE);
if ($request->getMethod() == 'POST' && !empty($payload)) {
try {
// We currently don't verify the signature if it's available since we only
// accept types which are useful for the user at the moment. The plugins
// handle them when necessary.
// Save when the actor is not blocked.
if (!$this->domainIsBlocked($activitypub_actor->getBlockedDomains(), $payload['actor'])) {
$object = '';
if (isset($payload['object'])) {
if (is_array($payload['object']) && isset($payload['object']['object'])) {
$object = $payload['object']['object'];
}
elseif (is_array($payload['object']) && isset($payload['object']['inReplyTo'])) {
$object = $payload['object']['inReplyTo'];
}
elseif (is_string($payload['object'])) {
$object = $payload['object'];
}
}
/** @var \Drupal\activitypub\Entity\Storage\ActivityPubActivityStorage $storage */
$storage = $this->entityTypeManager()->getStorage('activitypub_activity');
$values = [
'uid' => $user->id(),
'collection' => ActivityPubActivityInterface::INBOX,
'external_id' => $payload['id'],
'type' => $payload['type'],
'actor' => $payload['actor'],
'object' => $object,
'payload' => (string) $request->getContent(),
];
/** @var \Drupal\activitypub\Entity\ActivityPubActivityInterface $activity */
$activity = $storage->create($values);
$activity->save();
$status = 201;
}
elseif (is_object($object) && isset($object->object)) {
$object = $object->object;
else {
$status = 403;
}
/** @var \Drupal\activitypub\Entity\Storage\ActivityPubActivityStorage $storage */
$storage = $this->entityTypeManager()->getStorage('activitypub_activity');
$values = [
'uid' => $user->id(),
'collection' => ActivityPubActivityInterface::INBOX,
'external_id' => $activityObject->get('id'),
'type' => $activityObject->get('type'),
'actor' => $activityObject->get('actor'),
'object' => $object,
'payload' => (string) $request->getContent(),
];
/** @var \Drupal\activitypub\Entity\ActivityPubActivityInterface $activity */
$activity = $storage->create($values);
$activity->save();
$status = 201;
}
else {
$status = 403;
catch (\Exception $e) {
$this->getLogger('activitypub')->notice('Inbox error: @message - @content', ['@message' => $e->getMessage(), '@content' => (string) $request->getContent()]);
}
}
catch (\Exception $e) {
$this->getLogger('activitypub')->notice('Inbox error: @message - @content', ['@message' => $e->getMessage(), '@content' => (string) $request->getContent()]);
}
return new JsonResponse(NULL, $status);
......
......@@ -2,7 +2,7 @@
namespace Drupal\activitypub\Controller;
use Drupal\activitypub\Services\ActivityPubOutboxClientInterface;
use Drupal\activitypub\Services\ActivityPubProcessClientInterface;
use Drupal\node\NodeInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
......@@ -40,7 +40,7 @@ class NodeController extends BaseController {
else {
$output = $build;
}
$output['@context'] = ActivityPubOutboxClientInterface::STREAMS_CONTEXT;
$output['@context'] = ActivityPubProcessClientInterface::STREAMS_CONTEXT;
$response = new JsonResponse($output, 200);
$response->headers->set('Content-Type', 'application/activity+json');
return $response;
......
......@@ -195,6 +195,43 @@ class ActivityPubActivity extends ContentEntityBase implements ActivityPubActivi
return $object->build($this, $entity);
}
/**
* {@inheritdoc}
*/
public function doInboxProcess() {
/** @var \Drupal\activitypub\Entity\ActivityPubTypeInterface $activityPubType */
$activityPubType = $this->entityTypeManager()->getStorage('activitypub_type')->load($this->getConfigID());
// Add entity.
$entity = NULL;
if ($this->getTargetEntityTypeId() && $this->getTargetEntityId()) {
$entity = $this->entityTypeManager()->getStorage($this->getTargetEntityTypeId())->load($this->getTargetEntityId());
}
/** @var \Drupal\activitypub\Services\Type\TypePluginInterface $object */
$object = $this->getTypePluginManager()->createInstance($activityPubType->getPlugin()['id'], $activityPubType->getPlugin()['configuration']);
return $object->doInboxProcess($this, $entity);
}
/**
* {@inheritdoc}
*/
public function canBeQueued() {
$canBeQueued = FALSE;
if ($this->getCollection() == ActivityPubActivityInterface::OUTBOX) {
$canBeQueued = TRUE;
}
elseif ($this->getCollection() == ActivityPubActivityInterface::INBOX) {
$json = @json_decode($this->getPayLoad(), TRUE);
if (isset($json['object']) && !empty($json['object']['inReplyTo'])) {
$canBeQueued = TRUE;
}
}
return $canBeQueued;
}
/**
* {@inheritdoc}
*/
......
......@@ -114,4 +114,16 @@ interface ActivityPubActivityInterface extends ContentEntityInterface, EntityOwn
*/
public function buildActivity();
/**
* Do inbox processing.
*/
public function doInboxProcess();
/**
* Returns whether the activity can be queued.
*
* @return bool
*/
public function canBeQueued();
}
......@@ -67,7 +67,6 @@ class ActivityPubActivityListBuilder extends EntityListBuilder {