federator/php/federator/api/v1/newcontent.php
Yannis Vogel d355b5a7cd
integrate queue for NewContent
- also integrated better support for newContent types
- Integrated saving posts to database (posts gotten via outbox request as well as posts received in the NewContent endpoint)
- proper support for handling comments
- support for likes/dislikes
- support for requesting followers / following endpoints
- better inbox support

database needs changes, don't forget to run migration
2025-05-20 16:34:50 +02:00

369 lines
No EOL
13 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\V1;
/**
* Called from our application to inform us about new content (f.e. new posts on contentnation.net)
*/
class NewContent implements \Federator\Api\APIInterface
{
/**
* main instance
*
* @var \Federator\Api $main
*/
private $main;
/**
* response from sub-calls
*
* @var string $response
*/
private $response;
/**
* constructor
*
* @param \Federator\Main $main main instance
* @return void
*/
public function __construct($main)
{
$this->main = $main;
}
/**
* run given url path
*
* @param array<string> $paths path array split by /
* @param \Federator\Data\User|false $user user who is calling us @unused-param
* @return bool true on success
*/
public function exec($paths, $user)
{
$method = $_SERVER["REQUEST_METHOD"];
$_username = $paths[2];
if ($method === 'GET') { // unsupported
throw new \Federator\Exceptions\InvalidArgument("GET not supported");
}
switch (sizeof($paths)) {
case 3:
$ret = $this->post($_username);
break;
}
if (isset($ret) && $ret !== false) {
$this->response = $ret;
return true;
}
$this->main->setResponseCode(404);
return false;
}
/**
* handle post call
*
* @param string|null $_user optional user that triggered the post
* @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) {
error_log("NewContent::post Signature check failed: " . $e->getMessage());
http_response_code(401);
return false;
}
$input = is_string($_rawInput) ? json_decode($_rawInput, true) : null;
$config = $this->main->getConfig();
$domain = $config['generic']['externaldomain'];
if (!is_array($input)) {
error_log("NewContent::post Input wasn't of type array");
return false;
}
if (isset($allHeaders['X-Sender'])) {
$newActivity = $this->main->getConnector()->jsonToActivity($input);
} else {
error_log("NewContent::post No X-Sender header found");
return false;
}
if ($newActivity === false) {
error_log("NewContent::post couldn't create newActivity");
return false;
}
$dbh = $this->main->getDatabase();
$cache = $this->main->getCache();
$connector = $this->main->getConnector();
if (!isset($_user)) {
$user = $newActivity->getAActor(); // url of the sender https://contentnation.net/username
$user = str_replace(
$domain,
'',
$user
); // retrieve only the last part of the url
} else {
$user = $dbh->real_escape_string($_user);
}
$users = [];
if ($newActivity->getType() === 'Create') {
$followers = $this->fetchAllFollowers($dbh, $connector, $cache, $user);
}
if (!empty($followers)) {
$users = array_merge($users, $followers);
}
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/newContent.log',
date('Y-m-d H:i:s') . ": ==== POST NewContent Activity ====\n" . json_encode($newActivity, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) . "\n\n",
FILE_APPEND
);
}
if ($_user !== false && !in_array($_user, $users, true)) {
$users[] = $_user;
}
foreach ($users as $user) {
if (!isset($user)) {
continue;
}
$token = \Resque::enqueue('inbox', 'Federator\\Jobs\\NewContentJob', [
'user' => $user,
'activity' => $newActivity->toObject(),
]);
error_log("Inbox::post enqueued job for user: $user with token: $token");
}
return json_encode($newActivity, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
}
/**
* handle post call for specific user
*
* @param \mysqli $dbh @unused-param
* database handle
* @param \Federator\Connector\Connector $connector
* connector to fetch use with
* @param \Federator\Cache\Cache|null $cache
* optional caching service
* @param string $_user user that triggered the post
* @param \Federator\Data\ActivityPub\Common\Activity $newActivity the activity that we received
* @return boolean response
*/
public static function postForUser($dbh, $connector, $cache, $_user, $newActivity)
{
if (!isset($_user)) {
error_log("NewContent::postForUser no user given");
return false;
}
// get user
$user = \Federator\DIO\User::getUserByName(
$dbh,
$_user,
$connector,
$cache
);
if ($user === null || $user->id === null) {
error_log("NewContent::postForUser couldn't find user: $_user");
return false;
}
$rootDir = PROJECT_ROOT . '/';
// Save the raw input and parsed JSON to a file for inspection
file_put_contents(
$rootDir . 'logs/newcontent_' . $_user . '.log',
date('Y-m-d H:i:s') . ": ==== POST " . $_user . " NewContent Activity ====\n" . json_encode($newActivity, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) . "\n\n",
FILE_APPEND
);
$type = $newActivity->getType();
switch ($type) {
case 'Follow':
$success = false;
$actor = $newActivity->getAActor();
if ($actor !== '') {
$followerUsername = basename((string) (parse_url($actor, PHP_URL_PATH) ?? ''));
$followerDomain = parse_url($actor, PHP_URL_HOST);
if (is_string($followerDomain)) {
$followerId = "{$followerUsername}@{$followerDomain}";
$success = \Federator\DIO\Followers::addFollow($dbh, $followerId, $user->id, $followerDomain);
}
}
if ($success === false) {
error_log("NewContent::postForUser: Failed to add follower for user $user->id");
}
break;
case 'Delete':
// Delete Note/Post
$object = $newActivity->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 = $newActivity->getObject();
if (is_object($object)) {
switch ($object->getType()) {
case 'Follow':
$success = false;
if ($object instanceof \Federator\Data\ActivityPub\Common\Activity) {
$actor = $object->getAActor();
if ($actor !== '') {
$followerUsername = basename((string) (parse_url($actor, PHP_URL_PATH) ?? ''));
$followerDomain = parse_url($actor, PHP_URL_HOST);
if (is_string($followerDomain)) {
$followerId = "{$followerUsername}@{$followerDomain}";
$success = \Federator\DIO\Followers::removeFollow($dbh, $followerId, $user->id);
}
}
}
if ($success === false) {
error_log("NewContent::postForUser: Failed to remove follower for user $user->id");
}
break;
case 'Vote':
// Undo Vote (remove vote)
if (method_exists($object, 'getObject')) {
$targetId = $object->getObject();
if (is_string($targetId)) {
// \Federator\DIO\Votes::removeVote($dbh, $user->id, $targetId);
\Federator\DIO\Posts::deletePost($dbh, $targetId);
} else {
error_log("NewContent::postForUser: Error in Undo Vote for user $user->id, targetId is not a string");
}
}
break;
case 'Note':
// Undo Note (remove note)
if (method_exists($object, 'getID')) {
$noteId = $object->getID();
\Federator\DIO\Posts::deletePost($dbh, $noteId);
}
break;
}
} else if (is_string($object)) {
\Federator\DIO\Posts::deletePost($dbh, $object);
} else {
error_log("NewContent::postForUser: Error in Undo for user $user->id, object is not a string or object");
}
break;
case 'Like':
// Add Like
$targetId = $newActivity->getObject();
if (is_string($targetId)) {
// \Federator\DIO\Votes::addVote($dbh, $user->id, $targetId, 'like');
\Federator\DIO\Posts::savePost($dbh, $user->id, $newActivity);
} else {
error_log("NewContent::postForUser: Error in Add Like for user $user->id, targetId is not a string");
return false;
}
break;
case 'Dislike':
// Add Dislike
$targetId = $newActivity->getObject();
if (is_string($targetId)) {
// \Federator\DIO\Votes::addVote($dbh, $user->id, $targetId, 'dislike');
\Federator\DIO\Posts::savePost($dbh, $user->id, $newActivity);
} else {
error_log("NewContent::postForUser: Error in Add Dislike for user $user->id, targetId is not a string");
return false;
}
break;
case 'Create':
$object = $newActivity->getObject();
if (is_object($object)) {
switch ($object->getType()) {
case 'Note':
case 'Article':
default:
\Federator\DIO\Posts::savePost($dbh, $user->id, $newActivity);
break;
}
}
// Post Note
break;
default:
error_log("NewContent::postForUser: Unhandled activity type $type for user $user->id");
break;
}
return true;
}
/**
* fetch all followers from url and return the ones that belong to our server
*
* @param \mysqli $dbh
* database handle
* @param \Federator\Connector\Connector $connector
* connector to fetch use with
* @param \Federator\Cache\Cache|null $cache
* optional caching service
* @param string $userId The id of the user
* @return string[] the names of the followers that are hosted on our server
*/
private static function fetchAllFollowers($dbh, $connector, $cache, string $userId): array
{
if (empty($userId)) {
return [];
}
$users = [];
$apFollowers = \Federator\DIO\Followers::getFollowersByUser(
$dbh,
$userId,
$connector,
cache: $cache,
);
foreach ($apFollowers as $follower) {
$users[] = $follower->id;
}
return $users;
}
/**
* get internal represenation as json string
* @return string json string or html
*/
public function toJson()
{
return $this->response;
}
}