forked from grumpydevelop/federator
major structure rework
- checkSignature for our fqdn now happens in the connector - cache public key for 1 hour - change author on some files - refactored outbox to api/v1/newcontent (CN->Federator communicates to here) - disabled post for outbox (as that should go to v1/newcontent) - initial support for receiving votes from CN, not fully working/clean yet
This commit is contained in:
parent
10a3b1e0f9
commit
8ea9bdcf9a
16 changed files with 500 additions and 205 deletions
|
@ -21,7 +21,5 @@ username = 'federatoradmin'
|
||||||
password = '*change*me*as*well'
|
password = '*change*me*as*well'
|
||||||
|
|
||||||
[keys]
|
[keys]
|
||||||
headerSenderName = 'contentnation'
|
|
||||||
contentnationPublicKeyPath = '../contentnation.pub'
|
|
||||||
federatorPrivateKeyPath = '../federator.key'
|
federatorPrivateKeyPath = '../federator.key'
|
||||||
federatorPublicKeyPath = '../federator.pub'
|
federatorPublicKeyPath = '../federator.pub'
|
|
@ -3,4 +3,8 @@ service-uri = http://local.contentnation.net
|
||||||
|
|
||||||
[userdata]
|
[userdata]
|
||||||
path = '/home/net/contentnation/userdata/htdocs/' // need to download local copy of image and put img-path here
|
path = '/home/net/contentnation/userdata/htdocs/' // need to download local copy of image and put img-path here
|
||||||
url = 'https://userdata.contentnation.net'
|
url = 'https://userdata.contentnation.net'
|
||||||
|
|
||||||
|
[keys]
|
||||||
|
headerSenderName = 'contentnation'
|
||||||
|
publicKeyPath = '../contentnation.pub'
|
|
@ -107,6 +107,9 @@ class Api extends Main
|
||||||
case 'dummy':
|
case 'dummy':
|
||||||
$handler = new Api\V1\Dummy($this);
|
$handler = new Api\V1\Dummy($this);
|
||||||
break;
|
break;
|
||||||
|
case 'newcontent':
|
||||||
|
$handler = new Api\V1\NewContent($this);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
@ -201,6 +204,15 @@ class Api extends Main
|
||||||
*/
|
*/
|
||||||
public function checkSignature($headers)
|
public function checkSignature($headers)
|
||||||
{
|
{
|
||||||
|
if (isset($headers['X-Sender'])) {
|
||||||
|
try {
|
||||||
|
return $this->connector->checkSignature($headers);
|
||||||
|
} catch (Exceptions\PermissionDenied $e) {
|
||||||
|
http_response_code(500);
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$signatureHeader = $headers['Signature'] ?? null;
|
$signatureHeader = $headers['Signature'] ?? null;
|
||||||
|
|
||||||
if (!isset($signatureHeader)) {
|
if (!isset($signatureHeader)) {
|
||||||
|
@ -213,12 +225,11 @@ class Api extends Main
|
||||||
|
|
||||||
$signature = base64_decode($signatureParts['signature']);
|
$signature = base64_decode($signatureParts['signature']);
|
||||||
$signedHeaders = explode(' ', $signatureParts['headers']);
|
$signedHeaders = explode(' ', $signatureParts['headers']);
|
||||||
if (isset($headers['X-Sender']) && $headers['X-Sender'] === $this->config['keys']['headerSenderName']) {
|
$keyId = $signatureParts['keyId'];
|
||||||
$pKeyPath = $_SERVER['DOCUMENT_ROOT'] . $this->config['keys']['contentnationPublicKeyPath'];
|
|
||||||
$publicKeyPem = file_get_contents($pKeyPath);
|
|
||||||
} else {
|
|
||||||
$keyId = $signatureParts['keyId'];
|
|
||||||
|
|
||||||
|
$publicKeyPem = $this->cache->getPublicKey($keyId);
|
||||||
|
|
||||||
|
if (!isset($publicKeyPem) || $publicKeyPem === false) {
|
||||||
// Fetch public key from `keyId` (usually actor URL + #main-key)
|
// Fetch public key from `keyId` (usually actor URL + #main-key)
|
||||||
[$publicKeyData, $info] = \Federator\Main::getFromRemote($keyId, ['Accept: application/activity+json']);
|
[$publicKeyData, $info] = \Federator\Main::getFromRemote($keyId, ['Accept: application/activity+json']);
|
||||||
|
|
||||||
|
@ -233,11 +244,14 @@ class Api extends Main
|
||||||
}
|
}
|
||||||
|
|
||||||
$publicKeyPem = $actor['publicKey']['publicKeyPem'] ?? null;
|
$publicKeyPem = $actor['publicKey']['publicKeyPem'] ?? null;
|
||||||
}
|
|
||||||
|
|
||||||
if (!isset($publicKeyPem) || $publicKeyPem === false) {
|
if (!isset($publicKeyPem) || $publicKeyPem === false) {
|
||||||
http_response_code(500);
|
http_response_code(500);
|
||||||
throw new Exceptions\PermissionDenied("Public key couldn't be determined");
|
throw new Exceptions\PermissionDenied("Public key couldn't be determined");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache the public key for 1 hour
|
||||||
|
$this->cache->savePublicKey($keyId, $publicKeyPem);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reconstruct the signed string
|
// Reconstruct the signed string
|
||||||
|
@ -264,7 +278,7 @@ class Api extends Main
|
||||||
}
|
}
|
||||||
if ($verified != 1) {
|
if ($verified != 1) {
|
||||||
http_response_code(500);
|
http_response_code(500);
|
||||||
throw new Exceptions\PermissionDenied("Signature verification failed for publicKey");
|
throw new Exceptions\PermissionDenied("Signature verification failed");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Signature is valid!
|
// Signature is valid!
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
* SPDX-FileCopyrightText: 2024 Sascha Nitsch (grumpydeveloper) https://contentnation.net/@grumpydevelop
|
* SPDX-FileCopyrightText: 2024 Sascha Nitsch (grumpydeveloper) https://contentnation.net/@grumpydevelop
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
*
|
*
|
||||||
* @author Sascha Nitsch (grumpydeveloper)
|
* @author Yannis Vogel (vogeldevelopment)
|
||||||
**/
|
**/
|
||||||
|
|
||||||
namespace Federator\Api\FedUsers;
|
namespace Federator\Api\FedUsers;
|
||||||
|
|
|
@ -90,182 +90,11 @@ class Outbox implements \Federator\Api\FedUsers\FedUsersInterface
|
||||||
/**
|
/**
|
||||||
* handle post call
|
* handle post call
|
||||||
*
|
*
|
||||||
* @param string|null $_user user to add data to outbox
|
* @param string|null $_user user to add data to outbox @unused-param
|
||||||
* @return string|false response
|
* @return string|false response
|
||||||
*/
|
*/
|
||||||
public function post($_user)
|
public function post($_user)
|
||||||
{
|
{
|
||||||
$_rawInput = file_get_contents('php://input');
|
return false;
|
||||||
|
|
||||||
$allHeaders = getallheaders();
|
|
||||||
try {
|
|
||||||
$this->main->checkSignature($allHeaders);
|
|
||||||
} catch (\Federator\Exceptions\PermissionDenied $e) {
|
|
||||||
error_log("Outbox::post Signature check failed: " . $e->getMessage());
|
|
||||||
http_response_code(401);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$input = is_string($_rawInput) ? json_decode($_rawInput, true) : null;
|
|
||||||
$host = $_SERVER['SERVER_NAME'];
|
|
||||||
if (!is_array($input)) {
|
|
||||||
error_log("Outbox::post Input wasn't of type array");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isset($allHeaders['X-Sender']) && $allHeaders['X-Sender'] === $this->main->getConfig()['keys']['headerSenderName']) {
|
|
||||||
$outboxActivity = $this->main->getConnector()->jsonToActivity($input);
|
|
||||||
} else {
|
|
||||||
$outboxActivity = \Federator\Data\ActivityPub\Factory::newActivityFromJson($input);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($outboxActivity === false) {
|
|
||||||
error_log("Outbox::post couldn't create outboxActivity");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$sendTo = $outboxActivity->getCC();
|
|
||||||
if ($outboxActivity->getType() === 'Undo') {
|
|
||||||
$object = $outboxActivity->getObject();
|
|
||||||
if ($object !== null) {
|
|
||||||
$sendTo = $object->getCC();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$users = [];
|
|
||||||
|
|
||||||
foreach ($sendTo as $receiver) {
|
|
||||||
if ($receiver === '' || !is_string($receiver)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (str_ends_with($receiver, '/followers')) {
|
|
||||||
$followers = $this->fetchAllFollowers($receiver, $host);
|
|
||||||
if (is_array($followers)) {
|
|
||||||
$users = array_merge($users, $followers);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (empty($users)) { // todo remove, debugging for now
|
|
||||||
$rootDir = $_SERVER['DOCUMENT_ROOT'] . '../';
|
|
||||||
// Save the raw input and parsed JSON to a file for inspection
|
|
||||||
file_put_contents(
|
|
||||||
$rootDir . 'logs/outbox.log',
|
|
||||||
date('Y-m-d H:i:s') . ": ==== POST Outbox Activity ====\n" . json_encode($outboxActivity, 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->postForUser($user, $outboxActivity);
|
|
||||||
}
|
|
||||||
|
|
||||||
return json_encode($outboxActivity, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* handle post call for specific user
|
|
||||||
*
|
|
||||||
* @param string $_user user to add data to outbox
|
|
||||||
* @param \Federator\Data\ActivityPub\Common\Activity $outboxActivity the activity that we received
|
|
||||||
* @return boolean response
|
|
||||||
*/
|
|
||||||
private function postForUser($_user, $outboxActivity)
|
|
||||||
{
|
|
||||||
if (isset($_user)) {
|
|
||||||
$dbh = $this->main->getDatabase();
|
|
||||||
$cache = $this->main->getCache();
|
|
||||||
$connector = $this->main->getConnector();
|
|
||||||
|
|
||||||
// get user
|
|
||||||
$user = \Federator\DIO\User::getUserByName(
|
|
||||||
$dbh,
|
|
||||||
$_user,
|
|
||||||
$connector,
|
|
||||||
$cache
|
|
||||||
);
|
|
||||||
if ($user->id === null) {
|
|
||||||
error_log("Outbox::postForUser couldn't find user: $_user");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$rootDir = $_SERVER['DOCUMENT_ROOT'] . '../';
|
|
||||||
// Save the raw input and parsed JSON to a file for inspection
|
|
||||||
file_put_contents(
|
|
||||||
$rootDir . 'logs/outbox_' . $_user . '.log',
|
|
||||||
date('Y-m-d H:i:s') . ": ==== POST " . $_user . " Outbox Activity ====\n" . json_encode($outboxActivity, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) . "\n\n",
|
|
||||||
FILE_APPEND
|
|
||||||
);
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* fetch all followers from url and return the ones that belong to our server
|
|
||||||
*
|
|
||||||
* @param string $collectionUrl The url of f.e. the posters followers
|
|
||||||
* @param string $host our current host-url
|
|
||||||
* @return string[] the names of the followers that are hosted on our server
|
|
||||||
*/
|
|
||||||
private static function fetchAllFollowers(string $collectionUrl, string $host): array
|
|
||||||
{
|
|
||||||
$users = [];
|
|
||||||
|
|
||||||
[$collectionResponse, $collectionInfo] = \Federator\Main::getFromRemote($collectionUrl, ['Accept: application/activity+json']);
|
|
||||||
if ($collectionInfo['http_code'] != 200) {
|
|
||||||
error_log("Outbox::fetchAllFollowers Failed to fetch follower collection metadata from $collectionUrl");
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$collectionData = json_decode($collectionResponse, true);
|
|
||||||
$nextPage = $collectionData['first'] ?? $collectionData['current'] ?? null;
|
|
||||||
|
|
||||||
if (!isset($nextPage)) {
|
|
||||||
error_log("Outbox::fetchAllFollowers No 'first' or 'current' page in collection at $collectionUrl");
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Loop through all pages
|
|
||||||
while ($nextPage) {
|
|
||||||
[$pageResponse, $pageInfo] = \Federator\Main::getFromRemote($nextPage, ['Accept: application/activity+json']);
|
|
||||||
if ($pageInfo['http_code'] != 200) {
|
|
||||||
error_log("Outbox::fetchAllFollowers Failed to fetch follower page at $nextPage");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
$pageData = json_decode($pageResponse, true);
|
|
||||||
$items = $pageData['orderedItems'] ?? $pageData['items'] ?? [];
|
|
||||||
|
|
||||||
foreach ($items as $followerUrl) {
|
|
||||||
$parts = parse_url($followerUrl);
|
|
||||||
if (!isset($parts['host']) || !str_ends_with($parts['host'], $host)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
[$actorResponse, $actorInfo] = \Federator\Main::getFromRemote($followerUrl, ['Accept: application/activity+json']);
|
|
||||||
if ($actorInfo['http_code'] != 200) {
|
|
||||||
error_log("Outbox::fetchAllFollowers Failed to fetch actor data for follower: $followerUrl");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$actorData = json_decode($actorResponse, true);
|
|
||||||
if (isset($actorData['preferredUsername'])) {
|
|
||||||
$users[] = $actorData['preferredUsername'];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$nextPage = $pageData['next'] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $users;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
262
php/federator/api/v1/newcontent.php
Normal file
262
php/federator/api/v1/newcontent.php
Normal file
|
@ -0,0 +1,262 @@
|
||||||
|
<?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 user that triggered the post
|
||||||
|
* @return string|false response
|
||||||
|
*/
|
||||||
|
public function post($_user)
|
||||||
|
{
|
||||||
|
error_log("NewContent::post called with user: $_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;
|
||||||
|
$host = $_SERVER['SERVER_NAME'];
|
||||||
|
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 {
|
||||||
|
$newActivity = \Federator\Data\ActivityPub\Factory::newActivityFromJson($input);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($newActivity === false) {
|
||||||
|
error_log("NewContent::post couldn't create newActivity");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sendTo = $newActivity->getCC();
|
||||||
|
if ($newActivity->getType() === 'Undo') {
|
||||||
|
$object = $newActivity->getObject();
|
||||||
|
if ($object !== null) {
|
||||||
|
$sendTo = $object->getCC();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$users = [];
|
||||||
|
|
||||||
|
foreach ($sendTo as $receiver) {
|
||||||
|
if ($receiver === '' || !is_string($receiver)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_ends_with($receiver, '/followers')) {
|
||||||
|
$followers = $this->fetchAllFollowers($receiver, $host);
|
||||||
|
if (is_array($followers)) {
|
||||||
|
$users = array_merge($users, $followers);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($users)) { // todo remove, debugging for now
|
||||||
|
$rootDir = $_SERVER['DOCUMENT_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;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->postForUser($user, $newActivity);
|
||||||
|
}
|
||||||
|
|
||||||
|
return json_encode($newActivity, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* handle post call for specific user
|
||||||
|
*
|
||||||
|
* @param string $_user user that triggered the post
|
||||||
|
* @param \Federator\Data\ActivityPub\Common\Activity $newActivity the activity that we received
|
||||||
|
* @return boolean response
|
||||||
|
*/
|
||||||
|
private function postForUser($_user, $newActivity)
|
||||||
|
{
|
||||||
|
if (isset($_user)) {
|
||||||
|
$dbh = $this->main->getDatabase();
|
||||||
|
$cache = $this->main->getCache();
|
||||||
|
$connector = $this->main->getConnector();
|
||||||
|
|
||||||
|
// get user
|
||||||
|
$user = \Federator\DIO\User::getUserByName(
|
||||||
|
$dbh,
|
||||||
|
$_user,
|
||||||
|
$connector,
|
||||||
|
$cache
|
||||||
|
);
|
||||||
|
if ($user->id === null) {
|
||||||
|
error_log("NewContent::postForUser couldn't find user: $_user");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$rootDir = $_SERVER['DOCUMENT_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
|
||||||
|
);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* fetch all followers from url and return the ones that belong to our server
|
||||||
|
*
|
||||||
|
* @param string $collectionUrl The url of f.e. the posters followers
|
||||||
|
* @param string $host our current host-url
|
||||||
|
* @return string[] the names of the followers that are hosted on our server
|
||||||
|
*/
|
||||||
|
private static function fetchAllFollowers(string $collectionUrl, string $host): array
|
||||||
|
{
|
||||||
|
$users = [];
|
||||||
|
|
||||||
|
[$collectionResponse, $collectionInfo] = \Federator\Main::getFromRemote($collectionUrl, ['Accept: application/activity+json']);
|
||||||
|
if ($collectionInfo['http_code'] != 200) {
|
||||||
|
error_log("NewContent::fetchAllFollowers Failed to fetch follower collection metadata from $collectionUrl");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$collectionData = json_decode($collectionResponse, true);
|
||||||
|
$nextPage = $collectionData['first'] ?? $collectionData['current'] ?? null;
|
||||||
|
|
||||||
|
if (!isset($nextPage)) {
|
||||||
|
error_log("NewContent::fetchAllFollowers No 'first' or 'current' page in collection at $collectionUrl");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loop through all pages
|
||||||
|
while ($nextPage) {
|
||||||
|
[$pageResponse, $pageInfo] = \Federator\Main::getFromRemote($nextPage, ['Accept: application/activity+json']);
|
||||||
|
if ($pageInfo['http_code'] != 200) {
|
||||||
|
error_log("NewContent::fetchAllFollowers Failed to fetch follower page at $nextPage");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pageData = json_decode($pageResponse, true);
|
||||||
|
$items = $pageData['orderedItems'] ?? $pageData['items'] ?? [];
|
||||||
|
|
||||||
|
foreach ($items as $followerUrl) {
|
||||||
|
$parts = parse_url($followerUrl);
|
||||||
|
if (!isset($parts['host']) || !str_ends_with($parts['host'], $host)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
[$actorResponse, $actorInfo] = \Federator\Main::getFromRemote($followerUrl, ['Accept: application/activity+json']);
|
||||||
|
if ($actorInfo['http_code'] != 200) {
|
||||||
|
error_log("NewContent::fetchAllFollowers Failed to fetch actor data for follower: $followerUrl");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$actorData = json_decode($actorResponse, true);
|
||||||
|
if (isset($actorData['preferredUsername'])) {
|
||||||
|
$users[] = $actorData['preferredUsername'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$nextPage = $pageData['next'] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $users;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* get internal represenation as json string
|
||||||
|
* @return string json string or html
|
||||||
|
*/
|
||||||
|
public function toJson()
|
||||||
|
{
|
||||||
|
return $this->response;
|
||||||
|
}
|
||||||
|
}
|
17
php/federator/cache/cache.php
vendored
17
php/federator/cache/cache.php
vendored
|
@ -57,4 +57,21 @@ interface Cache extends \Federator\Connector\Connector
|
||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
public function saveRemoteUserBySession($_session, $_user, $user);
|
public function saveRemoteUserBySession($_session, $_user, $user);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save the public key for a given keyId
|
||||||
|
*
|
||||||
|
* @param string $keyId The keyId (e.g., actor URL + #main-key)
|
||||||
|
* @param string $publicKeyPem The public key PEM to cache
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function savePublicKey(string $keyId, string $publicKeyPem);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve the public key for a given keyId
|
||||||
|
*
|
||||||
|
* @param string $keyId The keyId (e.g., actor URL + #main-key)
|
||||||
|
* @return string|false The cached public key PEM or false if not found
|
||||||
|
*/
|
||||||
|
public function getPublicKey(string $keyId);
|
||||||
}
|
}
|
||||||
|
|
|
@ -64,4 +64,13 @@ interface Connector
|
||||||
* @return \Federator\Data\ActivityPub\Common\Activity|false
|
* @return \Federator\Data\ActivityPub\Common\Activity|false
|
||||||
*/
|
*/
|
||||||
public function jsonToActivity(array $jsonData);
|
public function jsonToActivity(array $jsonData);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* check if the headers include a valid signature
|
||||||
|
*
|
||||||
|
* @param string[] $headers the headers
|
||||||
|
* @throws \Federator\Exceptions\PermissionDenied
|
||||||
|
* @return string|\Federator\Exceptions\PermissionDenied
|
||||||
|
*/
|
||||||
|
public function checkSignature($headers);
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
* SPDX-FileCopyrightText: 2024 Sascha Nitsch (grumpydeveloper) https://contentnation.net/@grumpydevelop
|
* SPDX-FileCopyrightText: 2024 Sascha Nitsch (grumpydeveloper) https://contentnation.net/@grumpydevelop
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
*
|
*
|
||||||
* @author Sascha Nitsch (grumpydeveloper)
|
* @author Yannis Vogel (vogeldevelopment)
|
||||||
**/
|
**/
|
||||||
|
|
||||||
namespace Federator\Data\ActivityPub\Common;
|
namespace Federator\Data\ActivityPub\Common;
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
* SPDX-FileCopyrightText: 2024 Sascha Nitsch (grumpydeveloper) https://contentnation.net/@grumpydevelop
|
* SPDX-FileCopyrightText: 2024 Sascha Nitsch (grumpydeveloper) https://contentnation.net/@grumpydevelop
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
*
|
*
|
||||||
* @author Sascha Nitsch (grumpydeveloper)
|
* @author Yannis Vogel (vogeldevelopment)
|
||||||
**/
|
**/
|
||||||
|
|
||||||
namespace Federator\Data\ActivityPub\Common;
|
namespace Federator\Data\ActivityPub\Common;
|
||||||
|
|
|
@ -4,6 +4,7 @@
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
*
|
*
|
||||||
* @author Sascha Nitsch (grumpydeveloper)
|
* @author Sascha Nitsch (grumpydeveloper)
|
||||||
|
* @author Yannis Vogel (vogeldevelopment)
|
||||||
**/
|
**/
|
||||||
|
|
||||||
namespace Federator\Data\ActivityPub\Common;
|
namespace Federator\Data\ActivityPub\Common;
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
* SPDX-FileCopyrightText: 2024 Sascha Nitsch (grumpydeveloper) https://contentnation.net/@grumpydevelop
|
* SPDX-FileCopyrightText: 2024 Sascha Nitsch (grumpydeveloper) https://contentnation.net/@grumpydevelop
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
*
|
*
|
||||||
* @author Sascha Nitsch (grumpydeveloper)
|
* @author Yannis Vogel (vogeldevelopment)
|
||||||
**/
|
**/
|
||||||
|
|
||||||
namespace Federator\DIO;
|
namespace Federator\DIO;
|
||||||
|
|
|
@ -338,30 +338,128 @@ class ContentNation implements Connector
|
||||||
*/
|
*/
|
||||||
public function jsonToActivity(array $jsonData)
|
public function jsonToActivity(array $jsonData)
|
||||||
{
|
{
|
||||||
|
// Common fields for all activity types
|
||||||
$ap = [
|
$ap = [
|
||||||
'@context' => 'https://www.w3.org/ns/activitystreams',
|
'@context' => 'https://www.w3.org/ns/activitystreams',
|
||||||
'type' => 'Create',
|
'type' => 'Create', // Default to 'Create'
|
||||||
'id' => $jsonData['id'] ?? null,
|
'id' => $jsonData['id'] ?? null,
|
||||||
'actor' => $jsonData['actor']['id'] ?? null,
|
'actor' => $jsonData['actor'] ?? null,
|
||||||
'published' => $jsonData['object']['published'] ?? null,
|
'published' => $jsonData['object']['published'] ?? null,
|
||||||
'to' => ['https://www.w3.org/ns/activitystreams#Public'],
|
'to' => ['https://www.w3.org/ns/activitystreams#Public'],
|
||||||
'cc' => [$jsonData['related']['cc']['followers'] ?? null],
|
'cc' => [$jsonData['related']['cc']['followers'] ?? null],
|
||||||
'object' => [
|
|
||||||
'type' => 'Note',
|
|
||||||
'id' => $jsonData['object']['id'] ?? null,
|
|
||||||
'summary' => $jsonData['object']['summary'] ?? '',
|
|
||||||
'content' => $jsonData['object']['content'] ?? '',
|
|
||||||
'published' => $jsonData['object']['published'] ?? null,
|
|
||||||
'attributedTo' => $jsonData['actor']['id'] ?? null,
|
|
||||||
'to' => ['https://www.w3.org/ns/activitystreams#Public'],
|
|
||||||
'cc' => [$jsonData['related']['cc']['followers'] ?? null],
|
|
||||||
'url' => $jsonData['object']['url'] ?? null,
|
|
||||||
'inReplyTo' => $jsonData['related']['article']['id'] ?? null,
|
|
||||||
],
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Handle specific fields based on the type
|
||||||
|
switch ($jsonData['type']) {
|
||||||
|
case 'comment':
|
||||||
|
$ap['object'] = [
|
||||||
|
'type' => 'Note',
|
||||||
|
'id' => $jsonData['object']['id'] ?? null,
|
||||||
|
'summary' => $jsonData['object']['summary'] ?? '',
|
||||||
|
'content' => $jsonData['object']['content'] ?? '',
|
||||||
|
'published' => $jsonData['object']['published'] ?? null,
|
||||||
|
'attributedTo' => $jsonData['actor']['id'] ?? null,
|
||||||
|
'to' => ['https://www.w3.org/ns/activitystreams#Public'],
|
||||||
|
'cc' => [$jsonData['related']['cc']['followers'] ?? null],
|
||||||
|
'url' => $jsonData['object']['url'] ?? null,
|
||||||
|
'inReplyTo' => $jsonData['related']['article']['id'] ?? null,
|
||||||
|
];
|
||||||
|
break;
|
||||||
|
|
||||||
|
// todo fix this to properly handle votes, data is mocked for now
|
||||||
|
case 'vote':
|
||||||
|
// Handle voting on a comment or an article
|
||||||
|
if ($jsonData['object']['type'] === 'Comment') {
|
||||||
|
$jsonData['object']['type'] = 'Note';
|
||||||
|
}
|
||||||
|
$ap['object'] = [
|
||||||
|
'id' => $jsonData['object']['id'] ?? null,
|
||||||
|
'type' => $jsonData['object']['type'] ?? 'Article',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Include additional fields if voting on an article
|
||||||
|
if ($ap['object']['type'] === 'Article') {
|
||||||
|
$ap['object']['name'] = $jsonData['object']['name'] ?? null;
|
||||||
|
$ap['object']['author'] = $jsonData['object']['author'] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add vote-specific fields
|
||||||
|
$ap['vote'] = [
|
||||||
|
'value' => $jsonData['vote']['value'] ?? null,
|
||||||
|
'type' => $jsonData['vote']['type'] ?? null,
|
||||||
|
];
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Handle unsupported types or fallback to default behavior
|
||||||
|
throw new \InvalidArgumentException("Unsupported activity type: {$jsonData['type']}");
|
||||||
|
}
|
||||||
|
|
||||||
return \Federator\Data\ActivityPub\Factory::newActivityFromJson($ap);
|
return \Federator\Data\ActivityPub\Factory::newActivityFromJson($ap);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* check if the headers include a valid signature
|
||||||
|
*
|
||||||
|
* @param string[] $headers the headers
|
||||||
|
* @throws \Federator\Exceptions\PermissionDenied
|
||||||
|
* @return string|\Federator\Exceptions\PermissionDenied
|
||||||
|
*/
|
||||||
|
public function checkSignature($headers)
|
||||||
|
{
|
||||||
|
$signatureHeader = $headers['Signature'] ?? null;
|
||||||
|
|
||||||
|
if (!isset($signatureHeader)) {
|
||||||
|
throw new \Federator\Exceptions\PermissionDenied("Missing Signature header");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isset($headers['X-Sender']) || $headers['X-Sender'] !== $this->config['keys']['headerSenderName'])
|
||||||
|
{
|
||||||
|
throw new \Federator\Exceptions\PermissionDenied("Invalid sender name");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse Signature header
|
||||||
|
preg_match_all('/(\w+)=["\']?([^"\',]+)["\']?/', $signatureHeader, $matches);
|
||||||
|
$signatureParts = array_combine($matches[1], $matches[2]);
|
||||||
|
|
||||||
|
$signature = base64_decode($signatureParts['signature']);
|
||||||
|
$signedHeaders = explode(' ', $signatureParts['headers']);
|
||||||
|
|
||||||
|
$pKeyPath = $_SERVER['DOCUMENT_ROOT'] . $this->config['keys']['publicKeyPath'];
|
||||||
|
$publicKeyPem = file_get_contents($pKeyPath);
|
||||||
|
if ($publicKeyPem === false) {
|
||||||
|
http_response_code(500);
|
||||||
|
throw new \Federator\Exceptions\PermissionDenied("Public key couldn't be determined");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconstruct the signed string
|
||||||
|
$signedString = '';
|
||||||
|
foreach ($signedHeaders as $header) {
|
||||||
|
if ($header === '(request-target)') {
|
||||||
|
$method = strtolower($_SERVER['REQUEST_METHOD']);
|
||||||
|
$path = $_SERVER['REQUEST_URI'];
|
||||||
|
$headerValue = "$method $path";
|
||||||
|
} else {
|
||||||
|
$headerValue = $headers[ucwords($header, '-')] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$signedString .= strtolower($header) . ": " . $headerValue . "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
$signedString = rtrim($signedString);
|
||||||
|
|
||||||
|
// Verify the signature
|
||||||
|
$pubkeyRes = openssl_pkey_get_public($publicKeyPem);
|
||||||
|
$verified = false;
|
||||||
|
if ($pubkeyRes instanceof \OpenSSLAsymmetricKey && is_string($signature)) {
|
||||||
|
$verified = openssl_verify($signedString, $signature, $pubkeyRes, OPENSSL_ALGO_SHA256);
|
||||||
|
}
|
||||||
|
if ($verified != 1) {
|
||||||
|
http_response_code(500);
|
||||||
|
throw new \Federator\Exceptions\PermissionDenied("Signature verification failed");
|
||||||
|
}
|
||||||
|
return "Signature verified.";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
namespace Federator;
|
namespace Federator;
|
||||||
|
|
|
@ -95,6 +95,18 @@ class DummyConnector implements Connector
|
||||||
$user->session = $_session;
|
$user->session = $_session;
|
||||||
return $user;
|
return $user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* check if the headers include a valid signature
|
||||||
|
*
|
||||||
|
* @param string[] $headers the headers @unused-param
|
||||||
|
* @throws \Federator\Exceptions\PermissionDenied
|
||||||
|
* @return string|\Federator\Exceptions\PermissionDenied
|
||||||
|
*/
|
||||||
|
public function checkSignature($headers)
|
||||||
|
{
|
||||||
|
return new \Federator\Exceptions\PermissionDenied("Dummy connector: no signature check");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
namespace Federator;
|
namespace Federator;
|
||||||
|
|
|
@ -41,6 +41,13 @@ class RedisCache implements Cache
|
||||||
*/
|
*/
|
||||||
private $userTTL;
|
private $userTTL;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* public key cache time to live in secods
|
||||||
|
*
|
||||||
|
* @var int $publicKeyPemTTL
|
||||||
|
*/
|
||||||
|
private $publicKeyPemTTL;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* constructor
|
* constructor
|
||||||
*/
|
*/
|
||||||
|
@ -50,6 +57,7 @@ class RedisCache implements Cache
|
||||||
if ($config !== false) {
|
if ($config !== false) {
|
||||||
$this->config = $config;
|
$this->config = $config;
|
||||||
$this->userTTL = array_key_exists('userttl', $config) ? intval($config['userttl'], 10) : 60;
|
$this->userTTL = array_key_exists('userttl', $config) ? intval($config['userttl'], 10) : 60;
|
||||||
|
$this->publicKeyPemTTL = array_key_exists('publickeypemttl', $config) ? intval($config['publickeypemttl'], 10) : 3600;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -176,6 +184,21 @@ class RedisCache implements Cache
|
||||||
return $user;
|
return $user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve the public key for a given keyId
|
||||||
|
*
|
||||||
|
* @param string $keyId The keyId (e.g., actor URL + #main-key)
|
||||||
|
* @return string|false The cached public key PEM or false if not found
|
||||||
|
*/
|
||||||
|
public function getPublicKey(string $keyId)
|
||||||
|
{
|
||||||
|
if (!$this->connected) {
|
||||||
|
$this->connect();
|
||||||
|
}
|
||||||
|
$key = self::createKey('publickey', $keyId);
|
||||||
|
return $this->redis->get($key);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* save remote followers by user
|
* save remote followers by user
|
||||||
*
|
*
|
||||||
|
@ -243,6 +266,34 @@ class RedisCache implements Cache
|
||||||
$serialized = $user->toJson();
|
$serialized = $user->toJson();
|
||||||
$this->redis->setEx($key, $this->userTTL, $serialized,);
|
$this->redis->setEx($key, $this->userTTL, $serialized,);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save the public key for a given keyId
|
||||||
|
*
|
||||||
|
* @param string $keyId The keyId (e.g., actor URL + #main-key)
|
||||||
|
* @param string $publicKeyPem The public key PEM to cache
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function savePublicKey(string $keyId, string $publicKeyPem)
|
||||||
|
{
|
||||||
|
if (!$this->connected) {
|
||||||
|
$this->connect();
|
||||||
|
}
|
||||||
|
$key = self::createKey('publickey', $keyId);
|
||||||
|
$this->redis->setEx($key, $this->publicKeyPemTTL, $publicKeyPem); // TTL = 1 hour
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* check if the headers include a valid signature
|
||||||
|
*
|
||||||
|
* @param string[] $headers the headers @unused-param
|
||||||
|
* @throws \Federator\Exceptions\PermissionDenied
|
||||||
|
* @return string|\Federator\Exceptions\PermissionDenied
|
||||||
|
*/
|
||||||
|
public function checkSignature($headers)
|
||||||
|
{
|
||||||
|
return new \Federator\Exceptions\PermissionDenied("RedisCache: no signature check");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
namespace Federator;
|
namespace Federator;
|
||||||
|
|
|
@ -2,6 +2,6 @@
|
||||||
host = localhost
|
host = localhost
|
||||||
port = 6379
|
port = 6379
|
||||||
username = federator
|
username = federator
|
||||||
password = redis*change*password
|
|
||||||
userttl = 10
|
userttl = 10
|
||||||
|
publickeypemttl = 3600
|
||||||
statsttl = 60
|
statsttl = 60
|
||||||
|
|
Loading…
Add table
Reference in a new issue