mirror of https://github.com/pixelfed/pixelfed
You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
356 lines
10 KiB
PHP
356 lines
10 KiB
PHP
<?php
|
|
|
|
namespace App\Util\ActivityPub\Inbox;
|
|
|
|
use App\Federation\Handlers\DirectMessageHandler;
|
|
use App\Federation\Validators\DirectMessageValidator;
|
|
use App\Jobs\StatusPipeline\RemoteReplyResolvePipeline;
|
|
use App\Models\PollVote;
|
|
use App\Models\Profile;
|
|
use App\Models\Status;
|
|
use App\Services\FollowerService;
|
|
use App\Services\PollService;
|
|
use App\Util\ActivityPub\Helpers;
|
|
|
|
trait HandlesCreates
|
|
{
|
|
public function handleCreateActivity(): void
|
|
{
|
|
$activity = $this->payload['object'];
|
|
|
|
if ($this->isContentFilteredBySpam($activity)) {
|
|
return;
|
|
}
|
|
|
|
$actor = $this->validateAndFetchActor($this->payload['actor']);
|
|
if (! $actor || $actor->domain == null) {
|
|
return;
|
|
}
|
|
|
|
if (! isset($activity['to'])) {
|
|
return;
|
|
}
|
|
|
|
if ($activity['type'] == 'Question') {
|
|
return;
|
|
}
|
|
|
|
// A poll vote is addressed to the poll author alone, which is also
|
|
// the shape of a direct message, so it has to be recognised first.
|
|
// Votes are tallies: never a DM, never a comment.
|
|
if ($activity['type'] == 'Note' && $this->isPollVoteObject($activity)) {
|
|
if (
|
|
is_string($activity['inReplyTo']) &&
|
|
Helpers::validateLocalUrl($activity['inReplyTo'])
|
|
) {
|
|
$this->handlePollVote();
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
// Anything addressed only to people is a direct message, whether it
|
|
// names one of them or several. It stops here even when it cannot be
|
|
// stored: the paths below file every non-public Note as
|
|
// followers-only, which would show it to the sender's followers.
|
|
if (DirectMessageValidator::isDirect($activity, $actor)) {
|
|
$this->handleDirectMessage($actor);
|
|
|
|
return;
|
|
}
|
|
|
|
if ($activity['type'] == 'Note' && ! empty($activity['inReplyTo'])) {
|
|
$this->handleNoteReply();
|
|
} elseif ($activity['type'] == 'Note' && ! empty($activity['attachment'])) {
|
|
if (! $this->verifyNoteAttachment()) {
|
|
return;
|
|
}
|
|
$this->handleNoteCreate();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle a delivered reply.
|
|
*
|
|
* The object is stored from the signed delivery, not re-fetched. Fetching
|
|
* loses every reply our instance actor cannot read (followers-only
|
|
* replies to local posts), and the old fetch used object.url, which is an
|
|
* HTML permalink on most software and an array on some.
|
|
*
|
|
* A reply is only ever stored with its parent attached. If the parent
|
|
* cannot be resolved right now the delivery is parked in
|
|
* RemoteReplyResolvePipeline and retried, never written as a top-level
|
|
* status.
|
|
*/
|
|
public function handleNoteReply(): void
|
|
{
|
|
$object = $this->payload['object'];
|
|
|
|
$actorUrl = Helpers::pluckval($this->payload['actor'] ?? null);
|
|
if (! is_string($actorUrl)) {
|
|
return;
|
|
}
|
|
|
|
$actor = $this->validateAndFetchActor($actorUrl);
|
|
if (! $actor || $actor->domain == null) {
|
|
return;
|
|
}
|
|
|
|
$id = Helpers::pluckval($object['id'] ?? null);
|
|
if (! is_string($id) || ! Helpers::validateUrl($id)) {
|
|
return;
|
|
}
|
|
|
|
if (Helpers::findExistingStatus($id)) {
|
|
return;
|
|
}
|
|
|
|
if (! $this->deliveredObjectIsTrusted($object, $actor, $id)) {
|
|
// Not provably authored by the sender. Ask the origin, by id.
|
|
Helpers::statusFirstOrFetch($id, true);
|
|
|
|
return;
|
|
}
|
|
|
|
$published = Helpers::pluckval($object['published'] ?? null);
|
|
if (! is_string($published) || ! Helpers::validateTimestamp($published)) {
|
|
return;
|
|
}
|
|
|
|
$resolution = Helpers::resolveReplyParent($object, $actor);
|
|
|
|
if ($resolution['state'] === Helpers::REPLY_PARENT_UNRESOLVED) {
|
|
RemoteReplyResolvePipeline::park($object, $actor);
|
|
|
|
return;
|
|
}
|
|
|
|
if (! Helpers::replyParentAllowsStore($resolution)) {
|
|
return;
|
|
}
|
|
|
|
Helpers::storeStatus($id, $actor, $object);
|
|
}
|
|
|
|
/**
|
|
* A delivered object can be stored as-is only when the sender is its
|
|
* author: attributedTo is exactly the activity actor, the object id lives
|
|
* on the actor's host, and, when the signing key maps to a known profile,
|
|
* that profile is the actor. InboxValidator only binds these by host.
|
|
*/
|
|
protected function deliveredObjectIsTrusted(array $object, Profile $actor, string $id): bool
|
|
{
|
|
$attributedTo = $object['attributedTo'] ?? null;
|
|
|
|
if (! is_string($attributedTo) && ! is_array($attributedTo)) {
|
|
return false;
|
|
}
|
|
|
|
$author = Helpers::extractAttributedTo($attributedTo);
|
|
|
|
if (! is_string($author) || $author !== $actor->remote_url) {
|
|
return false;
|
|
}
|
|
|
|
$idHost = parse_url($id, PHP_URL_HOST);
|
|
$actorHost = parse_url((string) $actor->remote_url, PHP_URL_HOST);
|
|
|
|
if (
|
|
! is_string($idHost) ||
|
|
! is_string($actorHost) ||
|
|
strcasecmp($idHost, $actorHost) !== 0
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
$signer = $this->signingProfile();
|
|
|
|
return ! $signer || (string) $signer->id === (string) $actor->id;
|
|
}
|
|
|
|
/**
|
|
* A poll vote: a Note that names an option and says nothing else.
|
|
*/
|
|
protected function isPollVoteObject(array $object): bool
|
|
{
|
|
return isset($object['inReplyTo'], $object['name']) &&
|
|
! isset($object['content']) &&
|
|
! isset($object['attachment']);
|
|
}
|
|
|
|
public function handlePollCreate(): void
|
|
{
|
|
$activity = $this->payload['object'];
|
|
$actor = $this->validateAndFetchActor($this->payload['actor']);
|
|
if (! $actor || $actor->domain == null) {
|
|
return;
|
|
}
|
|
$url = $activity['url'] ?? $activity['id'];
|
|
Helpers::statusFirstOrFetch($url);
|
|
}
|
|
|
|
public function handleNoteCreate(): void
|
|
{
|
|
$activity = $this->payload['object'];
|
|
$actor = $this->validateAndFetchActor($this->payload['actor']);
|
|
if (! $actor || $actor->domain == null) {
|
|
return;
|
|
}
|
|
|
|
if (
|
|
isset($activity['inReplyTo']) &&
|
|
isset($activity['name']) &&
|
|
! isset($activity['content']) &&
|
|
! isset($activity['attachment']) &&
|
|
Helpers::validateLocalUrl($activity['inReplyTo'])
|
|
) {
|
|
$this->handlePollVote();
|
|
|
|
return;
|
|
}
|
|
|
|
if ($actor->followers_count == 0) {
|
|
if (config('federation.activitypub.ingest.store_notes_without_followers')) {
|
|
// allowed — continue
|
|
} elseif (FollowerService::followerCount($actor->id, true) == 0) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
$hasUrl = isset($activity['url']);
|
|
$url = $activity['url'] ?? $activity['id'];
|
|
|
|
if ($hasUrl) {
|
|
if (Status::whereUri($url)->exists()) {
|
|
return;
|
|
}
|
|
} else {
|
|
if (Status::whereObjectUrl($url)->exists()) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
$id = Helpers::pluckval($activity['id'] ?? null);
|
|
|
|
if (! is_string($id) || ! Helpers::validateUrl($id)) {
|
|
return;
|
|
}
|
|
|
|
// Same rule as replies: only store the delivered object when the
|
|
// sender is provably its author. Otherwise an actor could claim
|
|
// another server's object id and squat its unique object_url.
|
|
if (! $this->deliveredObjectIsTrusted($activity, $actor, $id)) {
|
|
Helpers::statusFirstOrFetch($id);
|
|
|
|
return;
|
|
}
|
|
|
|
Helpers::storeStatus($url, $actor, $activity);
|
|
}
|
|
|
|
public function handlePollVote(): void
|
|
{
|
|
$activity = $this->payload['object'];
|
|
$actor = $this->validateAndFetchActor($this->payload['actor']);
|
|
|
|
if (! $actor) {
|
|
return;
|
|
}
|
|
|
|
$status = Helpers::statusFetch($activity['inReplyTo']);
|
|
|
|
if (! $status) {
|
|
return;
|
|
}
|
|
|
|
$poll = $status->poll;
|
|
|
|
if (! $poll) {
|
|
return;
|
|
}
|
|
|
|
if (now()->gt($poll->expires_at)) {
|
|
return;
|
|
}
|
|
|
|
$choices = $poll->poll_options;
|
|
$choice = array_search($activity['name'], $choices);
|
|
|
|
if ($choice === false) {
|
|
return;
|
|
}
|
|
|
|
if (PollVote::whereStatusId($status->id)->whereProfileId($actor->id)->exists()) {
|
|
return;
|
|
}
|
|
|
|
$vote = new PollVote;
|
|
$vote->status_id = $status->id;
|
|
$vote->profile_id = $actor->id;
|
|
$vote->poll_id = $poll->id;
|
|
$vote->choice = $choice;
|
|
$vote->uri = $activity['id'] ?? null;
|
|
$vote->save();
|
|
|
|
$tallies = $poll->cached_tallies;
|
|
$tallies[$choice] += 1;
|
|
$poll->cached_tallies = $tallies;
|
|
$poll->votes_count = array_sum($tallies);
|
|
$poll->save();
|
|
|
|
PollService::del($status->id);
|
|
}
|
|
|
|
/**
|
|
* Hand a direct Note to the direct message handler.
|
|
*/
|
|
public function handleDirectMessage(Profile $actor): void
|
|
{
|
|
$object = $this->payload['object'];
|
|
|
|
$id = Helpers::pluckval($object['id'] ?? null);
|
|
|
|
if (! is_string($id) || ! Helpers::validateUrl($id)) {
|
|
return;
|
|
}
|
|
|
|
// Only the author can deliver their own message. A direct message is
|
|
// never fetched to double check: it is not readable by anyone else.
|
|
if (! $this->deliveredObjectIsTrusted($object, $actor, $id)) {
|
|
return;
|
|
}
|
|
|
|
app(DirectMessageHandler::class)->handleCreate($object, $actor);
|
|
}
|
|
|
|
/**
|
|
* Check if content matches autospam live filters.
|
|
*/
|
|
protected function isContentFilteredBySpam(array $activity): bool
|
|
{
|
|
if (! config('autospam.live_filters.enabled')) {
|
|
return false;
|
|
}
|
|
|
|
$filters = config('autospam.live_filters.filters');
|
|
if (empty($filters) || ! isset($activity['content']) || empty($activity['content']) || strlen($filters) <= 3) {
|
|
return false;
|
|
}
|
|
|
|
$filters = array_map('trim', explode(',', $filters));
|
|
$content = strtolower($activity['content']);
|
|
|
|
foreach ($filters as $filter) {
|
|
$filter = trim(strtolower($filter));
|
|
if (! $filter || ! strlen($filter)) {
|
|
continue;
|
|
}
|
|
if (str_contains($content, $filter)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
}
|