forked from grumpydevelop/federator

- integrate support to send new posts to CN - save original article-id in DB (needs db-migration) - votes and comments on CN-articles and comments are sent to CN, with proper signing and format - fixed minor issue where delete-activity was not properly working with objects - fixed minor issue where tombstone wasn't supported (which prevented being able to delete mastodon-posts from the db)
441 lines
17 KiB
PHP
441 lines
17 KiB
PHP
<?php
|
|
/**
|
|
* SPDX-FileCopyrightText: 2024 Sascha Nitsch (grumpydeveloper) https://contentnation.net/@grumpydevelop
|
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
*
|
|
* @author Yannis Vogel (vogeldevelopment)
|
|
**/
|
|
|
|
namespace Federator\Api\FedUsers;
|
|
|
|
/**
|
|
* handle activitypub inbox requests
|
|
*/
|
|
class Inbox implements \Federator\Api\FedUsers\FedUsersInterface
|
|
{
|
|
/**
|
|
* main instance
|
|
*
|
|
* @var \Federator\Api $main
|
|
*/
|
|
private $main;
|
|
|
|
/**
|
|
* constructor
|
|
* @param \Federator\Api $main api main instance
|
|
*/
|
|
public function __construct($main)
|
|
{
|
|
$this->main = $main;
|
|
}
|
|
|
|
/**
|
|
* handle get call
|
|
*
|
|
* @param string|null $_user user to fetch inbox for @unused-param
|
|
* @return string|false response
|
|
*/
|
|
public function get($_user)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* handle post call
|
|
*
|
|
* @param string|null $_user user to add data to inbox
|
|
* @return string|false response
|
|
*/
|
|
public function post($_user)
|
|
{
|
|
$_rawInput = file_get_contents('php://input');
|
|
|
|
$allHeaders = getallheaders();
|
|
try {
|
|
$this->main->checkSignature($allHeaders);
|
|
} catch (\Federator\Exceptions\PermissionDenied $e) {
|
|
throw new \Federator\Exceptions\Unauthorized("Inbox::post Signature check failed: " . $e->getMessage());
|
|
}
|
|
|
|
$activity = is_string($_rawInput) ? json_decode($_rawInput, true) : null;
|
|
|
|
$dbh = $this->main->getDatabase();
|
|
$cache = $this->main->getCache();
|
|
$connector = $this->main->getConnector();
|
|
|
|
$config = $this->main->getConfig();
|
|
|
|
if (!is_array($activity)) {
|
|
throw new \Federator\Exceptions\ServerError("Inbox::post Input wasn't of type array");
|
|
}
|
|
|
|
$inboxActivity = \Federator\Data\ActivityPub\Factory::newActivityFromJson($activity);
|
|
|
|
if ($inboxActivity === false) {
|
|
throw new \Federator\Exceptions\ServerError("Inbox::post couldn't create inboxActivity");
|
|
}
|
|
$user = $inboxActivity->getAActor(); // url of the sender https://contentnation.net/username
|
|
$username = basename((string) (parse_url($user, PHP_URL_PATH) ?? ''));
|
|
$domain = parse_url($user, PHP_URL_HOST);
|
|
$userId = $username . '@' . $domain;
|
|
$user = \Federator\DIO\FedUser::getUserByName(
|
|
$dbh,
|
|
$userId,
|
|
$cache
|
|
);
|
|
if ($user === null || $user->id === null) {
|
|
error_log("Inbox::post couldn't find user: $userId");
|
|
throw new \Federator\Exceptions\ServerError("Inbox::post couldn't find user: $userId");
|
|
}
|
|
|
|
$users = [];
|
|
|
|
$receivers = array_merge($inboxActivity->getTo(), $inboxActivity->getCC());
|
|
|
|
// For Undo, the object may hold the proper to/cc
|
|
if ($inboxActivity->getType() === 'Undo') {
|
|
$object = $inboxActivity->getObject();
|
|
if ($object !== null && is_object($object)) {
|
|
$receivers = array_merge($object->getTo(), $object->getCC());
|
|
}
|
|
}
|
|
|
|
// Filter out the public address and keep only actual URLs
|
|
$receivers = array_filter($receivers, static function (mixed $receiver): bool {
|
|
return is_string($receiver)
|
|
&& $receiver !== 'https://www.w3.org/ns/activitystreams#Public'
|
|
&& (filter_var($receiver, FILTER_VALIDATE_URL) !== false);
|
|
});
|
|
|
|
if (isset($_user)) {
|
|
$receivers[] = $dbh->real_escape_string($_user); // Add the target user to the receivers list
|
|
}
|
|
|
|
// Special handling for Follow and Undo follow activities
|
|
if (strtolower($inboxActivity->getType()) === 'follow') {
|
|
// For Follow, the object should hold the target
|
|
$object = $inboxActivity->getObject();
|
|
if ($object !== null && is_string($object)) {
|
|
$receivers[] = $object;
|
|
}
|
|
} elseif (strtolower($inboxActivity->getType()) === 'undo') {
|
|
$object = $inboxActivity->getObject();
|
|
if ($object !== null && is_object($object)) {
|
|
// For Undo, the objects object should hold the target
|
|
if (strtolower($object->getType()) === 'follow') {
|
|
$objObject = $object->getObject();
|
|
if ($objObject !== null && is_string($objObject)) {
|
|
$receivers[] = $objObject;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
$ourDomain = $config['generic']['externaldomain'];
|
|
|
|
foreach ($receivers as $receiver) {
|
|
if ($receiver === '' || !is_string($receiver)) {
|
|
continue;
|
|
}
|
|
|
|
if (str_ends_with($receiver, '/followers')) {
|
|
$actor = $inboxActivity->getAActor();
|
|
if ($actor === null || !is_string($actor)) {
|
|
error_log("Inbox::post no actor found");
|
|
continue;
|
|
}
|
|
|
|
// Extract username from the actor URL
|
|
$username = basename((string) (parse_url($actor, PHP_URL_PATH) ?? ''));
|
|
$domain = parse_url($actor, PHP_URL_HOST);
|
|
if ($username === null || $domain === null) {
|
|
error_log("Inbox::post no username or domain found for recipient: $receiver");
|
|
continue;
|
|
}
|
|
try {
|
|
$followers = \Federator\DIO\Followers::getFollowersByFedUser($dbh, $connector, $cache, $username . '@' . $domain);
|
|
} catch (\Throwable $e) {
|
|
error_log("Inbox::post get followers for user: " . $username . '@' . $domain . ". Exception: " . $e->getMessage());
|
|
continue;
|
|
}
|
|
|
|
if (is_array($followers)) {
|
|
$users = array_merge($users, array_column($followers, 'id'));
|
|
}
|
|
} else {
|
|
// check if receiver is an actor url from our domain
|
|
if (!str_contains($receiver, $ourDomain) && $receiver !== $_user) {
|
|
continue;
|
|
}
|
|
if ($receiver !== $_user) {
|
|
$receiverName = basename((string) (parse_url($receiver, PHP_URL_PATH) ?? ''));
|
|
$ourDomain = parse_url($receiver, PHP_URL_HOST);
|
|
if ($receiverName === null || $ourDomain === null) {
|
|
error_log("Inbox::post no receiverName or domain found for receiver: " . $receiver);
|
|
continue;
|
|
}
|
|
$receiver = $receiverName;
|
|
}
|
|
try {
|
|
$localUser = \Federator\DIO\User::getUserByName(
|
|
$dbh,
|
|
$receiver,
|
|
$connector,
|
|
$cache
|
|
);
|
|
} catch (\Throwable $e) {
|
|
error_log("Inbox::post get user by name: " . $receiver . ". Exception: " . $e->getMessage());
|
|
continue;
|
|
}
|
|
if ($localUser === null || $localUser->id === null) {
|
|
error_log("Inbox::post couldn't find user: $receiver");
|
|
continue;
|
|
}
|
|
$users[] = $localUser->id;
|
|
}
|
|
}
|
|
|
|
if (empty($users)) { // todo remove after proper implementation, debugging for now
|
|
$rootDir = PROJECT_ROOT . '/';
|
|
// Save the raw input and parsed JSON to a file for inspection
|
|
file_put_contents(
|
|
$rootDir . 'logs/inbox.log',
|
|
date('Y-m-d H:i:s') . ": ==== POST Inbox Activity ====\n" . json_encode($inboxActivity, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) . "\n\n",
|
|
FILE_APPEND
|
|
);
|
|
}
|
|
|
|
foreach ($users as $receiver) {
|
|
if (!isset($receiver)) {
|
|
continue;
|
|
}
|
|
$token = \Resque::enqueue('inbox', 'Federator\\Jobs\\InboxJob', [
|
|
'user' => $user->id,
|
|
'recipientId' => $receiver,
|
|
'activity' => $inboxActivity->toObject(),
|
|
]);
|
|
error_log("Inbox::post enqueued job for user: $user->id with token: $token");
|
|
}
|
|
if (empty($users)) {
|
|
$type = strtolower($inboxActivity->getType());
|
|
if ($type === 'undo' || $type === 'delete') {
|
|
$token = \Resque::enqueue('inbox', 'Federator\\Jobs\\InboxJob', [
|
|
'user' => $user->id,
|
|
'recipientId' => "",
|
|
'activity' => $inboxActivity->toObject(),
|
|
]);
|
|
error_log("Inbox::post enqueued job for user: $user->id with token: $token");
|
|
} else {
|
|
error_log("Inbox::post no users found for activity, doing nothing: " . json_encode($inboxActivity, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT));
|
|
}
|
|
}
|
|
|
|
try {
|
|
$articleId = \Federator\DIO\Posts::getOriginalArticleId($dbh, $inboxActivity);
|
|
if ($articleId !== null) {
|
|
$connector->sendActivity($user, $inboxActivity);
|
|
}
|
|
} catch (\Throwable $e) {
|
|
error_log("Inbox::postForUser Error sending activity to connector. Exception: " . $e->getMessage());
|
|
return false;
|
|
}
|
|
|
|
return "success";
|
|
}
|
|
|
|
/**
|
|
* handle post call for specific user
|
|
*
|
|
* @param \mysqli $dbh database handle
|
|
* @param \Federator\Connector\Connector $connector connector to use
|
|
* @param \Federator\Cache\Cache|null $cache optional caching service
|
|
* @param string $_user user that triggered the post
|
|
* @param string $_recipientId recipient of the post
|
|
* @param \Federator\Data\ActivityPub\Common\Activity $inboxActivity the activity that we received
|
|
* @return boolean response
|
|
*/
|
|
public static function postForUser($dbh, $connector, $cache, $_user, $_recipientId, $inboxActivity)
|
|
{
|
|
if (!isset($_user)) {
|
|
error_log("Inbox::postForUser no user given");
|
|
return false;
|
|
}
|
|
|
|
// get sender
|
|
$user = \Federator\DIO\FedUser::getUserByName(
|
|
$dbh,
|
|
$_user,
|
|
$cache
|
|
);
|
|
if ($user === null || $user->id === null) {
|
|
error_log("Inbox::postForUser couldn't find user: $_user");
|
|
return false;
|
|
}
|
|
|
|
$type = strtolower($inboxActivity->getType());
|
|
|
|
if ($_recipientId === '') {
|
|
if ($type === 'undo' || $type === 'delete') {
|
|
switch ($type) {
|
|
case 'delete':
|
|
// Delete Note/Post
|
|
$object = $inboxActivity->getObject();
|
|
if (is_string($object)) {
|
|
\Federator\DIO\Posts::deletePost($dbh, $object);
|
|
} elseif (is_object($object)) {
|
|
$objectId = $object->getID();
|
|
\Federator\DIO\Posts::deletePost($dbh, $objectId);
|
|
} else {
|
|
error_log("Inbox::postForUser Error in Delete Post for user $user->id, object is not a string or object");
|
|
error_log(" object of type " . gettype($object));
|
|
return false;
|
|
}
|
|
break;
|
|
|
|
case 'undo':
|
|
$object = $inboxActivity->getObject();
|
|
if (is_object($object)) {
|
|
switch (strtolower($object->getType())) {
|
|
case 'like':
|
|
case 'dislike':
|
|
// Undo Like/Dislike (remove like/dislike)
|
|
$targetId = $object->getID();
|
|
// \Federator\DIO\Votes::removeVote($dbh, $user->id, $targetId, 'dislike');
|
|
\Federator\DIO\Posts::deletePost($dbh, $targetId);
|
|
break;
|
|
case 'note':
|
|
case 'article':
|
|
// Undo Note (remove note)
|
|
$noteId = $object->getID();
|
|
\Federator\DIO\Posts::deletePost($dbh, $noteId);
|
|
break;
|
|
}
|
|
}
|
|
break;
|
|
|
|
default:
|
|
error_log("Inbox::postForUser Unhandled activity type $type for user $user->id");
|
|
break;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
}
|
|
|
|
$atPos = strpos($_recipientId, '@');
|
|
if ($atPos !== false) {
|
|
$_recipientId = substr($_recipientId, 0, $atPos);
|
|
}
|
|
|
|
// get recipient
|
|
$recipient = \Federator\DIO\User::getUserByName(
|
|
$dbh,
|
|
$_recipientId,
|
|
$connector,
|
|
$cache
|
|
);
|
|
if ($recipient === null || $recipient->id === null) {
|
|
error_log("Inbox::postForUser couldn't find user: $_recipientId");
|
|
return false;
|
|
}
|
|
|
|
$rootDir = PROJECT_ROOT . '/';
|
|
// Save the raw input and parsed JSON to a file for inspection
|
|
file_put_contents(
|
|
$rootDir . 'logs/inbox_' . $recipient->id . '.log',
|
|
date('Y-m-d H:i:s') . ": ==== POST " . $recipient->id . " Inbox Activity ====\n" . json_encode($inboxActivity, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) . "\n\n",
|
|
FILE_APPEND
|
|
);
|
|
|
|
switch ($type) {
|
|
case 'follow':
|
|
$success = \Federator\DIO\Followers::addExternalFollow($dbh, $inboxActivity->getID(), $user->id, $recipient->id);
|
|
|
|
if ($success === false) {
|
|
error_log("Inbox::postForUser Failed to add follower for user $user->id");
|
|
}
|
|
break;
|
|
|
|
case 'delete':
|
|
// Delete Note/Post
|
|
$object = $inboxActivity->getObject();
|
|
if (is_string($object)) {
|
|
\Federator\DIO\Posts::deletePost($dbh, $object);
|
|
} elseif (is_object($object)) {
|
|
$objectId = $object->getID();
|
|
\Federator\DIO\Posts::deletePost($dbh, $objectId);
|
|
}
|
|
break;
|
|
|
|
case 'undo':
|
|
$object = $inboxActivity->getObject();
|
|
if (is_object($object)) {
|
|
switch (strtolower($object->getType())) {
|
|
case 'follow':
|
|
$success = false;
|
|
if ($object instanceof \Federator\Data\ActivityPub\Common\Activity) {
|
|
$actor = $object->getAActor();
|
|
if ($actor !== '') {
|
|
$success = \Federator\DIO\Followers::removeFollow($dbh, $user->id, $recipient->id);
|
|
}
|
|
}
|
|
if ($success === false) {
|
|
error_log("Inbox::postForUser Failed to remove follower for user $user->id");
|
|
}
|
|
break;
|
|
case 'like':
|
|
case 'dislike':
|
|
// Undo Like/Dislike (remove like/dislike)
|
|
$targetId = $object->getID();
|
|
// \Federator\DIO\Votes::removeVote($dbh, $user->id, $targetId, 'dislike');
|
|
\Federator\DIO\Posts::deletePost($dbh, $targetId);
|
|
break;
|
|
case 'note':
|
|
// Undo Note (remove note)
|
|
$noteId = $object->getID();
|
|
\Federator\DIO\Posts::deletePost($dbh, $noteId);
|
|
break;
|
|
}
|
|
}
|
|
break;
|
|
|
|
case 'like':
|
|
case 'dislike':
|
|
// Add Like/Dislike
|
|
$targetId = $inboxActivity->getObject();
|
|
if (is_string($targetId)) {
|
|
// \Federator\DIO\Votes::addVote($dbh, $user->id, $targetId, 'dislike');
|
|
\Federator\DIO\Posts::savePost($dbh, $user->id, $inboxActivity);
|
|
} else {
|
|
error_log("Inbox::postForUser Error in Add Like/Dislike for user $user->id, targetId is not a string");
|
|
return false;
|
|
}
|
|
break;
|
|
|
|
case 'create':
|
|
case 'update':
|
|
$object = $inboxActivity->getObject();
|
|
if (is_object($object)) {
|
|
switch (strtolower($object->getType())) {
|
|
case 'note':
|
|
\Federator\DIO\Posts::savePost($dbh, $user->id, $inboxActivity);
|
|
|
|
break;
|
|
case 'article':
|
|
\Federator\DIO\Posts::savePost($dbh, $user->id, $inboxActivity);
|
|
|
|
break;
|
|
default:
|
|
\Federator\DIO\Posts::savePost($dbh, $user->id, $inboxActivity);
|
|
break;
|
|
}
|
|
}
|
|
break;
|
|
|
|
default:
|
|
error_log("Inbox::postForUser Unhandled activity type $type for user $user->id");
|
|
break;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
}
|