
- fix bug in which inReplyTo isn't correctly set from contentnation-comments - added dio/article which has functions to convert article to note based on new file - added formatsupport.json to manage special cases (f.e. includes which servers can handle articles)
552 lines
No EOL
22 KiB
PHP
552 lines
No EOL
22 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;
|
|
|
|
|
|
$dbh = $this->main->getDatabase();
|
|
$cache = $this->main->getCache();
|
|
$connector = $this->main->getConnector();
|
|
|
|
$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 = $connector->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;
|
|
}
|
|
if (!isset($_user)) {
|
|
$user = $newActivity->getAActor(); // url of the sender https://contentnation.net/username
|
|
$posterName = str_replace(
|
|
$domain,
|
|
'',
|
|
$user
|
|
); // retrieve only the last part of the url
|
|
} else {
|
|
$posterName = $dbh->real_escape_string($_user);
|
|
}
|
|
|
|
$users = [];
|
|
|
|
$receivers = array_merge($newActivity->getTo(), $newActivity->getCC());
|
|
|
|
// For Undo, the object may hold the proper to/cc
|
|
if ($newActivity->getType() === 'Undo') {
|
|
$object = $newActivity->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);
|
|
});
|
|
|
|
foreach ($receivers as $receiver) {
|
|
if ($receiver === '' || !is_string($receiver)) {
|
|
continue;
|
|
}
|
|
|
|
if (str_ends_with($receiver, '/followers')) {
|
|
|
|
if ($posterName === null) {
|
|
error_log("NewContent::post no username found");
|
|
continue;
|
|
}
|
|
try {
|
|
$followers = \Federator\DIO\Followers::getFollowersByUser($dbh, $posterName, $connector, $cache);
|
|
} catch (\Throwable $e) {
|
|
error_log("NewContent::post get followers for user: " . $posterName . ". Exception: " . $e->getMessage());
|
|
continue;
|
|
}
|
|
|
|
if (is_array($followers)) {
|
|
$users = array_merge($users, array_column($followers, 'id'));
|
|
}
|
|
} else {
|
|
// check if receiver is an actor url and not from our domain
|
|
if (str_contains($receiver, $domain)) {
|
|
continue;
|
|
}
|
|
$receiverName = basename((string) (parse_url($receiver, PHP_URL_PATH) ?? ''));
|
|
$domain = parse_url($receiver, PHP_URL_HOST);
|
|
if ($receiverName === null || $domain === null) {
|
|
if ($receiver === $posterName) {
|
|
continue;
|
|
}
|
|
error_log("NewContent::post no receiverName or domain found for receiver: " . $receiver);
|
|
continue;
|
|
}
|
|
$receiver = $receiverName . '@' . $domain;
|
|
try {
|
|
$user = \Federator\DIO\FedUser::getUserByName(
|
|
$dbh,
|
|
$receiver,
|
|
$cache
|
|
);
|
|
} catch (\Throwable $e) {
|
|
error_log("NewContent::post get user by name: " . $receiver . ". Exception: " . $e->getMessage());
|
|
continue;
|
|
}
|
|
if ($user === null || $user->id === null) {
|
|
error_log("NewContent::post couldn't find user: $receiver");
|
|
continue;
|
|
}
|
|
$users[] = $user->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/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
|
|
);
|
|
}
|
|
|
|
foreach ($users as $receiver) {
|
|
if (!isset($receiver)) {
|
|
continue;
|
|
}
|
|
|
|
$token = \Resque::enqueue('inbox', 'Federator\\Jobs\\NewContentJob', [
|
|
'user' => $posterName,
|
|
'recipientId' => $receiver,
|
|
'activity' => $newActivity->toObject(),
|
|
]);
|
|
error_log("Inbox::post enqueued job for receiver: $receiver 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 $host host url of our server (e.g. https://federator.com)
|
|
* @param string $_user user that triggered the post
|
|
* @param string $_recipientId recipient of the post
|
|
* @param \Federator\Data\ActivityPub\Common\Activity $newActivity the activity that we received
|
|
* @return boolean response
|
|
*/
|
|
public static function postForUser($dbh, $connector, $cache, $host, $_user, $_recipientId, $newActivity)
|
|
{
|
|
if (!isset($_user)) {
|
|
error_log("NewContent::postForUser no user given");
|
|
return false;
|
|
}
|
|
|
|
// get sender
|
|
$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;
|
|
}
|
|
|
|
// get recipient
|
|
$recipient = \Federator\DIO\FedUser::getUserByName(
|
|
$dbh,
|
|
$_recipientId,
|
|
$cache
|
|
);
|
|
if ($recipient === null || $recipient->id === null) {
|
|
error_log("NewContent::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/newcontent_' . $recipient->id . '.log',
|
|
date('Y-m-d H:i:s') . ": ==== POST " . $recipient->id . " 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);
|
|
$newIdUrl = \Federator\DIO\Followers::generateNewFollowId($dbh, $host);
|
|
$newActivity->setID($newIdUrl);
|
|
/* if (is_string($followerDomain)) {
|
|
$followerId = "{$followerUsername}@{$followerDomain}";
|
|
$success = \Federator\DIO\Followers::sendFollowRequest($dbh, $connector, $cache, $user->id, $followerId, $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}";
|
|
$removedId = \Federator\DIO\Followers::removeFollow($dbh, $followerId, $user->id);
|
|
if ($removedId !== false) {
|
|
$object->setID($removedId);
|
|
$newActivity->setObject($object);
|
|
$success = true;
|
|
} else {
|
|
error_log("NewContent::postForUser Failed to remove follow for user $user->id");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if ($success === false) {
|
|
error_log("NewContent::postForUser Failed to remove follower for user $user->id");
|
|
}
|
|
break;
|
|
case 'Like':
|
|
case 'Dislike':
|
|
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 Like/Dislike for user $user->id, targetId is not a string");
|
|
}
|
|
}
|
|
break;
|
|
case 'Note':
|
|
// Undo Note (remove note)
|
|
$noteId = $object->getID();
|
|
\Federator\DIO\Posts::deletePost($dbh, $noteId);
|
|
|
|
break;
|
|
case 'Article':
|
|
// Undo Article (remove article)
|
|
$idPart = strrchr($recipient->id, '@');
|
|
if ($idPart === false) {
|
|
error_log("NewContent::postForUser Error in Undo Article. $recipient->id, recipient ID is not valid");
|
|
return false;
|
|
} else {
|
|
$targetUrl = ltrim($idPart, '@');
|
|
|
|
if ($object instanceof \Federator\Data\ActivityPub\Common\Article) {
|
|
$object = \Federator\DIO\Article::conditionalConvertToNote($object, $targetUrl);
|
|
$newActivity->setObject($object);
|
|
} else {
|
|
error_log("NewContent::postForUser Error in Undo Article for recipient $recipient->id, object is not an Article");
|
|
}
|
|
}
|
|
$articleId = $object->getID();
|
|
\Federator\DIO\Posts::deletePost($dbh, $articleId);
|
|
// also remove latest saved article-update
|
|
\Federator\DIO\Posts::deletePost($dbh, $articleId . '#update');
|
|
|
|
break;
|
|
}
|
|
} else if (is_string($object)) {
|
|
\Federator\DIO\Posts::deletePost($dbh, $object);
|
|
} else {
|
|
error_log("NewContent::postForUser Error in Undo for recipient $recipient->id, object is not a string or object");
|
|
}
|
|
break;
|
|
|
|
case 'Like':
|
|
case 'Dislike':
|
|
// Add Like/Dislike
|
|
$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/Dislike for recipient $recipient->id, targetId is not a string");
|
|
return false;
|
|
}
|
|
break;
|
|
|
|
case 'Create':
|
|
case 'Update':
|
|
$object = $newActivity->getObject();
|
|
if (is_object($object)) {
|
|
switch ($object->getType()) {
|
|
case 'Note':
|
|
\Federator\DIO\Posts::savePost($dbh, $user->id, $newActivity);
|
|
|
|
break;
|
|
case 'Article':
|
|
$idPart = strrchr($recipient->id, '@');
|
|
if ($idPart === false) {
|
|
error_log("NewContent::postForUser Error in Create/Update Article. $recipient->id, recipient ID is not valid");
|
|
return false;
|
|
} else {
|
|
$targetUrl = ltrim($idPart, '@');
|
|
|
|
if ($object instanceof \Federator\Data\ActivityPub\Common\Article) {
|
|
$object = \Federator\DIO\Article::conditionalConvertToNote($object, $targetUrl);
|
|
$newActivity->setObject($object);
|
|
} else {
|
|
error_log("NewContent::postForUser Error in Create/Update Article for recipient $recipient->id, object is not an Article");
|
|
}
|
|
}
|
|
\Federator\DIO\Posts::savePost($dbh, $user->id, $newActivity);
|
|
|
|
break;
|
|
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;
|
|
}
|
|
|
|
try {
|
|
$response = self::sendActivity($dbh, $host, $user, $recipient, $newActivity);
|
|
} catch (\Exception $e) {
|
|
error_log("NewContent::postForUser Failed to send activity: " . $e->getMessage());
|
|
return false;
|
|
}
|
|
if (empty($response)) {
|
|
error_log("NewContent::postForUser Sent activity to $recipient->id");
|
|
} else {
|
|
error_log("NewContent::postForUser Sent activity to $recipient->id with response: " . json_encode($response, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT));
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* send activity to federated server
|
|
*
|
|
* @param \mysqli $dbh database handle
|
|
* @param string $host host url of our server (e.g. federator)
|
|
* @param \Federator\Data\User $sender source user
|
|
* @param \Federator\Data\FedUser $target federated target user
|
|
* @param \Federator\Data\ActivityPub\Common\Activity $activity activity to send
|
|
* @return string|true the generated follow ID on success, false on failure
|
|
*/
|
|
public static function sendActivity($dbh, $host, $sender, $target, $activity)
|
|
{
|
|
if ($dbh === false) {
|
|
throw new \Federator\Exceptions\ServerError("NewContent::sendActivity Failed to get database handle");
|
|
}
|
|
|
|
$inboxUrl = $target->inboxURL;
|
|
|
|
$json = json_encode($activity, JSON_UNESCAPED_SLASHES);
|
|
|
|
if ($json === false) {
|
|
throw new \Exception('Failed to encode JSON: ' . json_last_error_msg());
|
|
}
|
|
$digest = 'SHA-256=' . base64_encode(hash('sha256', $json, true));
|
|
$date = gmdate('D, d M Y H:i:s') . ' GMT';
|
|
$parsed = parse_url($inboxUrl);
|
|
if ($parsed === false) {
|
|
throw new \Exception('Failed to parse URL: ' . $inboxUrl);
|
|
}
|
|
|
|
if (!isset($parsed['host']) || !isset($parsed['path'])) {
|
|
throw new \Exception('Invalid inbox URL: missing host or path');
|
|
}
|
|
$extHost = $parsed['host'];
|
|
$path = $parsed['path'];
|
|
|
|
// Build the signature string
|
|
$signatureString = "(request-target): post {$path}\n" .
|
|
"host: {$extHost}\n" .
|
|
"date: {$date}\n" .
|
|
"digest: {$digest}";
|
|
|
|
// Get rsa private key
|
|
$privateKey = \Federator\DIO\User::getrsaprivate($dbh, $sender->id); // OR from DB
|
|
if ($privateKey === false) {
|
|
throw new \Exception('Failed to get private key');
|
|
}
|
|
$pkeyId = openssl_pkey_get_private($privateKey);
|
|
|
|
if ($pkeyId === false) {
|
|
throw new \Exception('Invalid private key');
|
|
}
|
|
|
|
openssl_sign($signatureString, $signature, $pkeyId, OPENSSL_ALGO_SHA256);
|
|
$signature_b64 = base64_encode($signature);
|
|
|
|
// Build keyId (public key ID from your actor object)
|
|
$keyId = $host . '/' . $sender->id . '#main-key';
|
|
|
|
$signatureHeader = 'keyId="' . $keyId . '",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="' . $signature_b64 . '"';
|
|
|
|
$ch = curl_init($inboxUrl);
|
|
if ($ch === false) {
|
|
throw new \Exception('Failed to initialize cURL');
|
|
}
|
|
$headers = [
|
|
'Host: ' . $extHost,
|
|
'Date: ' . $date,
|
|
'Digest: ' . $digest,
|
|
'Content-Type: application/activity+json',
|
|
'Signature: ' . $signatureHeader,
|
|
'Accept: application/activity+json',
|
|
];
|
|
|
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
|
curl_setopt($ch, CURLOPT_POST, true);
|
|
curl_setopt($ch, CURLOPT_POSTFIELDS, $json);
|
|
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
|
$response = curl_exec($ch);
|
|
curl_close($ch);
|
|
|
|
// Log the response for debugging if needed
|
|
if ($response === false) {
|
|
throw new \Exception("Failed to send activity: " . curl_error($ch));
|
|
} else {
|
|
$httpcode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
if ($httpcode != 200 && $httpcode != 202) {
|
|
throw new \Exception("Unexpected HTTP code $httpcode: $response");
|
|
}
|
|
}
|
|
return $response;
|
|
}
|
|
|
|
/**
|
|
* get internal represenation as json string
|
|
* @return string json string or html
|
|
*/
|
|
public function toJson()
|
|
{
|
|
return $this->response;
|
|
}
|
|
} |