Compare commits

..

No commits in common. "mastodon-support" and "develop" have entirely different histories.

67 changed files with 265 additions and 5011 deletions

16
.gitattributes vendored
View file

@ -1,16 +0,0 @@
# Set the default behavior, in case people don't have core.autocrlf set.
* text=auto
# Explicitly declare text files you want to always be normalized and converted
# to native line endings on checkout.
*.c text
*.h text
*.php text
# Declare files that will always have LF line endings on checkout.
*.sln text eol=lf
*.php text eol.lf
# Denote all files that are truly binary and should not be modified.
*.png binary
*.jpg binary

1
.gitignore vendored
View file

@ -5,4 +5,3 @@ php-docs
.phpdoc
phpdoc
html
/cache

View file

@ -359,7 +359,6 @@ return [
'directory_list' => [
'vendor/phan/phan/src/Phan',
'vendor/smarty/smarty/src',
'vendor/resque/php-resque/lib',
'php/',
'plugins',
'htdocs',
@ -368,7 +367,5 @@ return [
// A list of individual files to include in analysis
// with a path relative to the root directory of the
// project.
'file_list' => [
'phan-stubs.php',
],
'file_list' => [],
];

View file

@ -35,7 +35,7 @@ If the include redis cache is enabled,
- install redis
- create a users.acl for redis with the content:
user federator on ~u_* +get +set ~s_* +get +setex ~m_* +get +setex ~publickey_* +get +setex >redis*change*password
user federator on ~u_* +get +set ~s_* +get +setex ~m_* +get +setex >redis*change*password
- change password in the rediscache.ini to match your given password.
- install the redis plugin from pecl if not provided via your distro
@ -52,11 +52,9 @@ To configure an apache server, add the following rewrite rules:
RewriteCond expr "%{HTTP:content-type} -strcmatch '*application/activity+json*'"
RewriteRule ^@(.*)$ /federator.php?_call=fedusers/$1 [L,END]
RewriteRule ^users/(.*)$ /federator.php?_call=fedusers/$1 [L,END]
RewriteRule ^inbox[/]?$ /federator.php?_call=fedusers/inbox [L,END]
RewriteRule ^api/federator/(.+)$ /federator.php?_call=$1 [L,END]
RewriteRule ^api/federator/(.+)$ federator.php?_call=$1 [L,END]
RewriteRule ^(\.well-known/.*)$ /federator.php?_call=$1 [L,END]
RewriteRule ^(nodeinfo/2\.[01])$ /federator.php?_call=$1 [L,END]
RewriteRule ^([a-zA-Z0-9_-]+.*)$ /federator.php?_call=fedusers/$1 [L,END]
</Directory>
change your document root for the domain you want to use (or default one if using localhost) to the directory you installed it, with the /htdocs at the end. A user should only be able to open that file, not the other data.

View file

@ -3,8 +3,7 @@
"description": "A federation service",
"type": "project",
"require": {
"smarty/smarty": "^5.3",
"resque/php-resque": "^1.3.6"
"smarty/smarty": "^5.3"
},
"license": "GPL-3.0-or-later",
"authors": [
@ -15,10 +14,5 @@
],
"require-dev": {
"phan/phan": "^5.4"
},
"autoload": {
"psr-4": {
"Federator\\": "php/federator/"
}
}
}

View file

@ -1,5 +1,5 @@
[generic]
externaldomain = 'contentnation.net'
externaldomain = 'your.fqdn'
[database]
host = '127.0.0.1'
@ -8,18 +8,13 @@ password = '*change*me*'
database = 'federator'
[templates]
path = 'templates/federator/'
compiledir = 'cache'
path = '../templates/federator/'
compiledir = '../cache'
[plugins]
rediscache = 'rediscache.php'
# dummy = 'dummyconnector.php'
contentnation = 'contentnation.php'
dummy = 'dummyconnector.php'
[maintenance]
username = 'federatoradmin'
password = '*change*me*as*well'
[keys]
federatorPrivateKeyPath = 'federator.key'
federatorPublicKeyPath = 'federator.pub'
password = '*change*me*as*well'

View file

@ -1,10 +0,0 @@
[contentnation]
service-uri = http://local.contentnation.net
[userdata]
path = '/home/net/contentnation/userdata/htdocs/' // need to download local copy of image and put img-path here
url = 'https://userdata.contentnation.net'
[keys]
headerSenderName = 'contentnation'
publicKeyPath = 'contentnation.pub'

View file

@ -1,8 +0,0 @@
{
"activitypub": {
"article": [
"localhost",
"writefreely.org"
]
}
}

View file

@ -14,7 +14,6 @@ date_default_timezone_set("Europe/Berlin");
spl_autoload_register(static function (string $className) {
include '../php/' . str_replace("\\", "/", strtolower($className)) . '.php';
});
define('PROJECT_ROOT', dirname(__DIR__, 1));
/// main instance
$contentnation = new \Federator\Api();

View file

@ -1,113 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>API Request UI</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="flex justify-center items-center min-h-screen bg-gray-100">
<div class="w-full max-w-3xl bg-white shadow-lg rounded-lg p-6 m-6">
<h2 class="text-2xl font-semibold mb-4 text-center">API Request UI</h2>
<div id="request-container">
<!-- Request Form Template -->
<div class="request-box border p-4 rounded-lg mb-4 bg-gray-50 overflow-y-auto">
<label class="block font-medium">API target link</label>
<input type="text" class="target-link-input w-full p-2 border rounded-md mb-2"
placeholder="Enter target link" value="users/grumpydevelop/outbox?page=0">
<label class="block font-medium">Request type</label>
<input type="text" class="request-type-input w-full p-2 border rounded-md mb-2"
placeholder="POST or GET" value="GET">
<label class="block font-medium">X-Session:</label>
<input type="text" class="session-input w-full p-2 border rounded-md mb-2"
placeholder="Enter X-Session token" value="">
<label class="block font-medium">X-Profile:</label>
<input type="text" class="profile-input w-full p-2 border rounded-md mb-2" placeholder="Enter X-Profile"
value="">
<div class="buttonContainer">
<button class="send-btn bg-blue-500 text-white px-4 py-2 rounded-md w-full hover:bg-blue-600">
Send Request
</button>
</div>
<p class="mt-2 text-sm text-gray-700">Response:</p>
<p class="response mt-2 text-sm text-gray-700 font-mono whitespace-pre-wrap">Waiting for response</p>
</div>
</div>
<button id="add-request" class="mt-4 bg-green-500 text-white px-4 py-2 rounded-md w-full hover:bg-green-600">
Add Another Request
</button>
</div>
<script>
function sendRequest(button) {
const container = button.parentElement.parentElement;
const targetLink = container.querySelector(".target-link-input").value;
const requestType = container.querySelector(".request-type-input").value;
const session = container.querySelector(".session-input").value;
const profile = container.querySelector(".profile-input").value;
const responseField = container.querySelector(".response");
button.parentElement.style.cursor = "not-allowed";
button.style.pointerEvents = "none";
button.textContent = "Sending...";
responseField.textContent = "Waiting for response";
const headers = {
...(session ? { "X-Session": session } : {}),
...(profile ? { "X-Profile": profile } : {}),
"HTTP_HOST": "localhost",
};
fetch("http://localhost/" + targetLink, {
method: requestType,
headers
})
.then(response => response.text())
.then(data => {
responseField.textContent = data;
button.parentElement.style.cursor = "";
button.style.pointerEvents = "";
button.textContent = "Send Request";
})
.catch(error => {
responseField.textContent = "Error: " + error;
button.parentElement.style.cursor = "";
button.style.pointerEvents = "";
button.textContent = "Send Request";
});
}
document.querySelectorAll(".send-btn").forEach(btn => {
btn.addEventListener("click", function () {
sendRequest(this);
});
});
document.getElementById("add-request").addEventListener("click", function () {
const container = document.getElementById("request-container");
const requestBox = container.firstElementChild.cloneNode(true);
requestBox.querySelector(".target-link-input").value = "users/grumpydevelop@contentnation.net/outbox?page=0";
requestBox.querySelector(".request-type-input").value = "GET";
requestBox.querySelector(".session-input").value = "";
requestBox.querySelector(".profile-input").value = "";
requestBox.querySelector(".response").textContent = "Waiting for response";
requestBox.querySelector(".send-btn").addEventListener("click", function () {
sendRequest(this);
});
container.appendChild(requestBox);
});
</script>
</body>
</html>

View file

@ -1,10 +0,0 @@
<?php
if (!function_exists('getallheaders')) {
/**
* @return array<string, string>
*/
function getallheaders(): array {
return [];
}
}

View file

@ -6,7 +6,7 @@
* @author Sascha Nitsch (grumpydeveloper)
**/
namespace Federator;
namespace Federator;
/**
* main API class
@ -47,7 +47,7 @@ class Api extends Main
public function __construct()
{
$this->contentType = "application/json";
parent::__construct();
Main::__construct();
}
/**
@ -69,12 +69,11 @@ class Api extends Main
/**
* main API function
*/
public function run(): void
public function run() : void
{
$this->setPath((string) $_REQUEST['_call']);
$this->setPath((string)$_REQUEST['_call']);
$this->openDatabase();
$this->loadPlugins();
$retval = "";
$handler = null;
if ($this->connector === null) {
@ -101,49 +100,12 @@ class Api extends Main
break;
case 'fedusers':
$handler = new Api\FedUsers($this);
$this->setContentType("application/activity+json");
break;
case 'v1':
switch ($this->paths[1]) {
case 'dummy':
$handler = new Api\V1\Dummy($this);
break;
case 'newcontent':
$handler = new Api\V1\NewContent($this);
break;
/* case 'sendFollow': { // hacky implementation for testing purposes
$username = $this->paths[2];
$domain = $this->config['generic']['externaldomain'];
$response = \Federator\DIO\Followers::sendFollowRequest(
$this->dbh,
$this->connector,
$this->cache,
$username,
"admin@mastodon.local",
$domain
);
header("Content-type: " . $this->contentType);
header("Access-Control-Allow-Origin: *");
header("Cache-Control: no-cache, no-store, must-revalidate");
header("Pragma: no-cache");
header("Expires: 0");
if (is_string($response)) {
$this->setResponseCode(200);
$retval = json_encode(array(
"status" => "ok",
"message" => $response
));
} else {
$this->setResponseCode(500);
$retval = json_encode(array(
"status" => "error",
"message" => "Failed to send follow request"
));
}
http_response_code($this->responseCode);
echo $retval;
return;
} */
}
break;
}
@ -206,7 +168,7 @@ class Api extends Main
* @param string $message optional message
* @throws Exceptions\PermissionDenied
*/
public function checkPermission($permission, $exception = "\Exceptions\PermissionDenied", $message = null): void
public function checkPermission($permission, $exception = "\Exceptions\PermissionDenied", $message = null) : void
{
// generic check first
if ($this->user === false) {
@ -229,96 +191,6 @@ class Api extends Main
throw new $exception($message);
}
/**
* check if the headers include a valid signature
*
* @param string[] $headers the headers
* @throws Exceptions\PermissionDenied
* @return string|Exceptions\PermissionDenied
*/
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;
if (!isset($signatureHeader)) {
throw new Exceptions\PermissionDenied("Missing Signature header");
}
// 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']);
$keyId = $signatureParts['keyId'];
$publicKeyPem = $this->cache->getPublicKey($keyId);
if (!isset($publicKeyPem) || $publicKeyPem === false) {
// Fetch public key from `keyId` (usually actor URL + #main-key)
[$publicKeyData, $info] = \Federator\Main::getFromRemote($keyId, ['Accept: application/activity+json']);
if ($info['http_code'] != 200) {
throw new Exceptions\PermissionDenied("Failed to fetch public key from keyId: $keyId");
}
$actor = json_decode($publicKeyData, true);
if (!is_array($actor) || !isset($actor['id'])) {
throw new Exceptions\PermissionDenied("Invalid actor data");
}
$publicKeyPem = $actor['publicKey']['publicKeyPem'] ?? null;
if (!isset($publicKeyPem) || $publicKeyPem === false) {
http_response_code(500);
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
$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 Exceptions\PermissionDenied("Signature verification failed");
}
// Signature is valid!
return "Signature verified.";
}
/**
* remove unwanted elements from html input
*
@ -326,7 +198,7 @@ class Api extends Main
* input to strip
* @return string stripped input
*/
public static function stripHTML(string $_input): string
public static function stripHTML(string $_input) : string
{
$out = preg_replace('/<(script[^>]*)>/i', '&lt;${1}&gt;', $_input);
$out = preg_replace('/<\/(script)>/i', '&lt;/${1};&gt;', $out);
@ -340,7 +212,7 @@ class Api extends Main
* parameter to check
* @return bool true if in
*/
public static function hasPost(string $_key): bool
public static function hasPost(string $_key) : bool
{
return array_key_exists($_key, $_POST);
}
@ -356,13 +228,13 @@ class Api extends Main
*/
public function escapePost(string $key, $int = false)
{
if (!array_key_exists($key, $_POST)) {
if (! array_key_exists($key, $_POST)) {
return $int ? 0 : "";
}
if ($int === true) {
return intval($_POST[$key]);
}
$ret = $this->dbh->escape_string($this->stripHTML((string) $_POST[$key]));
$ret = $this->dbh->escape_string($this->stripHTML((string)$_POST[$key]));
return $ret;
}

View file

@ -16,7 +16,7 @@ class FedUsers implements APIInterface
/**
* main instance
*
* @var \Federator\Api $main
* @var \Federator\Main $main
*/
private $main;
@ -49,34 +49,24 @@ class FedUsers implements APIInterface
{
$method = $_SERVER["REQUEST_METHOD"];
$handler = null;
$_username = $paths[1];
switch (sizeof($paths)) {
case 2:
if ($method === 'GET') {
// /users/username or /@username or /username
return $this->returnUserProfile($_username);
} else {
switch ($paths[1]) {
case 'inbox':
$_username = NULL;
$handler = new FedUsers\Inbox($this->main);
break;
default:
break;
}
// /users/username or /@username
return $this->returnUserProfile($paths[1]);
}
break;
case 3:
// /users/username/(inbox|outbox|following|followers)
switch ($paths[2]) {
case 'following':
$handler = new FedUsers\Following($this->main);
// $handler = new FedUsers\Following();
break;
case 'followers':
$handler = new FedUsers\Followers($this->main);
// $handler = new FedUsers\Followers();
break;
case 'inbox':
$handler = new FedUsers\Inbox($this->main);
// $handler = new FedUsers\Inbox();
break;
case 'outbox':
$handler = new FedUsers\Outbox($this->main);
@ -92,10 +82,10 @@ class FedUsers implements APIInterface
$ret = false;
switch ($method) {
case 'GET':
$ret = $handler->get($_username);
$ret = $handler->get($paths[1]);
break;
case 'POST':
$ret = $handler->post($_username);
$ret = $handler->post($paths[1]);
break;
}
if ($ret !== false) {
@ -125,24 +115,18 @@ class FedUsers implements APIInterface
if ($user === false || $user->id === null) {
throw new \Federator\Exceptions\FileNotFound();
}
$config = $this->main->getConfig();
$domain = $config['generic']['externaldomain'];
$jsonKey = json_encode($user->publicKey);
if (!is_string($jsonKey)) {
throw new \Federator\Exceptions\FileNotFound();
}
$data = [
'iconMediaType' => $user->iconMediaType,
'iconURL' => $user->iconURL,
'imageMediaType' => $user->imageMediaType,
'imageURL' => $user->imageURL,
'fqdn' => $domain,
'fqdn' => $_SERVER['SERVER_NAME'],
'name' => $user->name,
'username' => $user->id,
'publickey' => trim($jsonKey, '"'),
'publickey' => str_replace("\n", "\\n", $user->publicKey),
'registered' => gmdate('Y-m-d\TH:i:s\Z', $user->registered), // 2021-03-25T00:00:00Z
'summary' => $user->summary,
'type' => ucfirst($user->type) // capitalized user type
'type' => $user->type
];
$this->response = $this->main->renderTemplate('user.json', $data);
return true;

View file

@ -1,120 +0,0 @@
<?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 followers requests
*/
class Followers implements \Federator\Api\FedUsers\FedUsersInterface
{
/**
* main instance
*
* @var \Federator\Api $main
*/
private $main;
/**
* constructor
* @param \Federator\Api $main main instance
*/
public function __construct($main)
{
$this->main = $main;
}
/**
* handle get call
*
* @param string|null $_user user to fetch followers for
* @return string|false response
*/
public function get($_user)
{
if (!isset($_user)) {
return false;
}
$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) {
return false;
}
$followers = new \Federator\Data\ActivityPub\Common\Followers();
$followerItems = \Federator\DIO\Followers::getFollowersByUser($dbh, $user->id, $connector, $cache);
$config = $this->main->getConfig();
$domain = $config['generic']['externaldomain'];
$baseUrl = 'https://' . $domain . '/' . $_user . '/followers';
$pageSize = 10;
$page = $this->main->extractFromURI("page", "");
$id = $baseUrl;
$items = [];
$totalItems = count($followerItems);
if ($page !== "") {
$pageNum = max(0, (int) $page);
$offset = (int)($pageNum * $pageSize);
$pagedItems = array_slice($followerItems, $offset, $pageSize);
foreach ($pagedItems as $follower) {
$items[] = $follower->actorURL;
}
$followers->setItems($items);
$id .= '?page=' . urlencode($page);
}
$followers->setID($id);
$followers->setPartOf($baseUrl);
$followers->setTotalItems($totalItems);
// Pagination navigation
$lastPage = max(0, ceil($totalItems / $pageSize) - 1);
if ($page === "" || $followers->count() == 0) {
$followers->setFirst($baseUrl . '?page=0');
$followers->setLast($baseUrl . '?page=' . $lastPage);
}
if ($page !== "") {
$pageNum = max(0, (int) $page);
if ($pageNum < $lastPage) {
$followers->setNext($baseUrl . '?page=' . ($pageNum + 1));
}
if ($pageNum > 0) {
$followers->setPrev($baseUrl . '?page=' . ($pageNum - 1));
}
$followers->setFirst($baseUrl . '?page=0');
$followers->setLast($baseUrl . '?page=' . $lastPage);
}
$obj = $followers->toObject();
return json_encode($obj, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
}
/**
* handle post call
*
* @param string|null $_user user to add data to outbox @unused-param
* @return string|false response
*/
public function post($_user)
{
return false;
}
}

View file

@ -1,120 +0,0 @@
<?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 following requests
*/
class Following implements \Federator\Api\FedUsers\FedUsersInterface
{
/**
* main instance
*
* @var \Federator\Api $main
*/
private $main;
/**
* constructor
* @param \Federator\Api $main main instance
*/
public function __construct($main)
{
$this->main = $main;
}
/**
* handle get call
*
* @param string|null $_user user to fetch followers for
* @return string|false response
*/
public function get($_user)
{
if (!isset($_user)) {
return false;
}
$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) {
return false;
}
$following = new \Federator\Data\ActivityPub\Common\Following();
$followingItems = \Federator\DIO\Followers::getFollowingForUser($dbh, $user->id, $connector, $cache);
$config = $this->main->getConfig();
$domain = $config['generic']['externaldomain'];
$baseUrl = 'https://' . $domain . '/users/' . $_user . '/following';
$pageSize = 10;
$page = $this->main->extractFromURI("page", "");
$id = $baseUrl;
$items = [];
$totalItems = count($followingItems);
if ($page !== "") {
$pageNum = max(0, (int) $page);
$offset = (int) ($pageNum * $pageSize);
$pagedItems = array_slice($followingItems, $offset, $pageSize);
foreach ($pagedItems as $followed) {
$items[] = $followed->actorURL;
}
$following->setItems($items);
$id .= '?page=' . urlencode($page);
}
$following->setID($id);
$following->setPartOf($baseUrl);
$following->setTotalItems($totalItems);
// Pagination navigation
$lastPage = max(0, ceil($totalItems / $pageSize) - 1);
if ($page === "" || $following->count() == 0) {
$following->setFirst($baseUrl . '?page=0');
$following->setLast($baseUrl . '?page=' . $lastPage);
}
if ($page !== "") {
$pageNum = max(0, (int) $page);
if ($pageNum < $lastPage) {
$following->setNext($baseUrl . '?page=' . ($pageNum + 1));
}
if ($pageNum > 0) {
$following->setPrev($baseUrl . '?page=' . ($pageNum - 1));
}
$following->setFirst($baseUrl . '?page=0');
$following->setLast($baseUrl . '?page=' . $lastPage);
}
$obj = $following->toObject();
return json_encode($obj, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
}
/**
* handle post call
*
* @param string|null $_user user to add data to outbox @unused-param
* @return string|false response
*/
public function post($_user)
{
return false;
}
}

View file

@ -1,441 +0,0 @@
<?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;
}
}

View file

@ -16,13 +16,13 @@ class Outbox implements \Federator\Api\FedUsers\FedUsersInterface
/**
* main instance
*
* @var \Federator\Api $main
* @var \Federator\Main $main
*/
private $main;
/**
* constructor
* @param \Federator\Api $main main instance
* @param \Federator\Main $main main instance
*/
public function __construct($main)
{
@ -32,14 +32,11 @@ class Outbox implements \Federator\Api\FedUsers\FedUsersInterface
/**
* handle get call
*
* @param string|null $_user user to fetch outbox for
* @param string $_user user to fetch outbox for
* @return string|false response
*/
public function get($_user)
{
if (!isset($_user)) {
return false;
}
$dbh = $this->main->getDatabase();
$cache = $this->main->getCache();
$connector = $this->main->getConnector();
@ -53,7 +50,6 @@ class Outbox implements \Federator\Api\FedUsers\FedUsersInterface
if ($user->id === null) {
return false;
}
// get posts from user
$outbox = new \Federator\Data\ActivityPub\Common\Outbox();
$min = $this->main->extractFromURI("min", "");
@ -65,32 +61,31 @@ class Outbox implements \Federator\Api\FedUsers\FedUsersInterface
} else {
$items = [];
}
$config = $this->main->getConfig();
$domain = $config['generic']['externaldomain'];
$id = 'https://' . $domain . '/users/' . $_user . '/outbox';
$host = $_SERVER['SERVER_NAME'];
$id = 'https://' . $host .'/' . $_user . '/outbox';
$outbox->setPartOf($id);
$outbox->setID($id);
if ($page !== '') {
$id .= '?page=' . urlencode($page);
}
if ($page === '' || $outbox->count() == 0) {
$outbox->setFirst($id . '?page=0');
$outbox->setFirst($id);
$outbox->setLast($id . '&min=0');
}
if (sizeof($items) > 0) {
if (sizeof($items)>0) {
$newestId = $items[0]->getPublished();
$oldestId = $items[sizeof($items) - 1]->getPublished();
$oldestId = $items[sizeof($items)-1]->getPublished();
$outbox->setNext($id . '&max=' . $newestId);
$outbox->setPrev($id . '&min=' . $oldestId);
}
$obj = $outbox->toObject();
return json_encode($obj, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
return json_encode($obj);
}
/**
* handle post call
*
* @param string|null $_user user to add data to outbox @unused-param
* @param string $_user user to add data to outbox @unused-param
* @return string|false response
*/
public function post($_user)

View file

@ -20,7 +20,11 @@ class Dummy implements \Federator\Api\APIInterface
*/
private $main;
/** @var array<string, string> $message internal message to output */
/**
* internal message to output
*
* @var Array<string, mixed> $message
*/
private $message = [];
/**
@ -37,10 +41,10 @@ class Dummy implements \Federator\Api\APIInterface
* 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
* @param \Federator\Data\User|false $user user who is calling us
* @return bool true on success
*/
public function exec($paths, $user): bool
public function exec($paths, $user) : bool
{
// only for user with the 'publish' permission
if ($user === false || $user->hasPermission('publish') === false) {
@ -51,23 +55,16 @@ class Dummy implements \Federator\Api\APIInterface
case 'GET':
switch (sizeof($paths)) {
case 3:
switch ($paths[2]) {
case 'moo':
return $this->getDummy();
default:
break;
if ($paths[2] === 'moo') {
return $this->getDummy();
}
break;
}
break;
case 'POST':
switch (sizeof($paths)) {
case 3:
switch ($paths[2]) {
case 'moo':
return $this->postDummy();
default:
break;
if ($paths[2] === 'moo') {
return $this->postDummy();
}
break;
}

View file

@ -1,557 +0,0 @@
<?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;
}
$articleId = "";
if (isset($allHeaders['X-Sender'])) {
$newActivity = $connector->jsonToActivity($input, $articleId);
} 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(),
'articleId' => $articleId,
]);
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
* @param string $articleId the original id of the article (if applicable)
* (used to identify the article in the remote system)
* @return boolean response
*/
public static function postForUser($dbh, $connector, $cache, $host, $_user, $_recipientId, $newActivity, $articleId)
{
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 = strtolower($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 (strtolower($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':
$articleId = $object->getID();
\Federator\DIO\Posts::deletePost($dbh, $articleId);
// also remove latest saved article-update
\Federator\DIO\Posts::deletePost($dbh, $articleId . '#update');
// 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");
}
}
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, $articleId);
} 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 (strtolower($object->getType())) {
case 'note':
\Federator\DIO\Posts::savePost($dbh, $user->id, $newActivity, $articleId);
break;
case 'article':
\Federator\DIO\Posts::savePost($dbh, $user->id, $newActivity, $articleId);
$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");
}
}
break;
default:
\Federator\DIO\Posts::savePost($dbh, $user->id, $newActivity, $articleId);
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);
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;
}
}

View file

@ -44,10 +44,8 @@ class WellKnown implements APIInterface
*/
private function hostMeta()
{
$config = $this->main->getConfig();
$domain = $config['generic']['externaldomain'];
$data = [
'fqdn' => $domain
'fqdn' => $_SERVER['SERVER_NAME']
];
$this->response = $this->main->renderTemplate('host-meta.xml', $data);
return true;

View file

@ -45,10 +45,8 @@ class NodeInfo
*/
public function exec($paths)
{
$config = $this->main->getConfig();
$domain = $config['generic']['externaldomain'];
$data = [
'fqdn' => $domain
'fqdn' => $_SERVER['SERVER_NAME']
];
$template = null;
if (sizeof($paths) == 2 && $paths[0] === '.well-known' && $paths[1] === 'nodeinfo') {

View file

@ -51,7 +51,6 @@ class WebFinger
if (preg_match("/^acct:([^@]+)@(.*)$/", $_resource, $matches) != 1 || $matches[2] !== $domain) {
throw new \Federator\Exceptions\InvalidArgument();
}
$domain = $matches[2];
$user = \Federator\DIO\User::getUserByName(
$this->main->getDatabase(),
$matches[1],
@ -59,7 +58,6 @@ class WebFinger
$this->main->getCache()
);
if ($user->id == 0) {
echo "not found";
throw new \Federator\Exceptions\FileNotFound();
}
$data = [

View file

@ -13,24 +13,6 @@ namespace Federator\Cache;
*/
interface Cache extends \Federator\Connector\Connector
{
/**
* save remote followers of user
*
* @param string $user user name
* @param \Federator\Data\FedUser[]|false $followers user followers
* @return void
*/
public function saveRemoteFollowersOfUser($user, $followers);
/**
* save remote following for user
*
* @param string $user user name
* @param \Federator\Data\FedUser[]|false $following user following
* @return void
*/
public function saveRemoteFollowingForUser($user, $following);
/**
* save remote posts by user
*
@ -57,15 +39,6 @@ interface Cache extends \Federator\Connector\Connector
*/
public function saveRemoteUserByName($_name, $user);
/**
* save remote federation user by given name
*
* @param string $_name user/profile name
* @param \Federator\Data\FedUser $user user data
* @return void
*/
public function saveRemoteFedUserByName(string $_name, \Federator\Data\FedUser $user);
/**
* save remote user by given session
*
@ -75,29 +48,4 @@ interface Cache extends \Federator\Connector\Connector
* @return void
*/
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);
/**
* get remote federation user by given name
*
* @param string $_name user/profile name
* @return \Federator\Data\FedUser | false
*/
public function getRemoteFedUserByName(string $_name);
/**
* 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);
}

View file

@ -13,34 +13,16 @@ namespace Federator\Connector;
*/
interface Connector
{
/**
* get followers of given user
*
* @param string $id user id
* @return \Federator\Data\FedUser[]|false
*/
public function getRemoteFollowersOfUser($id);
/**
* get following of given user
*
* @param string $id user id
* @return \Federator\Data\FedUser[]|false
*/
public function getRemoteFollowingForUser($id);
/**
* get posts by given user
*
* @param string $id user id
* @param string $min min date
* @param string $max max date
* @param string $minId min ID
* @param string $maxId max ID
* @return \Federator\Data\ActivityPub\Common\Activity[]|false
* @return \Federator\Data\ActivityPub\Common\APObject[]|false
*/
public function getRemotePostsByUser($id, $min, $max);
public function getRemotePostsByUser($id, $minId, $maxId);
/**
* get remote user by given name
@ -65,32 +47,4 @@ interface Connector
* @return \Federator\Data\Stats|false
*/
public function getRemoteStats();
/**
* Convert jsonData to Activity format
*
* @param array<string, mixed> $jsonData the json data from our platfrom
* @param string $articleId the original id of the article (if applicable)
* (used to identify the article in the remote system)
* @return \Federator\Data\ActivityPub\Common\Activity|false
*/
public function jsonToActivity(array $jsonData, &$articleId);
/**
* send target-friendly json from ActivityPub activity
*
* @param \Federator\Data\FedUser $sender the user of the sender
* @param \Federator\Data\ActivityPub\Common\Activity $activity the activity
* @return boolean did we successfully send the activity?
*/
public function sendActivity($sender, $activity);
/**
* 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);
}

View file

@ -1,41 +0,0 @@
<?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\Data\ActivityPub\Common;
class Undo extends Activity
{
public function __construct()
{
parent::__construct('Undo');
parent::addContext('https://www.w3.org/ns/activitystreams');
}
/**
* convert internal state to php array
*
* @return array<string,mixed>
*/
public function toObject()
{
$return = parent::toObject();
$return['type'] = 'Undo';
return $return;
}
/**
* create object from json
*
* @param array<string,mixed> $json input json
* @return bool true on success
*/
public function fromJson($json)
{
return parent::fromJson($json);
}
}

View file

@ -1,18 +0,0 @@
<?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\Data\ActivityPub\Common;
class Accept extends Activity
{
public function __construct()
{
parent::__construct('Accept');
parent::addContext('https://www.w3.org/ns/activitystreams');
}
}

View file

@ -47,7 +47,7 @@ class Activity extends APObject
return $this;
}
public function getAActor(): string
public function getAActor() : string
{
return $this->aactor;
}
@ -74,7 +74,6 @@ class Activity extends APObject
{
if (array_key_exists('actor', $json)) {
$this->actor = $json['actor'];
$this->aactor = $json['actor'];
unset($json['actor']);
}
if (!parent::fromJson($json)) {
@ -103,7 +102,7 @@ class Activity extends APObject
/**
* get Child Object
*
* @return APObject|string|null
* @return APObject|null
*/
public function getObject()
{

View file

@ -1,41 +0,0 @@
<?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\Data\ActivityPub\Common;
class Announce extends Activity
{
public function __construct()
{
parent::__construct('Announce');
parent::addContext('https://www.w3.org/ns/activitystreams');
}
/**
* convert internal state to php array
*
* @return array<string,mixed>
*/
public function toObject()
{
$return = parent::toObject();
$return['type'] = 'Announce';
return $return;
}
/**
* create object from json
*
* @param array<string,mixed> $json input json
* @return bool true on success
*/
public function fromJson($json)
{
return parent::fromJson($json);
}
}

View file

@ -27,7 +27,7 @@ class APObject implements \JsonSerializable
/**
* child object
*
* @var APObject|string|null $object
* @var APObject|null $object
*/
private $object = null;
@ -347,7 +347,7 @@ class APObject implements \JsonSerializable
/**
* set child object
*
* @param APObject|string $object
* @param APObject $object
* @return void
*/
public function setObject($object)
@ -358,7 +358,7 @@ class APObject implements \JsonSerializable
/**
* get child object
*
* @return APObject|string|null child object
* @return APObject|null child object
*/
public function getObject()
{
@ -376,17 +376,6 @@ class APObject implements \JsonSerializable
$this->summary = $summary;
return $this;
}
/**
* get summary
*
* @return string summary
*/
public function getSummary()
{
return $this->summary;
}
/**
* set type
*
@ -470,16 +459,6 @@ class APObject implements \JsonSerializable
return $this;
}
/**
* get name
*
* @return string name
*/
public function getName() : string
{
return $this->name;
}
/**
* add Image
*
@ -668,7 +647,7 @@ class APObject implements \JsonSerializable
if (array_key_exists('duration', $json)) {
try {
$this->duration = new \DateInterval($json['duration']);
} catch (\Throwable $unused_e) {
} catch (\Exception $unused_e) {
error_log("error parsing duration ". $json['duration']);
}
}
@ -771,8 +750,8 @@ class APObject implements \JsonSerializable
if (array_key_exists('mediaType', $json)) {
$this->mediaType = $json['mediaType'];
}
if (array_key_exists('object', $json)) { // some actPub servers send strings in the object field
$this->object = is_array($json['object']) ? \Federator\Data\ActivityPub\Factory::newFromJson($json['object'], "") : $json['object'];
if (array_key_exists('object', $json)) {
$this->object = \Federator\Data\ActivityPub\Factory::newFromJson($json['object'], "");
}
if (array_key_exists('sensitive', $json)) {
$this->sensitive = $json['sensitive'];
@ -794,7 +773,7 @@ class APObject implements \JsonSerializable
* {@inheritDoc}
* @see JsonSerializable::jsonSerialize()
*/
public function jsonSerialize(): mixed
public function jsonSerialize()
{
return $this->toObject();
}
@ -896,7 +875,7 @@ class APObject implements \JsonSerializable
$return['tag'] = $tags;
}
if ($this->updated > 0) {
$return['updated'] = gmdate("Y-m-d\TH:i:s\Z", $this->updated);
$return['updated'] = gmdate("Y-m-d\TH:i:S\Z", $this->updated);
}
if ($this->url !== '') {
$return['url'] = $this->url;
@ -911,7 +890,7 @@ class APObject implements \JsonSerializable
$return['mediaType'] = $this->mediaType;
}
if ($this->object !== null) {
$return['object'] = is_string($this->object) ? $this->object : $this->object->toObject();
$return['object'] = $this->object->toObject();
}
if ($this->atomURI !== '') {
$return['atomUri'] = $this->atomURI;

View file

@ -51,27 +51,17 @@ class Collection extends APObject
return parent::fromJson($json);
}
/**
* set total items
*
* @param int $totalItems total items
*/
public function setTotalItems(int $totalItems): void
{
$this->totalItems = $totalItems;
}
public function count(): int
public function count() : int
{
return $this->totalItems;
}
public function setFirst(string $url): void
public function setFirst(string $url) : void
{
$this->first = $url;
}
public function setLast(string $url): void
public function setLast(string $url) : void
{
$this->last = $url;
}

View file

@ -26,9 +26,7 @@ class Create extends Activity
$return = parent::toObject();
$return['type'] = 'Create';
// overwrite id from url
if ($this->getURL() !== '') {
$return['id'] = $this->getURL();
}
$return['id'] = $this->getURL();
return $return;
}

View file

@ -1,36 +0,0 @@
<?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\Data\ActivityPub\Common;
class Delete extends Activity
{
public function __construct()
{
parent::__construct('Delete');
parent::addContext('https://www.w3.org/ns/activitystreams');
}
/**
* create from json/array
* @param mixed $json
*/
public function fromJson($json): bool
{
return parent::fromJson($json);
}
/**
* convert internal state to php array
* @return array<string,mixed>
*/
public function toObject()
{
$return = parent::toObject();
return $return;
}
}

View file

@ -1,18 +0,0 @@
<?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\Data\ActivityPub\Common;
class Dislike extends Activity
{
public function __construct()
{
parent::__construct('Dislike');
parent::addContext('https://www.w3.org/ns/activitystreams');
}
}

View file

@ -1,55 +0,0 @@
<?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\Data\ActivityPub\Common;
class Follow extends Activity
{
/**
* object overwrite
* @var string
*/
private $object = "";
public function setFObject(string $object): void
{
$this->object = $object;
}
public function getObject(): string
{
return $this->object;
}
public function __construct()
{
parent::__construct("Follow");
parent::addContext('https://www.w3.org/ns/activitystreams');
}
public function fromJson($json): bool
{
if (array_key_exists('object', $json)) {
$this->object = $json['object'];
unset($json['object']);
}
return parent::fromJson($json);
}
/**
* convert internal state to php array
* @return array<string,mixed>
*/
public function toObject()
{
$return = parent::toObject();
if ($this->object !== "") {
$return['object'] = $this->object;
}
return $return;
}
}

View file

@ -1,53 +0,0 @@
<?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\Data\ActivityPub\Common;
class Followers extends OrderedCollectionPage
{
public function __construct()
{
parent::__construct();
parent::addContext('https://www.w3.org/ns/activitystreams');
}
/**
* set items
*
* @param string[] $items the items in the collection
* @return void
*/
public function setItems(&$items)
{
// Optionally: type check that all $items are Activity objects
$this->items = $items;
$this->totalItems = sizeof($items);
}
/**
* convert internal state to php array
*
* @return array<string,mixed>
*/
public function toObject()
{
$return = parent::toObject();
return $return;
}
/**
* create object from json
*
* @param array<string,mixed> $json input json
* @return bool true on success
*/
public function fromJson($json)
{
return parent::fromJson($json);
}
}

View file

@ -1,53 +0,0 @@
<?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\Data\ActivityPub\Common;
class Following extends OrderedCollectionPage
{
public function __construct()
{
parent::__construct();
parent::addContext('https://www.w3.org/ns/activitystreams');
}
/**
* set items
*
* @param string[] $items the items in the collection
* @return void
*/
public function setItems(&$items)
{
// Optionally: type check that all $items are Activity objects
$this->items = $items;
$this->totalItems = sizeof($items);
}
/**
* convert internal state to php array
*
* @return array<string,mixed>
*/
public function toObject()
{
$return = parent::toObject();
return $return;
}
/**
* create object from json
*
* @param array<string,mixed> $json input json
* @return bool true on success
*/
public function fromJson($json)
{
return parent::fromJson($json);
}
}

View file

@ -1,50 +0,0 @@
<?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\Data\ActivityPub\Common;
class Inbox extends OrderedCollectionPage
{
public function __construct()
{
parent::__construct();
parent::addContext('https://www.w3.org/ns/activitystreams');
parent::addContexts([
"ostatus" => "http://ostatus.org#",
"atomUri" => "ostatus:atomUri",
"inReplyToAtomUri" => "ostatus:inReplyToAtomUri",
"conversation" => "ostatus:conversation",
"sensitive" => "as:sensitive",
"toot" => "http://joinmastodon.org/ns#",
"votersCount" => "toot:votersCount",
"Hashtag" => "as:Hashtag"
]);
}
/**
* convert internal state to php array
*
* @return array<string,mixed>
*/
public function toObject()
{
$return = parent::toObject();
return $return;
}
/**
* create object from json
*
* @param array<string,mixed> $json input json
* @return bool true on success
*/
public function fromJson($json)
{
return parent::fromJson($json);
}
}

View file

@ -1,18 +0,0 @@
<?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\Data\ActivityPub\Common;
class Like extends Activity
{
public function __construct()
{
parent::__construct('Like');
parent::addContext('https://www.w3.org/ns/activitystreams');
}
}

View file

@ -13,7 +13,7 @@ class OrderedCollection extends Collection
/**
* nested items
*
* @var APObject[]|string[]
* @var APObject[]
*/
protected $items = [];
@ -34,11 +34,7 @@ class OrderedCollection extends Collection
$return['type'] = 'OrderedCollection';
if ($this->totalItems > 0) {
foreach ($this->items as $item) {
if (is_string($item)) {
$return['orderedItems'][] = $item;
} elseif (is_object($item)) {
$return['orderedItems'][] = $item->toObject();
}
$return['OrderedItems'][] = $item->toObject();
}
}
return $return;
@ -55,11 +51,7 @@ class OrderedCollection extends Collection
return parent::fromJson($json);
}
/**
* add item to collection
* @param APObject|string $item
*/
public function append(&$item): void
public function append(APObject &$item) : void
{
$this->items[] = $item;
$this->totalItems = sizeof($this->items);
@ -68,7 +60,7 @@ class OrderedCollection extends Collection
/**
* get item with given index
*
* @return APObject|string|false
* @return APObject|false
*/
public function get(int $index)
{
@ -78,7 +70,7 @@ class OrderedCollection extends Collection
}
return $this->items[$index];
} else {
if ($this->totalItems + $index < 0) {
if ($this->totalItems+ $index < 0) {
return false;
}
return $this->items[$this->totalItems + $index];
@ -88,7 +80,7 @@ class OrderedCollection extends Collection
/**
* set items
*
* @param APObject[]|string[] $items
* @param APObject[] $items
* @return void
*/
public function setItems(&$items)

View file

@ -4,7 +4,6 @@
* SPDX-License-Identifier: GPL-3.0-or-later
*
* @author Sascha Nitsch (grumpydeveloper)
* @author Yannis Vogel (vogeldevelopment)
**/
namespace Federator\Data\ActivityPub\Common;
@ -27,18 +26,6 @@ class Outbox extends OrderedCollectionPage
]);
}
/**
* set items
*
* @param \Federator\Data\ActivityPub\Common\APObject[] $items the items in the collection
*/
public function setItems(&$items)
{
// Optionally: type check that all $items are Activity objects
$this->items = $items;
$this->totalItems = sizeof($items);
}
/**
* convert internal state to php array
*

View file

@ -1,18 +0,0 @@
<?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\Data\ActivityPub\Common;
class Reject extends Activity
{
public function __construct()
{
parent::__construct('Reject');
parent::addContext('https://www.w3.org/ns/activitystreams');
}
}

View file

@ -1,45 +0,0 @@
<?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\Data\ActivityPub\Common;
class Update extends Activity
{
public function __construct()
{
parent::__construct('Update');
parent::addContext('https://www.w3.org/ns/activitystreams');
}
/**
* convert internal state to php array
*
* @return array<string,mixed>
*/
public function toObject()
{
$return = parent::toObject();
$return['type'] = 'Update';
// overwrite id from url
if ($this->getURL() !== '') {
$return['id'] = $this->getURL();
}
return $return;
}
/**
* create object from json
*
* @param array<string,mixed> $json input json
* @return bool true on success
*/
public function fromJson($json)
{
return parent::fromJson($json);
}
}

View file

@ -1,27 +0,0 @@
<?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\Data\ActivityPub\Common;
class Vote extends APObject
{
public function __construct()
{
parent::__construct('Vote');
}
/**
* create object from json
* @param mixed $json input
* @return bool true on success
*/
public function fromJson($json)
{
return parent::fromJson($json);
}
}

View file

@ -31,9 +31,6 @@ class Factory
}
$return = null;
switch ($json['type']) {
case 'Announce':
$return = new Common\Announce();
break;
case 'Article':
$return = new Common\Article();
break;
@ -42,39 +39,21 @@ class Factory
break;
case 'Event':
$return = new Common\Event();
break;*/
break;
case 'Follow':
$return = new Common\Follow();
break;
break;*/
case 'Image':
$return = new Common\Image();
break;
case 'Note':
/*case 'Note':
$return = new Common\Note();
break;
case 'Outbox':
$return = new Common\Outbox();
break;
case 'Vote':
$return = new Common\Vote();
break;
case 'Like':
$return = new Common\Like();
break;
case 'Dislike':
$return = new Common\Dislike();
break;
case 'Inbox':
$return = new Common\Inbox();
break;
case 'Tombstone':
$return = new Common\APObject("Tombstone");
break;
/*case 'Question':
$return = new Common\Question();
case 'Question':
$return = new \Common\Question();
break;
case 'Video':
$return = new Common\Video();
$return = new \Common\Video();
break;*/
default:
error_log("newFromJson: unknown type: '" . $json['type'] . "' " . $jsonstring);
@ -98,7 +77,9 @@ class Factory
}
//$return = false;
switch ($json['type']) {
case 'Accept':
case 'MakePhanHappy':
break;
/* case 'Accept':
$return = new Common\Accept();
break;
case 'Announce':
@ -110,30 +91,18 @@ class Factory
case 'Delete':
$return = new Common\Delete();
break;
case 'Like':
$return = new Common\Like();
break;
case 'Dislike':
$return = new Common\Dislike();
break;
case 'Follow':
$return = new Common\Follow();
break;
case 'Reject':
$return = new Common\Reject();
break;
case 'Undo':
$return = new Common\Undo();
break;
case 'Update':
$return = new Common\Update();
break;
$return = new \Common\Undo();
break;*/
default:
error_log("newActivityFromJson unsupported type: " . print_r($json, true));
error_log("newActivityFromJson " . print_r($json, true));
}
if (isset($return) && $return->fromJson($json) !== null) {
/*if ($return !== false && $return->fromJson($json) !== null) {
return $return;
}
}*/
return false;
}
}

View file

@ -1,151 +0,0 @@
<?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\Data;
/**
* storage class for user attributes
*/
class FedUser
{
/**
* user id
*
* @var string $id
*/
public $id;
/**
* user url
*
* @var string $actorURL
*/
public $actorURL;
/**
* user name
*
* @var string $name
*/
public $name = '';
/**
* user public key
*
* @var string $publicKey
*/
public $publicKey;
/**
* summary for user/profile
*
* @var string $summary
*/
public $summary = '';
/**
* type of user (person/group)
*
* @var string $type
*/
public $type = 'Person';
/**
* inbox URL
*
* @var string $inboxURL
*/
public $inboxURL;
/**
* shared inbox URL
*
* @var string $sharedInboxURL
*/
public $sharedInboxURL;
/**
* followers URL
*
* @var string $followersURL
*/
public $followersURL;
/**
* following URL
*
* @var string $followingURL
*/
public $followingURL;
/**
* public key ID
*
* @var string $publicKeyId
*/
public $publicKeyId;
/**
* outbox URL
*
* @var string $outboxURL
*/
public $outboxURL;
/**
* create new user object from json string
*
* @param string $input input string
* @return FedUser|false
*/
public static function createFromJson($input)
{
$data = json_decode($input, true);
if ($data === null) {
return false;
}
$user = new FedUser();
$user->id = $data['id'] ?? '';
$user->actorURL = $data['actorURL'] ?? '';
$user->name = $data['name'] ?? '';
$user->publicKey = $data['publicKey'] ?? '';
$user->summary = $data['summary'] ?? '';
$user->type = $data['type'] ?? 'Person';
$user->inboxURL = $data['inbox'] ?? '';
$user->sharedInboxURL = $data['sharedInbox'] ?? '';
$user->followersURL = $data['followers'] ?? '';
$user->followingURL = $data['following'] ?? '';
$user->publicKeyId = $data['publicKeyId'] ?? '';
$user->outboxURL = $data['outbox'] ?? '';
return $user;
}
/**
* convert internal data to json string
*
* @return string
*/
public function toJson()
{
$data = [
'id' => $this->id,
'actorURL' => $this->actorURL,
'name' => $this->name,
'publicKey' => $this->publicKey,
'summary' => $this->summary,
'type' => $this->type,
'inbox' => $this->inboxURL,
'sharedInbox' => $this->sharedInboxURL,
'followers' => $this->followersURL,
'following' => $this->followingURL,
'publicKeyId' => $this->publicKeyId,
'outbox' => $this->outboxURL,
];
return json_encode($data) ?: '';
}
}

View file

@ -1,68 +0,0 @@
<?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\DIO;
/**
* IO functions related to articles
*/
class Article
{
/**
* Convert an Article to a Note
*
* @param \Federator\Data\ActivityPub\Common\Article $article
* @return \Federator\Data\ActivityPub\Common\Note
* The generated note
*/
public static function convertToNote($article)
{
$note = new \Federator\Data\ActivityPub\Common\Note();
$note->setId($article->getId())
->setURL($article->getURL());
$note->setContent($article->getContent());
$note->setSummary($article->getSummary());
$note->setPublished($article->getPublished());
$note->setName($article->getName());
$note->setAttributedTo($article->getAttributedTo());
foreach ($article->getTo() as $to) {
$note->addTo($to);
}
foreach ($article->getCc() as $cc) {
$note->addCc($cc);
}
return $note;
}
/** Conditionally convert article to a note
*
* @param \Federator\Data\ActivityPub\Common\Article $article
* @param string $targetUrl
* The target URL for the activity (e.g. mastodon.social)
* @return \Federator\Data\ActivityPub\Common\Note|\Federator\Data\ActivityPub\Common\Article
* The generated note on success, false on failure
*/
public static function conditionalConvertToNote($article, $targetUrl)
{
$supportFile = file_get_contents(PROJECT_ROOT . '/formatsupport.json');
if ($supportFile === false) {
error_log("Article::conditionalConvertToNote Failed to read support file for article conversion.");
return $article; // Fallback to original article if file read fails
}
$supportlist = json_decode($supportFile, true);
if (
!isset($supportlist['activitypub']['article']) ||
!is_array($supportlist['activitypub']['article']) ||
!in_array($targetUrl, $supportlist['activitypub']['article'], true)
) {
return self::convertToNote($article); // Articles are not supported for this target
}
return $article; // Articles are supported, return as is
}
}

View file

@ -1,242 +0,0 @@
<?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\DIO;
/**
* IO functions related to fedUsers
*/
class FedUser
{
/**
* add local user based on given user object received from remote service
* @param \mysqli $dbh database handle
* @param \Federator\Data\FedUser $user user object to use
* @param string $_user user/profile name
* @return void
*/
protected static function addLocalUser($dbh, $user, $_user)
{
// check if it is timed out user
$sql = 'select unix_timestamp(`validuntil`) from fedusers where id=?';
$stmt = $dbh->prepare($sql);
if ($stmt === false) {
throw new \Federator\Exceptions\ServerError("FedUser::addLocalUser Failed to prepare statement");
}
$stmt->bind_param("s", $_user);
$validuntil = 0;
$ret = $stmt->bind_result($validuntil);
$stmt->execute();
if ($ret) {
$stmt->fetch();
}
$stmt->close();
if ($validuntil == 0) {
$sql = 'insert into fedusers (id, url, name, publickey, summary, type, inboxurl, sharedinboxurl,';
$sql .= ' followersurl, followingurl, publickeyid, outboxurl, validuntil)';
$sql .= ' values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, now() + interval 1 day)';
$stmt = $dbh->prepare($sql);
if ($stmt === false) {
throw new \Federator\Exceptions\ServerError("FedUser::addLocalUser Failed to prepare create statement");
}
$stmt->bind_param(
"ssssssssssss",
$_user,
$user->actorURL,
$user->name,
$user->publicKey,
$user->summary,
$user->type,
$user->inboxURL,
$user->sharedInboxURL,
$user->followersURL,
$user->followingURL,
$user->publicKeyId,
$user->outboxURL
);
} else {
// update to existing user
$sql = 'update fedusers set validuntil=now() + interval 1 day, url=?, name=?, publickey=?, summary=?,';
$sql .= ' type=?, inboxurl=?, sharedinboxurl=?, followersurl=?, followingurl=?, publickeyid=?, outboxurl=?';
$sql .= ' where id=?';
$stmt = $dbh->prepare($sql);
if ($stmt === false) {
throw new \Federator\Exceptions\ServerError("FedUser::extendUser Failed to prepare update statement");
}
$stmt->bind_param(
"ssssssssssss",
$user->actorURL,
$user->name,
$user->publicKey,
$user->summary,
$user->type,
$user->inboxURL,
$user->sharedInboxURL,
$user->followersURL,
$user->followingURL,
$user->publicKeyId,
$user->outboxURL,
$_user
);
}
try {
$stmt->execute();
$stmt->close();
$user->id = $_user;
} catch (\mysqli_sql_exception $e) {
error_log($sql);
error_log(print_r($user, true));
error_log($e->getMessage());
}
}
/**
* extend the given user with internal data
* @param \mysqli $dbh database handle
* @param \Federator\Data\FedUser $user user to extend
* @param string $_user user/profile name
*/
protected static function extendUser(\mysqli $dbh, \Federator\Data\FedUser $user, $_user): void
{
$sql = 'select id,unix_timestamp(`validuntil`) from fedusers where id=?';
$stmt = $dbh->prepare($sql);
if ($stmt === false) {
throw new \Federator\Exceptions\ServerError("FedUser::extendUser Failed to prepare statement");
}
$stmt->bind_param("s", $_user);
$validuntil = 0;
$ret = $stmt->bind_result($user->id, $validuntil);
$stmt->execute();
if ($ret) {
$stmt->fetch();
}
$stmt->close();
// if a new user, create own database entry with additionally needed info
if ($user->id === null || $validuntil < time()) {
self::addLocalUser($dbh, $user, $_user);
}
// no further processing for now
}
/**
* get user by name
*
* @param \mysqli $dbh
* database handle
* @param string $_name
* user name
* @param \Federator\Cache\Cache|null $cache
* optional caching service
* @return \Federator\Data\FedUser
*/
public static function getUserByName($dbh, $_name, $cache)
{
$user = false;
// ask cache
if ($cache !== null) {
$user = $cache->getRemoteFedUserByName($_name);
}
if ($user !== false) {
return $user;
}
// check our db
$sql = 'select `id`, `url`, `name`, `publickey`, `summary`, `type`, `inboxurl`, `sharedinboxurl`, `followersurl`,';
$sql .= ' `followingurl`, `publickeyid`, `outboxurl`';
$sql .= ' from fedusers where `id`=? and `validuntil`>=now()';
$stmt = $dbh->prepare($sql);
if ($stmt === false) {
throw new \Federator\Exceptions\ServerError("FedUser::getUserByName Failed to prepare statement");
}
$stmt->bind_param("s", $_name);
$user = new \Federator\Data\FedUser();
$ret = $stmt->bind_result(
$user->id,
$user->actorURL,
$user->name,
$user->publicKey,
$user->summary,
$user->type,
$user->inboxURL,
$user->sharedInboxURL,
$user->followersURL,
$user->followingURL,
$user->publicKeyId,
$user->outboxURL
);
$stmt->execute();
if ($ret) {
$stmt->fetch();
}
$stmt->close();
if ($user->id === null) {
// check if its a federated user with username@domain.ending
if (preg_match("/^([^@]+)@(.*)$/", $_name, $matches) == 1) {
// make webfinger request
$remoteURL = 'https://' . $matches[2] . '/.well-known/webfinger?resource=acct:' . urlencode($_name);
$headers = ['Accept: application/activity+json'];
[$response, $info] = \Federator\Main::getFromRemote($remoteURL, $headers);
if ($info['http_code'] != 200) {
throw new \Federator\Exceptions\ServerError("FedUser::getUserByName Failed to fetch webfinger for " . $_name);
}
$r = json_decode($response, true);
if ($r === false || $r === null || !is_array($r)) {
throw new \Federator\Exceptions\ServerError("FedUser::getUserByName Failed to decode webfinger for " . $_name);
}
// get the webwinger user url and fetch the user
if (isset($r['links'])) {
foreach ($r['links'] as $link) {
if (isset($link['rel']) && $link['rel'] === 'self') {
$remoteURL = $link['href'];
break;
}
}
}
if (!isset($remoteURL)) {
throw new \Federator\Exceptions\ServerError("FedUser::getUserByName Failed to find self link in webfinger for " . $_name);
}
// fetch the user
$headers = ['Accept: application/activity+json'];
[$response, $info] = \Federator\Main::getFromRemote($remoteURL, $headers);
if ($info['http_code'] != 200) {
throw new \Federator\Exceptions\ServerError("FedUser::getUserByName Failed to fetch user from remoteUrl for " . $_name);
}
$r = json_decode($response, true);
if ($r === false || $r === null || !is_array($r)) {
throw new \Federator\Exceptions\ServerError("FedUser::getUserByName Failed to decode user for " . $_name);
}
$r['publicKeyId'] = $r['publicKey']['id'];
$r['publicKey'] = $r['publicKey']['publicKeyPem'];
if (isset($r['endpoints'])) {
if (isset($r['endpoints']['sharedInbox'])) {
$r['sharedInbox'] = $r['endpoints']['sharedInbox'];
}
}
$r['actorURL'] = $remoteURL;
$data = json_encode($r);
if ($data === false) {
throw new \Federator\Exceptions\ServerError("FedUser::getUserByName Failed to encode userdata " . $_name);
}
$user = \Federator\Data\FedUser::createFromJson($data);
}
}
if ($cache !== null && $user !== false) {
if ($user->id !== null && $user->actorURL !== null) {
self::addLocalUser($dbh, $user, $_name);
}
$cache->saveRemoteFedUserByName($_name, $user);
}
if ($user === false) {
throw new \Federator\Exceptions\ServerError("FedUser::getUserByName User not found");
}
return $user;
}
}

View file

@ -1,525 +0,0 @@
<?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\DIO;
/**
* IO functions related to followers
*/
class Followers
{
/**
* get followers of user
*
* @param \mysqli $dbh
* database handle
* @param string $id
* user id
* @param \Federator\Connector\Connector $connector
* connector to fetch use with
* @param \Federator\Cache\Cache|null $cache
* optional caching service
* @return \Federator\Data\FedUser[]
*/
public static function getFollowersByUser($dbh, $id, $connector, $cache)
{
// ask cache
if ($cache !== null) {
$followers = $cache->getRemoteFollowersOfUser($id);
if ($followers !== false) {
return $followers;
}
}
$followers = [];
$sql = 'select source_user from follows where target_user = ?';
$stmt = $dbh->prepare($sql);
if ($stmt === false) {
throw new \Federator\Exceptions\ServerError("Followers::getFollowersByUser Failed to prepare statement");
}
$stmt->bind_param("s", $id);
$stmt->execute();
$followerIds = [];
$stmt->bind_result($sourceUser);
while ($stmt->fetch()) {
$followerIds[] = $sourceUser;
}
$stmt->close();
foreach ($followerIds as $followerId) {
try {
$user = \Federator\DIO\FedUser::getUserByName(
$dbh,
$followerId,
$cache,
);
} catch (\Throwable $e) {
error_log("Followers::getFollowersByUser Exception: " . $e->getMessage());
continue; // Skip this user if an exception occurs
}
if ($user !== false && $user->id !== null) {
$followers[] = $user;
}
}
if ($followers === []) {
// ask connector for user-id
$followers = $connector->getRemoteFollowersOfUser($id);
if ($followers === false) {
$followers = [];
}
}
// save followers to cache
if ($cache !== null) {
$cache->saveRemoteFollowersOfUser($id, $followers);
}
return $followers;
}
/**
* get following for user - who does the user follow
*
* @param \mysqli $dbh
* database handle
* @param string $id
* user id
* @param \Federator\Connector\Connector $connector
* connector to fetch use with
* @param \Federator\Cache\Cache|null $cache
* optional caching service
* @return \Federator\Data\FedUser[]
*/
public static function getFollowingForUser($dbh, $id, $connector, $cache)
{
// ask cache
if ($cache !== null) {
$following = $cache->getRemoteFollowingForUser($id);
if ($following !== false) {
return $following;
}
}
$following = [];
$sql = 'select target_user from follows where source_user = ?';
$stmt = $dbh->prepare($sql);
if ($stmt === false) {
throw new \Federator\Exceptions\ServerError("Followers::getFollowingForUser Failed to prepare statement");
}
$stmt->bind_param("s", $id);
$stmt->execute();
$followingIds = [];
$stmt->bind_result($sourceUser);
while ($stmt->fetch()) {
$followingIds[] = $sourceUser;
}
$stmt->close();
foreach ($followingIds as $followingId) {
try {
$user = \Federator\DIO\FedUser::getUserByName(
$dbh,
$followingId,
$cache,
);
} catch (\Throwable $e) {
error_log("Followers::getFollowingForUser Exception: " . $e->getMessage());
continue; // Skip this user if an exception occurs
}
if ($user !== false && $user->id !== null) {
$following[] = $user;
}
}
if ($following === []) {
// ask connector for user-id
$following = $connector->getRemoteFollowingForUser($id);
if ($following === false) {
$following = [];
}
}
// save posts to DB
if ($cache !== null) {
$cache->saveRemoteFollowingForUser($id, $following);
}
return $following;
}
/**
* get followers of federated external user (e.g. mastodon)
*
* @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 $id
* user id
* @return \Federator\Data\User[]
*/
public static function getFollowersByFedUser($dbh, $connector, $cache, $id)
{
$followers = [];
$sql = 'select source_user from follows where target_user = ?';
$stmt = $dbh->prepare($sql);
if ($stmt === false) {
throw new \Federator\Exceptions\ServerError("Followers::getFollowersByFedUser Failed to prepare statement");
}
$stmt->bind_param("s", $id);
$stmt->execute();
$followerIds = [];
$stmt->bind_result($sourceUser);
while ($stmt->fetch()) {
$followerIds[] = $sourceUser;
}
foreach ($followerIds as $followerId) {
try {
$user = \Federator\DIO\User::getUserByName(
$dbh,
$followerId,
$connector,
$cache
);
} catch (\Throwable $e) {
error_log("Followers::getFollowersByFedUser Exception: " . $e->getMessage());
continue; // Skip this user if an exception occurs
}
if ($user !== false && $user->id !== null) {
$followers[] = $user;
}
}
return $followers;
}
/**
* send follow request
*
* @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 source user
* @param string $_targetUser target user id
* @param string $host the host for generating the follow ID
* @return string|false the generated follow ID on success, false on failure
*/
public static function sendFollowRequest($dbh, $connector, $cache, $_user, $_targetUser, $host)
{
if ($dbh === false) {
throw new \Federator\Exceptions\ServerError("Followers::sendFollowRequest Failed to get database handle");
}
$user = \Federator\DIO\User::getUserByName(
$dbh,
$_user,
$connector,
$cache
);
if ($user === false || $user->id === null) {
throw new \Federator\Exceptions\FileNotFound();
}
$fedUser = \Federator\DIO\FedUser::getUserByName(
$dbh,
$_targetUser,
$cache
);
if ($fedUser === false || $fedUser->actorURL === null) {
throw new \Federator\Exceptions\FileNotFound();
}
$sourceUser = $user->id;
$idUrl = self::addFollow($dbh, $sourceUser, $fedUser->id, $host);
if ($idUrl === false) {
return false; // Failed to add follow
}
$followObj = new \Federator\Data\ActivityPub\Common\Follow();
$sourceUserUrl = 'https://' . $host . '/' . $sourceUser;
$followObj->setFObject($fedUser->actorURL);
$followObj->setAActor($sourceUserUrl);
$followObj->setID($idUrl);
// Send the Follow activity
$inboxUrl = $fedUser->inboxURL;
$json = json_encode($followObj, JSON_UNESCAPED_SLASHES);
if ($json === false) {
self::removeFollow($dbh, $sourceUser, $fedUser->id);
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) {
self::removeFollow($dbh, $sourceUser, $fedUser->id);
throw new \Exception('Failed to parse URL: ' . $inboxUrl);
}
if (!isset($parsed['host']) || !isset($parsed['path'])) {
self::removeFollow($dbh, $sourceUser, $fedUser->id);
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, $user->id); // OR from DB
if ($privateKey === false) {
self::removeFollow($dbh, $sourceUser, $fedUser->id);
throw new \Exception('Failed to get private key');
}
$pkeyId = openssl_pkey_get_private($privateKey);
if ($pkeyId === false) {
self::removeFollow($dbh, $sourceUser, $fedUser->id);
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 = 'https://' . $host . '/' . $user->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) {
self::removeFollow($dbh, $sourceUser, $fedUser->id);
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) {
self::removeFollow($dbh, $sourceUser, $fedUser->id);
throw new \Exception("Failed to send Follow activity: " . curl_error($ch));
} else {
$httpcode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if ($httpcode != 200 && $httpcode != 202) {
self::removeFollow($dbh, $sourceUser, $fedUser->id);
throw new \Exception("Unexpected HTTP code $httpcode: $response");
}
}
return $idUrl;
}
/**
* add follow
*
* @param \mysqli $dbh database handle
* @param string $sourceUser source user id
* @param string $targetUserId target user id
* @param string $host the host for generating the follow ID
* @return string|false the generated follow ID on success, false on failure
*/
public static function addFollow($dbh, $sourceUser, $targetUserId, $host)
{
// Check if we already follow this user
$sql = 'select id from follows where source_user = ? and target_user = ?';
$stmt = $dbh->prepare($sql);
if ($stmt === false) {
throw new \Federator\Exceptions\ServerError("Followers::addFollow Failed to prepare statement");
}
$stmt->bind_param("ss", $sourceUser, $targetUserId);
$foundId = 0;
$ret = $stmt->bind_result($foundId);
$stmt->execute();
if ($ret) {
$stmt->fetch();
}
$stmt->close();
if ($foundId != 0) {
return false; // Already following this user
}
// Generate a unique ID for the follow relationship
do {
$id = bin2hex(openssl_random_pseudo_bytes(16));
$idurl = 'https://' . $host . '/' . $sourceUser . '/' . $id;
// Check if the generated ID is unique
$sql = 'select id from follows where id = ?';
$stmt = $dbh->prepare($sql);
if ($stmt === false) {
throw new \Federator\Exceptions\ServerError("Followers::addFollow Failed to prepare id-check statement");
}
$stmt->bind_param("s", $idurl);
$foundId = 0;
$ret = $stmt->bind_result($foundId);
$stmt->execute();
if ($ret) {
$stmt->fetch();
}
$stmt->close();
} while ($foundId > 0);
// Add follow with created_at timestamp
$sql = 'insert into follows (id, source_user, target_user, created_at) values (?, ?, ?, NOW())';
$stmt = $dbh->prepare($sql);
if ($stmt === false) {
throw new \Federator\Exceptions\ServerError("Followers::addFollow Failed to prepare insert statement");
}
$stmt->bind_param("sss", $idurl, $sourceUser, $targetUserId);
$stmt->execute();
$stmt->close();
return $idurl; // Return the generated follow ID
}
/**
* add follow
*
* @param \mysqli $dbh database handle
* @param string $followId the follow ID to use (should be an external url)
* @param string $sourceUserId source user id
* @param string $targetUserId target user id
* @return boolean true on success, false on failure
*/
public static function addExternalFollow($dbh, $followId, $sourceUserId, $targetUserId)
{
// Check if we already follow this user
$sql = 'select id from follows where source_user = ? and target_user = ?';
$stmt = $dbh->prepare($sql);
if ($stmt === false) {
throw new \Federator\Exceptions\ServerError("Followers::addExternalFollow Failed to prepare statement");
}
$stmt->bind_param("ss", $sourceUserId, $targetUserId);
$foundId = 0;
$ret = $stmt->bind_result($foundId);
$stmt->execute();
if ($ret) {
$stmt->fetch();
}
$stmt->close();
if ($foundId != 0) {
return false; // Already following this user
}
// Add follow with created_at timestamp
$sql = 'insert into follows (id, source_user, target_user, created_at) values (?, ?, ?, NOW())';
$stmt = $dbh->prepare($sql);
if ($stmt === false) {
throw new \Federator\Exceptions\ServerError("Followers::addExternalFollow Failed to prepare insert statement");
}
$stmt->bind_param("sss", $followId, $sourceUserId, $targetUserId);
$stmt->execute();
$stmt->close();
return true;
}
/**
* generate new follow id
*
* @param \mysqli $dbh database handle
* @param string $hostUrl the host URL (e.g. federator URL)
* @return string the new follow id
*/
public static function generateNewFollowId($dbh, $hostUrl)
{
// Generate a new unique follow ID
do {
$newId = bin2hex(openssl_random_pseudo_bytes(16));
$newIdUrl = $hostUrl . '/' . $newId;
// Check if the generated ID is unique
$sql = 'select id from follows where id = ?';
$stmt = $dbh->prepare($sql);
if ($stmt === false) {
throw new \Federator\Exceptions\ServerError("Followers::generateNewFollowId Failed to prepare id-check statement");
}
$stmt->bind_param("s", $newIdUrl);
$foundId = 0;
$ret = $stmt->bind_result($foundId);
$stmt->execute();
if ($ret) {
$stmt->fetch();
}
$stmt->close();
} while ($foundId > 0);
return $newIdUrl;
}
/**
* remove follow
*
* @param \mysqli $dbh database handle
* @param string $sourceUser source user id
* @param string $targetUserId target user id
* @return string|false removed followId on success, false on failure
*/
public static function removeFollow($dbh, $sourceUser, $targetUserId)
{
// Combine retrieval and removal in one query using MySQL's RETURNING (if supported)
$sql = 'delete from follows where source_user = ? and target_user = ? RETURNING id';
$stmt = $dbh->prepare($sql);
if ($stmt !== false) {
$stmt->bind_param("ss", $sourceUser, $targetUserId);
if ($stmt->execute()) {
$stmt->bind_result($followId);
if ($stmt->fetch() === true) {
$stmt->close();
if (!empty($followId)) {
return $followId;
} else {
return false;
}
}
}
$stmt->close();
} else {
// Fallback for MySQL versions that do not support RETURNING
// First, fetch the id of the follow to be removed
$sql = 'select id from follows where source_user = ? and target_user = ?';
$stmt = $dbh->prepare($sql);
if ($stmt === false) {
throw new \Federator\Exceptions\ServerError("Followers::removeFollow Failed to prepare select statement");
}
$stmt->bind_param("ss", $sourceUser, $targetUserId);
$stmt->execute();
$stmt->bind_result($followId);
$found = $stmt->fetch();
$stmt->close();
if ($found === false || empty($followId)) {
return false; // No such follow found
}
// Now, delete the row
$sql = 'delete from follows where source_user = ? and target_user = ?';
$stmt = $dbh->prepare($sql);
if ($stmt === false) {
throw new \Federator\Exceptions\ServerError("Followers::removeFollow Failed to prepare delete statement");
}
$stmt->bind_param("ss", $sourceUser, $targetUserId);
$stmt->execute();
$affectedRows = $stmt->affected_rows;
$stmt->close();
return $affectedRows > 0 ? $followId : false;
}
return false;
}
}

View file

@ -13,326 +13,47 @@ namespace Federator\DIO;
*/
class Posts
{
/**
* get posts by user
*
* @param \mysqli $dbh @unused-param
* database handle
* @param string $userid
* @param string $id
* user id
* @param \Federator\Connector\Connector $connector
* connector to fetch use with
* @param \Federator\Cache\Cache|null $cache
* optional caching service
* @param string $min
* minimum date
* @param string $max
* maximum date
* @return \Federator\Data\ActivityPub\Common\Activity[]
* @param string $minId
* minimum ID
* @param string $maxId
* maximum ID
* @return \Federator\Data\ActivityPub\Common\APObject[]
*/
public static function getPostsByUser($dbh, $userid, $connector, $cache, $min, $max)
public static function getPostsByUser($dbh, $id, $connector, $cache, $minId, $maxId)
{
// ask cache
if ($cache !== null) {
$posts = $cache->getRemotePostsByUser($userid, $min, $max);
$posts = $cache->getRemotePostsByUser($id, $minId, $maxId);
if ($posts !== false) {
return $posts;
}
}
$posts = self::getPostsFromDb($dbh, $userid, $min, $max);
if ($posts === false) {
$posts = [];
}
// Only override $min if we found posts in our DB
$remoteMin = $min;
if (!empty($posts)) {
// Find the latest published date in the DB posts
$latestPublished = null;
foreach ($posts as $post) {
$published = $post->getPublished();
if ($published != null) {
$publishedStr = gmdate('Y-m-d H:i:s', $published);
if ($latestPublished === null || $publishedStr > $latestPublished) {
$latestPublished = $publishedStr;
}
}
}
if ($latestPublished !== null) {
$remoteMin = $latestPublished;
}
}
// Always fetch newer posts from connector (if any)
$newPosts = $connector->getRemotePostsByUser($userid, $remoteMin, $max);
if ($newPosts !== false && is_array($newPosts)) {
// Merge new posts with DB posts, avoiding duplicates by ID
$existingIds = [];
foreach ($posts as $post) {
$existingIds[$post->getID()] = true;
}
foreach ($newPosts as $newPost) {
if (!isset($existingIds[$newPost->getID()])) {
$posts[] = $newPost;
}
}
}
$originUrl = 'localhost';
if (isset($_SERVER['HTTP_HOST'])) {
$originUrl = $_SERVER['HTTP_HOST']; // origin of our request - e.g. mastodon
} elseif (isset($_SERVER['HTTP_ORIGIN'])) {
$origin = $_SERVER['HTTP_ORIGIN'];
$parsed = parse_url($origin);
if (isset($parsed) && isset($parsed['host'])) {
$parsedHost = $parsed['host'];
if (is_string($parsedHost) && $parsedHost !== "") {
$originUrl = $parsedHost;
}
}
}
if (!isset($originUrl) || $originUrl === "") {
$originUrl = 'localhost'; // Fallback to localhost if no origin is set
}
// save posts to DB
foreach ($posts as $post) {
if ($post->getID() !== "") {
self::savePost($dbh, $userid, $post);
}
switch (strtolower($post->getType())) {
case 'undo':
$object = $post->getObject();
if (is_object($object)) {
if (strtolower($object->getType()) === 'article') {
if ($object instanceof \Federator\Data\ActivityPub\Common\Article) {
$object = \Federator\DIO\Article::conditionalConvertToNote($object, $originUrl);
$post->setObject($object);
}
}
}
break;
case 'create':
case 'update':
$object = $post->getObject();
if (is_object($object)) {
if (strtolower($object->getType()) === 'article') {
if ($object instanceof \Federator\Data\ActivityPub\Common\Article) {
$object = \Federator\DIO\Article::conditionalConvertToNote($object, $originUrl);
$post->setObject($object);
}
}
}
break;
default:
break;
}
}
if ($cache !== null) {
$cache->saveRemotePostsByUser($userid, $posts);
}
return $posts;
}
/**
* Get posts for a user from the DB (optionally by date)
*
* @param \mysqli $dbh
* @param string $userId
* @param string|null $min
* @param string|null $max
* @return \Federator\Data\ActivityPub\Common\Activity[]|false
*/
public static function getPostsFromDb($dbh, $userId, $min = null, $max = null)
{
$sql = 'SELECT `id`, `url`, `user_id`, `actor`, `type`, `object`, `to`, `cc`, `published` FROM posts WHERE user_id = ?';
$params = [$userId];
$types = 's';
if ($min !== null && $min !== "") {
$sql .= ' AND published >= ?';
$params[] = $min;
$types .= 's';
}
if ($max !== null && $max !== "") {
$sql .= ' AND published <= ?';
$params[] = $max;
$types .= 's';
}
$sql .= ' ORDER BY published DESC';
$stmt = $dbh->prepare($sql);
if ($stmt === false) {
throw new \Federator\Exceptions\ServerError();
}
$stmt->bind_param($types, ...$params);
$stmt->execute();
$result = $stmt->get_result();
if (!($result instanceof \mysqli_result)) {
$stmt->close();
return false;
}
$posts = [];
while ($row = $result->fetch_assoc()) {
if (isset($row['to']) && $row['to'] !== null) {
$row['to'] = json_decode($row['to'], true);
}
if (isset($row['cc']) && $row['cc'] !== null) {
$row['cc'] = json_decode($row['cc'], true);
}
if (isset($row['object']) && $row['object'] !== null) {
$decoded = json_decode($row['object'], true);
// Only use decoded value if it's an array/object
if (is_array($decoded)) {
$row['object'] = $decoded;
}
}
if (isset($row['published']) && $row['published'] !== null) {
// If it's numeric, keep as int. If it's a string, try to parse as ISO 8601.
if (is_numeric($row['published'])) {
$row['published'] = intval($row['published'], 10);
} else {
// Try to parse as datetime string
$timestamp = strtotime($row['published']);
$row['published'] = $timestamp !== false ? $timestamp : null;
}
}
$activity = \Federator\Data\ActivityPub\Factory::newActivityFromJson($row);
if ($activity !== false) {
$posts[] = $activity;
// TODO: check our db
if ($posts === []) {
// ask connector for user-id
$posts = $connector->getRemotePostsByUser($id, $minId, $maxId);
if ($posts === false) {
$posts = [];
}
}
$stmt->close();
// save posts to DB
if ($cache !== null) {
$cache->saveRemotePostsByUser($id, $posts);
}
return $posts;
}
/**
* Save a post (insert or update)
*
* @param \mysqli $dbh
* @param string $userId
* @param \Federator\Data\ActivityPub\Common\Activity $post
* @param string|null $articleId the original id of the article
* (used to identify the source article in the remote system)
* @return bool
*/
public static function savePost($dbh, $userId, $post, $articleId = null)
{
$sql = 'INSERT INTO posts (
`id`, `url`, `user_id`, `actor`, `type`, `object`, `to`, `cc`, `published`, `article_id`
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
`url` = VALUES(`url`),
`user_id` = VALUES(`user_id`),
`actor` = VALUES(`actor`),
`type` = VALUES(`type`),
`object` = VALUES(`object`),
`to` = VALUES(`to`),
`cc` = VALUES(`cc`),
`published` = VALUES(`published`),
`article_id` = VALUES(`article_id`)';
$stmt = $dbh->prepare($sql);
if ($stmt === false) {
throw new \Federator\Exceptions\ServerError();
}
$id = $post->getID();
$url = $post->getUrl();
$actor = $post->getAActor();
$type = $post->getType();
$object = $post->getObject();
$objectJson = ($object instanceof \Federator\Data\ActivityPub\Common\APObject)
? json_encode($object)
: $object;
if ($objectJson === false) {
$objectJson = null;
}
if (is_object($object)) {
$id = $object->getID();
}
$to = $post->getTo();
$cc = $post->getCC();
$toJson = is_array($to) ? json_encode($to) : (is_string($to) ? json_encode([$to]) : null);
$ccJson = is_array($cc) ? json_encode($cc) : (is_string($cc) ? json_encode([$cc]) : null);
$published = $post->getPublished();
$publishedStr = $published ? gmdate('Y-m-d H:i:s', $published) : gmdate('Y-m-d H:i:s');
$stmt->bind_param(
"ssssssssss",
$id,
$url,
$userId,
$actor,
$type,
$objectJson,
$toJson,
$ccJson,
$publishedStr,
$articleId,
);
$result = $stmt->execute();
$stmt->close();
return $result;
}
/**
* Delete a post
*
* @param \mysqli $dbh
* @param string $id The post ID
* @return bool
*/
public static function deletePost($dbh, $id)
{
$sql = 'delete from posts where id = ?';
$stmt = $dbh->prepare($sql);
if ($stmt === false) {
throw new \Federator\Exceptions\ServerError();
}
$stmt->bind_param("s", $id);
$stmt->execute();
$affectedRows = $stmt->affected_rows;
$stmt->close();
return $affectedRows > 0;
}
/** retrieve original article id of post
*
* @param \mysqli $dbh
* @param \Federator\Data\ActivityPub\Common\Activity $post
* @return string|null
*/
public static function getOriginalArticleId($dbh, $post)
{
$sql = 'SELECT `article_id` FROM posts WHERE id = ?';
$stmt = $dbh->prepare($sql);
if ($stmt === false) {
throw new \Federator\Exceptions\ServerError();
}
$id = $post->getID();
$object = $post->getObject();
if (is_object($object)) {
$inReplyTo = $object->getInReplyTo();
if ($inReplyTo !== "") {
$id = $inReplyTo; // Use inReplyTo as ID if it's a string
} else {
$id = $object->getObject();
}
} elseif (is_string($object)) {
$id = $object; // If object is a string, use it directly
}
$stmt->bind_param("s", $id);
$articleId = null;
$ret = $stmt->bind_result($articleId);
$stmt->execute();
if ($ret) {
$stmt->fetch();
}
$stmt->close();
return $articleId;
}
}

View file

@ -17,7 +17,8 @@ class Stats
/**
* get remote stats
*
* @param \Federator\Main $main main instance
* @param \Federator\Main $main
* main instance
* @return \Federator\Data\Stats
*/
public static function getStats($main)

View file

@ -26,7 +26,7 @@ class User
$sql = 'select unix_timestamp(`validuntil`) from users where id=?';
$stmt = $dbh->prepare($sql);
if ($stmt === false) {
throw new \Federator\Exceptions\ServerError("User::addLocalUser Failed to prepare statement");
throw new \Federator\Exceptions\ServerError();
}
$stmt->bind_param("s", $_user);
$validuntil = 0;
@ -42,7 +42,6 @@ class User
throw new \Federator\Exceptions\ServerError();
}
$public = openssl_pkey_get_details($private_key)['key'];
$user->publicKey = $public;
$private = '';
openssl_pkey_export($private_key, $private);
$sql = 'insert into users (id, externalid, rsapublic, rsaprivate, validuntil,';
@ -50,7 +49,7 @@ class User
$sql .= ' values (?, ?, ?, ?, now() + interval 1 day, ?, ?, ?, ?, ?, ?, ?, ?)';
$stmt = $dbh->prepare($sql);
if ($stmt === false) {
throw new \Federator\Exceptions\ServerError("User::addLocalUser Failed to prepare create statement");
throw new \Federator\Exceptions\ServerError();
}
$registered = gmdate('Y-m-d H:i:s', $user->registered);
$stmt->bind_param(
@ -74,7 +73,7 @@ class User
$sql .= ' iconmediatype=?, iconurl=?, imagemediatype=?, imageurl=? where id=?';
$stmt = $dbh->prepare($sql);
if ($stmt === false) {
throw new \Federator\Exceptions\ServerError("User::addLocalUser Failed to prepare update statement");
throw new \Federator\Exceptions\ServerError();
}
$registered = gmdate('Y-m-d H:i:s', $user->registered);
$stmt->bind_param(
@ -101,42 +100,18 @@ class User
}
}
/**
* get private rsa key
* @return string|false key or false
*/
public static function getrsaprivate(\mysqli $dbh, string $_user)
{
$sql = "select rsaprivate from users where id=?";
$stmt = $dbh->prepare($sql);
if ($stmt === false) {
throw new \Federator\Exceptions\ServerError("User::getrsaprivate Failed to prepare statement");
}
$stmt->bind_param("s", $_user);
$ret = $stmt->bind_result($rsaPrivateKey);
$stmt->execute();
if ($ret) {
$stmt->fetch();
}
$stmt->close();
if ($rsaPrivateKey !== null) {
return $rsaPrivateKey;
}
return false;
}
/**
* extend the given user with internal data
* @param \mysqli $dbh database handle
* @param \Federator\Data\User $user user to extend
* @param string $_user user/profile name
*/
protected static function extendUser(\mysqli $dbh, $user, $_user): void
protected static function extendUser(\mysqli $dbh, $user, $_user) : void
{
$sql = 'select id,unix_timestamp(`validuntil`) from users where id=?';
$stmt = $dbh->prepare($sql);
if ($stmt === false) {
throw new \Federator\Exceptions\ServerError("User::extendUser Failed to prepare statement");
throw new \Federator\Exceptions\ServerError();
}
$stmt->bind_param("s", $_user);
$validuntil = 0;
@ -170,7 +145,6 @@ class User
public static function getUserByName($dbh, $_name, $connector, $cache)
{
$user = false;
// ask cache
if ($cache !== null) {
$user = $cache->getRemoteUserByName($_name);
@ -183,7 +157,7 @@ class User
$sql .= 'iconmediatype,iconurl,imagemediatype,imageurl from users where id=? and validuntil>=now()';
$stmt = $dbh->prepare($sql);
if ($stmt === false) {
throw new \Federator\Exceptions\ServerError("User::getUserByName Failed to prepare statement");
throw new \Federator\Exceptions\ServerError();
}
$stmt->bind_param("s", $_name);
$user = new \Federator\Data\User();
@ -205,7 +179,6 @@ class User
$stmt->fetch();
}
$stmt->close();
if ($user->id === null) {
// ask connector for user-id
$ruser = $connector->getRemoteUserByName($_name);

View file

@ -1,97 +0,0 @@
<?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\DIO;
/**
* IO functions related to votes
*/
class Votes
{
/**
* Add a vote (like/dislike)
*
* @param \mysqli $dbh
* @param string $userId The user who votes
* @param string $targetId The object being voted on (e.g., post id)
* @param string $type 'like' or 'dislike'
* @return string|false The generated vote ID on success, false on failure
*/
public static function addVote($dbh, $userId, $targetId, $type)
{
// Check if already voted
$sql = 'SELECT id FROM votes WHERE user_id = ? AND target_id = ? AND type = ?';
$stmt = $dbh->prepare($sql);
if ($stmt === false) {
throw new \Federator\Exceptions\ServerError();
}
$stmt->bind_param("sss", $userId, $targetId, $type);
$foundId = 0;
$ret = $stmt->bind_result($foundId);
$stmt->execute();
if ($ret) {
$stmt->fetch();
}
$stmt->close();
if ($foundId != 0) {
return false; // Already voted
}
// Generate a unique ID for the vote
do {
$id = bin2hex(openssl_random_pseudo_bytes(16));
// Check if the generated ID is unique
$sql = 'SELECT id FROM votes WHERE id = ?';
$stmt = $dbh->prepare($sql);
if ($stmt === false) {
throw new \Federator\Exceptions\ServerError();
}
$stmt->bind_param("s", $id);
$foundId = 0;
$ret = $stmt->bind_result($foundId);
$stmt->execute();
if ($ret) {
$stmt->fetch();
}
$stmt->close();
} while ($foundId > 0);
// Add vote with created_at timestamp
$sql = 'INSERT INTO votes (id, user_id, target_id, type, created_at) VALUES (?, ?, ?, ?, NOW())';
$stmt = $dbh->prepare($sql);
if ($stmt === false) {
throw new \Federator\Exceptions\ServerError();
}
$stmt->bind_param("ssss", $id, $userId, $targetId, $type);
$stmt->execute();
$stmt->close();
return $id; // Return the generated vote ID
}
/**
* Remove a vote (like/dislike)
*
* @param \mysqli $dbh
* @param string $userId
* @param string $targetId
* @return bool true on success
*/
public static function removeVote($dbh, $userId, $targetId)
{
$sql = 'DELETE FROM votes WHERE user_id = ? AND target_id = ?';
$stmt = $dbh->prepare($sql);
if ($stmt === false) {
throw new \Federator\Exceptions\ServerError();
}
$stmt->bind_param("ss", $userId, $targetId);
$stmt->execute();
$affectedRows = $stmt->affected_rows;
$stmt->close();
return $affectedRows > 0;
}
}

View file

@ -1,70 +0,0 @@
<?php
namespace Federator\Jobs;
class InboxJob extends \Federator\Api
{
/** @var array<string, mixed> $args Arguments for the job */
public $args = [];
/**
* cache instance
*
* @var \Federator\Cache\Cache $cache
*/
protected $cache;
/**
* remote connector
*
* @var \Federator\Connector\Connector $connector
*/
protected $connector = null;
/**
* database instance
*
* @var \Mysqli $dbh
*/
protected $dbh;
/**
* constructor
*/
public function __construct()
{
parent::__construct();
}
/**
* Set up environment for this job
*/
public function setUp(): void
{
$this->openDatabase();
$this->loadPlugins();
}
/**
* Perform the inbox job.
*
* @return bool true on success, false on failure
*/
public function perform(): bool
{
error_log("InboxJob: Starting job");
$user = $this->args['user'];
$recipientId = $this->args['recipientId'];
$activity = $this->args['activity'];
$inboxActivity = \Federator\Data\ActivityPub\Factory::newActivityFromJson($activity);
if ($inboxActivity === false) {
error_log("InboxJob: Failed to create inboxActivity from JSON");
return false;
}
\Federator\Api\FedUsers\Inbox::postForUser($this->dbh, $this->connector, $this->cache, $user, $recipientId, $inboxActivity);
return true;
}
}

View file

@ -1,74 +0,0 @@
<?php
namespace Federator\Jobs;
class NewContentJob extends \Federator\Api
{
/** @var array<string, mixed> $args Arguments for the job */
public $args = [];
/**
* cache instance
*
* @var \Federator\Cache\Cache $cache
*/
protected $cache;
/**
* remote connector
*
* @var \Federator\Connector\Connector $connector
*/
protected $connector = null;
/**
* database instance
*
* @var \Mysqli $dbh
*/
protected $dbh;
/**
* constructor
*/
public function __construct()
{
parent::__construct();
}
/**
* Set up environment for this job
*/
public function setUp(): void
{
$this->openDatabase();
$this->loadPlugins();
}
/**
* Perform the inbox job.
*
* @return bool true on success, false on failure
*/
public function perform(): bool
{
error_log("NewContentJob: Starting job");
$user = $this->args['user'];
$recipientId = $this->args['recipientId'];
$activity = $this->args['activity'];
$articleId = $this->args['articleId'] ?? null;
$activity = \Federator\Data\ActivityPub\Factory::newActivityFromJson($activity);
if ($activity === false) {
error_log("NewContentJob: Failed to create activity from JSON");
return false;
}
$domain = $this->config['generic']['externaldomain'];
$ourUrl = 'https://' . $domain;
\Federator\Api\V1\NewContent::postForUser($this->dbh, $this->connector, $this->cache, $ourUrl, $user, $recipientId, $activity, $articleId);
return true;
}
}

View file

@ -92,9 +92,12 @@ class Language
}
if (! isset($this->lang[$group])) {
$l = [];
$root = PROJECT_ROOT;
if (@file_exists($root . '/lang/federator/' . $this->uselang . "/$group.inc")) {
require($root . '/lang/federator/' . $this->uselang . "/$group.inc");
$root = $_SERVER['DOCUMENT_ROOT'];
if ($root === '') {
$root = '.';
}
if (@file_exists($root . '../lang/federator/' . $this->uselang . "/$group.inc")) {
require($root . '../lang/federator/' . $this->uselang . "/$group.inc");
$this->lang[$group] = $l;
}
}
@ -109,7 +112,7 @@ class Language
}
return $string;
}
$basedir = PROJECT_ROOT;
$basedir = $_SERVER['DOCUMENT_ROOT'] . '/../';
$fh = @fopen("$basedir/logs/missingtrans.txt", 'a');
if ($fh !== false) {
fwrite($fh, $this->uselang.":$group:$key\n");
@ -129,7 +132,7 @@ class Language
{
if (! isset($this->lang[$group])) {
$l = [];
require_once(PROJECT_ROOT . '/lang/' . $this->uselang . "/$group.inc");
require_once($_SERVER['DOCUMENT_ROOT'] . '/../lang/' . $this->uselang . "/$group.inc");
$this->lang[$group] = $l;
}
// @phan-suppress-next-line PhanPartialTypeMismatchReturn

View file

@ -33,12 +33,6 @@ class Main
* @var Connector\Connector $connector
*/
protected $connector = null;
/**
* remote host (f.e. https://contentnation.net)
*
* @var string $host
*/
protected $host = null;
/**
* response content type
*
@ -78,9 +72,9 @@ class Main
*/
public function __construct()
{
require_once(PROJECT_ROOT . '/vendor/autoload.php');
require_once($_SERVER['DOCUMENT_ROOT'] . '../vendor/autoload.php');
$this->responseCode = 200;
$rootDir = PROJECT_ROOT . '/';
$rootDir = $_SERVER['DOCUMENT_ROOT'] . '../';
$config = parse_ini_file($rootDir . 'config.ini', true);
if ($config !== false) {
$this->config = $config;
@ -99,7 +93,7 @@ class Main
public static function extractFromURI($param, $fallback = '')
{
$uri = $_SERVER['REQUEST_URI'];
$params = substr($uri, (int) (strpos($uri, '?') + 1));
$params = substr($uri, (int)(strpos($uri, '?') + 1));
$params = explode('&', $params);
foreach ($params as $p) {
$tokens = explode('=', $p);
@ -145,28 +139,20 @@ class Main
{
return $this->cache;
}
/**
* get connector
*
* @return \Federator\Connector\Connector
*/
* get connector
*
* @return \Federator\Connector\Connector
*/
public function getConnector()
{
return $this->connector;
}
/**
* get host (f.e. https://contentnation.net)
*
* @return string
*/
public function getHost()
{
return $this->host;
}
/**
* get config
* @return array<string, mixed>
* @return Array<String, Mixed>
*/
public function getConfig()
{
@ -186,10 +172,10 @@ class Main
/**
* load plugins
*/
public function loadPlugins(): void
public function loadPlugins() : void
{
if (array_key_exists('plugins', $this->config)) {
$basepath = PROJECT_ROOT . '/plugins/federator/';
$basepath = $_SERVER['DOCUMENT_ROOT'] . '../plugins/federator/';
$plugins = $this->config['plugins'];
foreach ($plugins as $name => $file) {
require_once($basepath . $file);
@ -213,8 +199,8 @@ class Main
$dbconf = $this->config["database"];
$this->dbh = new \mysqli(
$dbconf['host'],
$usernameOverride ?? (string) $dbconf['username'],
$passwordOverride ?? (string) $dbconf['password'],
$usernameOverride ?? (string)$dbconf['username'],
$passwordOverride ?? (string)$dbconf['password'],
$dbconf['database']
);
if ($this->dbh->connect_error !== null) {
@ -232,10 +218,10 @@ class Main
*/
public function renderTemplate($template, $data)
{
$rootDir = PROJECT_ROOT . '/';
$smarty = new \Smarty\Smarty();
$smarty->setCompileDir($rootDir . $this->config['templates']['compiledir']);
$smarty->setTemplateDir((string) realpath($rootDir . $this->config['templates']['path']));
$root = $_SERVER['DOCUMENT_ROOT'];
$smarty->setCompileDir($root . $this->config['templates']['compiledir']);
$smarty->setTemplateDir((string)realpath($root . $this->config['templates']['path']));
$smarty->assign('database', $this->dbh);
$smarty->assign('maininstance', $this);
foreach ($data as $key => $value) {
@ -246,57 +232,27 @@ class Main
/**
* set cache
*
* @param \Federator\Cache\Cache $cache the new cache
*/
public function setCache(Cache\Cache $cache): void
public function setCache(Cache\Cache $cache) : void
{
$this->cache = $cache;
}
/**
* set connector
*
* @param \Federator\Connector\Connector $connector the new connector
*/
public function setConnector(Connector\Connector $connector) : void
{
if (isset($this->connector)) {
# echo "main::setConnector Setting new connector will override old one.\n"; // TODO CHANGE TO LOG WARNING
}
$this->connector = $connector;
}
/**
* set host
*
* @param string $host the new host url
*/
public function setHost(string $host) : void
{
if (isset($this->host)) {
# echo "main::setHost Setting new host will override old one.\n"; // TODO CHANGE TO LOG WARNING
}
$this->host = $host;
}
/**
* set content type
*
* @param string $_type content type
*/
public function setContentType($_type): void
{
$this->contentType = $_type;
}
/**
* set response code
*
* @param int $code
* new response code
*/
public function setResponseCode(int $code): void
public function setResponseCode(int $code) : void
{
$this->responseCode = $code;
}
@ -314,7 +270,7 @@ class Main
* optional parameters
* @return string translation
*/
public static function translate(?string $lang, string $group, string $key, array $parameters = array()): string
public static function translate(?string $lang, string $group, string $key, array $parameters = array()) : string
{
$l = new Language($lang);
return $l->printlang($group, $key, $parameters);
@ -325,7 +281,7 @@ class Main
*
* @param ?string $lang
*/
public static function validLanguage(?string $lang): bool
public static function validLanguage(?string $lang) : bool
{
$language = new Language($lang);
if ($language->getLang() === $lang) {

View file

@ -24,13 +24,14 @@ class Maintenance
{
date_default_timezone_set("Europe/Berlin");
spl_autoload_register(static function (string $className) {
include PROJECT_ROOT . '/php/' . str_replace("\\", "/", strtolower($className)) . '.php';
$root = $_SERVER['DOCUMENT_ROOT'];
include $root . '../php/' . str_replace("\\", "/", strtolower($className)) . '.php';
});
if ($argc < 2) {
self::printUsage();
}
// pretend that we are running from web directory
define('PROJECT_ROOT', dirname(__DIR__, 2));
$_SERVER['DOCUMENT_ROOT'] = realpath('../../htdocs') . '/';
$main = new \Federator\Main();
switch ($argv[1]) {
case 'dbupgrade':
@ -70,7 +71,7 @@ class Maintenance
}
}
echo "current version: $version\n";
$root = PROJECT_ROOT . '/';
$root = $_SERVER['DOCUMENT_ROOT'] . '../';
$updateFolder = opendir($root . 'sql');
if ($updateFolder === false) {
die();

View file

@ -1,23 +0,0 @@
<?php
define('PROJECT_ROOT', dirname(__DIR__, 3));
require_once PROJECT_ROOT . '/vendor/autoload.php';
$config = parse_ini_file(PROJECT_ROOT . '/rediscache.ini');
// Set the Redis backend for Resque
$redisUrl = sprintf(
'redis://%s:%s@%s:%d',
urlencode($config['username']),
urlencode($config['password']),
$config['host'],
intval($config['port'], 10)
);
\Resque::setBackend($redisUrl);
// Start the worker
$worker = new \Resque_Worker(['inbox']);
fwrite(STDOUT, "*** Starting worker for inbox queue\n");
$worker->work(10); // 10 seconds interval

View file

@ -6,11 +6,11 @@
* @author Sascha Nitsch (grumpydeveloper)
**/
namespace Federator\Connector;
namespace Federator\Connector;
/**
* Connector to ContentNation.net
*/
/**
* Connector to ContentNation.net
*/
class ContentNation implements Connector
{
/**
@ -41,68 +41,12 @@ class ContentNation implements Connector
*/
public function __construct($main)
{
$config = parse_ini_file(PROJECT_ROOT . '/contentnation.ini', true);
$config = parse_ini_file($_SERVER['DOCUMENT_ROOT'] . '../contentnation.ini', true);
if ($config !== false) {
$this->config = $config;
}
$this->service = $config['contentnation']['service-uri'];
$this->main = $main;
$this->main->setHost($this->service);
}
/**
* get followers of given user
*
* @param string $userId user id
* @return \Federator\Data\FedUser[]|false
*/
public function getRemoteFollowersOfUser($userId)
{
// todo implement queue for this
if (preg_match("#^([^@]+)@([^/]+)#", $userId, $matches) == 1) {
$userId = $matches[1];
}
$remoteURL = $this->service . '/api/profile/' . urlencode($userId) . '/followers';
[$response, $info] = \Federator\Main::getFromRemote($remoteURL, []);
if ($info['http_code'] != 200) {
print_r($info);
return false;
}
$r = json_decode($response, true);
if ($r === false || $r === null || !is_array($r)) {
return false;
}
$followers = [];
return $followers;
}
/**
* get following of given user
*
* @param string $userId user id
* @return \Federator\Data\FedUser[]|false
*/
public function getRemoteFollowingForUser($userId)
{
// todo implement queue for this
if (preg_match("#^([^@]+)@([^/]+)#", $userId, $matches) == 1) {
$userId = $matches[1];
}
$remoteURL = $this->service . '/api/profile/' . urlencode($userId) . '/following';
[$response, $info] = \Federator\Main::getFromRemote($remoteURL, []);
if ($info['http_code'] != 200) {
print_r($info);
return false;
}
$r = json_decode($response, true);
if ($r === false || $r === null || !is_array($r)) {
return false;
}
$followers = [];
return $followers;
}
/**
@ -111,14 +55,11 @@ class ContentNation implements Connector
* @param string $userId user id
* @param string $min min date
* @param string $max max date
* @return \Federator\Data\ActivityPub\Common\Activity[]|false
* @return \Federator\Data\ActivityPub\Common\APObject[]|false
*/
public function getRemotePostsByUser($userId, $min, $max)
{
if (preg_match("#^([^@]+)@([^/]+)#", $userId, $matches) == 1) {
$userId = $matches[1];
}
$remoteURL = $this->service . '/api/profile/' . urlencode($userId) . '/activities';
$remoteURL = $this->service . '/api/profile/' . $userId . '/activities';
if ($min !== '') {
$remoteURL .= '&minTS=' . urlencode($min);
}
@ -137,122 +78,110 @@ class ContentNation implements Connector
$posts = [];
if (array_key_exists('activities', $r)) {
$activities = $r['activities'];
$config = $this->main->getConfig();
$domain = $config['generic']['externaldomain'];
$ourUrl = 'https://' . $domain;
$host = $_SERVER['SERVER_NAME'];
$imgpath = $this->config['userdata']['path'];
$userdata = $this->config['userdata']['url'];
foreach ($activities as $activity) {
$create = new \Federator\Data\ActivityPub\Common\Create();
$create->setAActor('https://' . $host .'/' . $userId);
$create->setID($activity['id'])
->setPublished($activity['timestamp'])
->addTo("https://www.w3.org/ns/activitystreams#Public")
->addCC('https://' . $host . '/' . $userId . '/followers.json');
switch ($activity['type']) {
case 'Article':
$create = new \Federator\Data\ActivityPub\Common\Create();
$create->setAActor($ourUrl . '/' . $userId);
$create->setID($activity['id'])
->setPublished($activity['published'] ?? $activity['timestamp'])
->addTo($ourUrl . '/' . $userId . '/followers')
->addCC("https://www.w3.org/ns/activitystreams#Public");
$create->setURL($ourUrl . '/' . $activity['profilename'] . '/' . $activity['name']);
$create->setID($ourUrl . '/' . $activity['profilename'] . '/' . $activity['id']);
$create->setURL('https://'.$host . '/' . $activity['language'] . '/' . $userId . '/'
. $activity['name']);
$apArticle = new \Federator\Data\ActivityPub\Common\Article();
if (array_key_exists('tags', $activity)) {
foreach ($activity['tags'] as $tag) {
$href = $ourUrl . '/search.htm?tagsearch=' . urlencode($tag);
$href = 'https://' . $host . '/' . $activity['language']
. '/search.htm?tagsearch=' . urlencode($tag);
$tagObj = new \Federator\Data\ActivityPub\Common\Tag();
$tagObj->setHref($href)
->setName('#' . urlencode(str_replace(' ', '', $tag)))
->setType('Hashtag');
->setName('#' . urlencode(str_replace(' ', '', $tag)))
->setType('Hashtag');
$apArticle->addTag($tagObj);
}
}
$apArticle->setPublished($activity['published'])
->setName($activity['title'])
->setAttributedTo($ourUrl . '/' . $activity['profilename'])
->setContent(
$activity['teaser'] ??
$this->main->translate(
$activity['language'],
'article',
'newarticle'
)
)
->addTo("https://www.w3.org/ns/activitystreams#Public")
->addCC($ourUrl . '/' . $userId . '/followers.json');
->setName($activity['title'])
->setAttributedTo('https://' . $host .'/' . $activity['profilename'])
->setContent(
$activity['teaser'] ??
$this->main->translate(
$activity['language'],
'article',
'newarticle'
)
)
->addTo("https://www.w3.org/ns/activitystreams#Public")
->addCC('https://' . $host . '/' . $userId . '/followers.json');
$articleimage = $activity['imagealt'] ??
$this->main->translate($activity['language'], 'article', 'image');
$idurl = $ourUrl . '/' . $userId . '/' . $activity['name'];
$idurl = 'https://' . $host . '/' . $activity['language']
. '/' . $userId . '/'. $activity['name'];
$apArticle->setID($idurl)
->setURL($idurl);
->setURL($idurl);
$image = $activity['image'] ?? $activity['profileimg'];
$path = $imgpath . $activity['profile'] . '/' . $image;
$type = file_exists($path) ? mime_content_type($path) : false;
$mediaType = ($type !== false && !str_starts_with($type, 'text/'))
? $type
: 'image/jpeg';
$mediaType = @mime_content_type($imgpath . $activity['profile'] . '/' . $image) | 'text/plain';
$img = new \Federator\Data\ActivityPub\Common\Image();
$img->setMediaType($mediaType)
->setName($articleimage)
->setURL($userdata . '/' . $activity['profile'] . $image);
->setName($articleimage)
->setURL($userdata . '/' . $activity['profile'] . $image);
$apArticle->addImage($img);
$create->setObject($apArticle);
$posts[] = $create;
break; // Article
case 'Comment':
$create = new \Federator\Data\ActivityPub\Common\Create();
$create->setAActor($ourUrl . '/' . $userId);
$create->setID($activity['id'])
->setPublished($activity['published'] ?? $activity['timestamp'])
->addTo($ourUrl . '/' . $userId . '/followers')
->addCC("https://www.w3.org/ns/activitystreams#Public");
$commentJson = $activity;
$commentJson['type'] = 'Note';
$commentJson['summary'] = $activity['subject'];
$commentJson['id'] = $ourUrl . '/' . $activity['articleOwnerName'] . '/' . $activity['articleName'] . '#' . $activity['id'];
$note = \Federator\Data\ActivityPub\Factory::newFromJson($commentJson, "");
if ($note === null) {
error_log("ContentNation::getRemotePostsByUser couldn't create comment");
$note = new \Federator\Data\ActivityPub\Common\Activity('Comment');
$create->setObject($note);
break;
}
$note->setID($commentJson['id']);
if (!isset($commentJson['parent']) || $commentJson['parent'] === null) {
$note->setInReplyTo($ourUrl . '/' . $activity['articleOwnerName'] . '/' . $activity['articleName']);
} else {
$note->setInReplyTo($ourUrl . '/' . $activity['articleOwnerName'] . '/' . $activity['articleName'] . "#" . $commentJson['parent']);
}
$url = $ourUrl . '/' . $activity['articleOwnerName'] . '/' . $activity['articleName'] . '#' . $activity['id'];
$create->setURL($url);
$create->setID($url);
$create->setObject($note);
$posts[] = $create;
// echo "comment\n";
// print_r($activity);
break; // Comment
case 'Vote':
// Build Like/Dislike as top-level activity
$likeType = $activity['upvote'] === true ? 'Like' : 'Dislike';
$like = new \Federator\Data\ActivityPub\Common\Activity($likeType);
$like->setAActor($ourUrl . '/' . $userId);
$like->setID($activity['id'])
->setPublished($activity['published'] ?? $activity['timestamp']);
// $like->addTo("https://www.w3.org/ns/activitystreams#Public")
// ->addCC('https://' . $domain . '/' . $userId . '/followers');
$like->setSummary(
$this->main->translate(
$activity['articlelang'],
'vote',
$likeType === 'Like' ? 'like' : 'dislike',
[$activity['username']]
)
);
$objectUrl = $ourUrl . '/' . $userId . '/' . $activity['articlename'];
$like->setURL($objectUrl . '#' . $activity['id']);
$like->setID($objectUrl . '#' . $activity['id']);
$like->setObject($objectUrl);
$posts[] = $like;
$url = 'https://'.$host . '/' . $activity['articlelang'] . $userId . '/'
. $activity['articlename'];
$url .= '/vote/' . $activity['id'];
$create->setURL($url);
if ($activity['upvote'] === true) {
$like = new \Federator\Data\ActivityPub\Common\Activity('Like');
$like->setSummary(
$this->main->translate(
$activity['articlelang'],
'vote',
'like',
[$activity['username']]
)
);
} else {
$like = new \Federator\Data\ActivityPub\Common\Activity('Dislike');
$like->setSummary(
$this->main->translate(
$activity['articlelang'],
'vote',
'dislike',
[$activity['username']]
)
);
}
$actor = new \Federator\Data\ActivityPub\Common\APObject('Person');
$actor->setName($activity['username']);
$like->setActor($actor);
$url = 'https://' . $host . '/' . $activity['articlelang']
. '/' . $userId . '/'. $activity['articlename'];
if ($activity['comment'] !== '') {
$url .= '/comment/' . $activity['comment'];
}
$type = 'Article';
switch ($activity['votetype']) {
case 'comment':
$type = 'Comment';
break;
}
$object = new \Federator\Data\ActivityPub\Common\APObject($type);
$object->setHref($url);
$like->setObject($object);
$create->setObject($like);
$posts[] = $create;
break; // Vote
}
}
@ -283,7 +212,7 @@ class ContentNation implements Connector
return $stats;
}
/**
/**
* get remote user by given name
*
* @param string $_name user/profile name
@ -292,16 +221,10 @@ class ContentNation implements Connector
public function getRemoteUserByName(string $_name)
{
// validate name
if (preg_match("/^[a-zA-Z@0-9\._\-]+$/", $_name) != 1) {
if (preg_match("/^[a-zA-Z0-9_\-]+$/", $_name) != 1) {
return false;
}
// make sure we only get name part, without domain
if (preg_match("#^([^@]+)@([^/]+)#", $_name, $matches) == 1) {
$name = $matches[1];
} else {
$name = $_name;
}
$remoteURL = $this->service . '/api/users/info?user=' . urlencode($name);
$remoteURL = $this->service . '/api/users/info?user=' . urlencode($_name);
$headers = ['Accept: application/json'];
[$response, $info] = \Federator\Main::getFromRemote($remoteURL, $headers);
if ($info['http_code'] != 200) {
@ -337,7 +260,7 @@ class ContentNation implements Connector
if (preg_match("/^[a-z0-9]{16}$/", $_session) != 1) {
return false;
}
if (preg_match("/^[a-zA-Z@0-9\._\-]+$/", $_user) != 1) {
if (preg_match("/^[a-zA-Z0-9_\-]+$/", $_user) != 1) {
return false;
}
$remoteURL = $this->service . '/api/users/permissions?profile=' . urlencode($_user);
@ -348,7 +271,7 @@ class ContentNation implements Connector
return false;
}
$r = json_decode($response, true);
if ($r === false || !is_array($r) || !array_key_exists($_user, $r)) {
if ($r === false || !is_array($r) || !array_key_exists($_user, $r)) {
return false;
}
$user = $this->getRemoteUserByName($_user);
@ -363,612 +286,6 @@ class ContentNation implements Connector
}
return $user;
}
/**
* Convert jsonData to Activity format
*
* @param array<string, mixed> $jsonData the json data from our platfrom
* @param string $articleId the original id of the article (if applicable)
* (used to identify the article in the remote system)
* @return \Federator\Data\ActivityPub\Common\Activity|false
*/
public function jsonToActivity($jsonData, &$articleId)
{
$returnActivity = false;
// Common fields for all activity types
$ap = [
'@context' => 'https://www.w3.org/ns/activitystreams',
'type' => 'Create', // Default to 'Create'
'id' => $jsonData['id'] ?? null,
'actor' => $jsonData['actor'] ?? null,
];
$config = $this->main->getConfig();
$domain = $config['generic']['externaldomain'];
$ourUrl = 'https://' . $domain;
// Extract actorName as the last segment of the actor URL (after the last '/')
$actorData = $jsonData['actor'] ?? null;
$actorName = $actorData['name'] ?? null;
$ap['actor'] = $ourUrl . '/' . $actorName;
if (isset($jsonData['type'])) {
switch ($jsonData['type']) {
case 'undo':
$ap['type'] = 'Undo';
$ap['actor'] = $ourUrl . '/' . $actorName;
$objectType = $jsonData['object']['type'] ?? null;
if ($objectType === "article") {
$articleName = $jsonData['object']['name'] ?? null;
$ownerName = $jsonData['object']['ownerName'] ?? null;
$ap['id'] = $ourUrl . '/' . $ownerName . '/' . $articleName . '/undo';
$ap['object'] = self::generateObjectJson($ourUrl, $jsonData);
} elseif ($objectType === "comment") {
$articleName = $jsonData['object']['articleName'] ?? null;
$ownerName = $jsonData['object']['articleOwnerName'] ?? null;
$commentId = $jsonData['object']['id'] ?? null;
$ap['id'] = $ourUrl . '/' . $ownerName . '/' . $articleName . '#' . $commentId . '/undo';
$ap['object'] = self::generateObjectJson($ourUrl, $jsonData);
} elseif ($objectType === "vote") {
$id = $jsonData['object']['id'] ?? null;
$articleName = $jsonData['object']['articleName'] ?? null;
$articleOwnerName = $jsonData['object']['articleOwnerName'] ?? null;
$ap['id'] = $ourUrl . '/' . $articleOwnerName . '/' . $articleName . '#' . $id . '/undo';
$ap['published'] = $jsonData['object']['published'] ?? null;
$ap['actor'] = $ourUrl . '/' . $actorName;
$ap['object']['id'] = $ourUrl . '/' . $articleOwnerName . '/' . $articleName . '#' . $id;
$ap['object']['url'] = $ourUrl . '/' . $articleOwnerName . '/' . $articleName . '#' . $id;
$ap['object']['actor'] = $ourUrl . '/' . $actorName;
if ($jsonData['object']['vote']['value'] == 1) {
$ap['object']['type'] = 'Like';
} elseif ($jsonData['object']['vote']['value'] == 0) {
$ap['object']['type'] = 'Dislike';
} else {
error_log("ContentNation::jsonToActivity unknown vote value: {$jsonData['object']['vote']['value']}");
break;
}
$ap['object']['object'] = self::generateObjectJson($ourUrl, $jsonData);
} else {
error_log("ContentNation::jsonToActivity unknown undo type: {$objectType}");
break;
}
$returnActivity = \Federator\Data\ActivityPub\Factory::newActivityFromJson($ap);
if ($returnActivity === false) {
error_log("ContentNation::jsonToActivity couldn't create undo");
$returnActivity = new \Federator\Data\ActivityPub\Common\Undo();
} else {
$returnActivity->setID($ap['id']);
$returnActivity->setURL($ap['id']);
}
break;
default:
// Handle unsupported types or fallback to default behavior
throw new \InvalidArgumentException("ContentNation::jsonToActivity Unsupported type: {$jsonData['type']}");
}
} else {
// Handle specific fields based on the type
switch ($jsonData['object']['type']) {
case 'article':
$articleName = $jsonData['object']['name'] ?? null;
$articleOwnerName = $jsonData['object']['ownerName'] ?? null;
// Set Create-level fields
$updatedOn = $jsonData['object']['modified'] ?? null;
$originalPublished = $jsonData['object']['published'] ?? null;
$update = $updatedOn !== $originalPublished;
$ap['published'] = $updatedOn ?? $originalPublished;
$ap['id'] = $ourUrl . "/" . $articleOwnerName . "/" . $articleName;
$ap['url'] = $ourUrl . "/" . $articleOwnerName . "/" . $articleName;
$ap['type'] = $update ? 'Update' : 'Create';
$ap['actor'] = $ourUrl . '/' . $actorName;
if ($update) {
$ap['id'] .= '#update';
$ap['url'] .= '#update';
}
$ap['cc'] = ['https://www.w3.org/ns/activitystreams#Public'];
if (isset($jsonData['options'])) {
if (isset($jsonData['options']['informFollowers'])) {
if ($jsonData['options']['informFollowers'] === true) {
$ap['to'][] = $ourUrl . '/' . $actorName . '/followers';
}
}
}
$ap['object'] = self::generateObjectJson($ourUrl, $jsonData);
$returnActivity = \Federator\Data\ActivityPub\Factory::newActivityFromJson($ap);
if ($returnActivity === false) {
error_log("ContentNation::jsonToActivity couldn't create article");
$returnActivity = new \Federator\Data\ActivityPub\Common\Activity('Create');
} else {
$returnActivity->setID($ap['id']);
$returnActivity->setURL($ap['url']);
}
$articleId = $jsonData['object']['id']; // Set the article ID for the activity
break;
case 'comment':
$commentId = $jsonData['object']['id'] ?? null;
$articleName = $jsonData['object']['articleName'] ?? null;
$articleOwnerName = $jsonData['object']['articleOwnerName'] ?? null;
// Set Create-level fields
$ap['published'] = $jsonData['object']['published'] ?? null;
$ap['actor'] = $ourUrl . '/' . $actorName;
$ap['id'] = $ourUrl . "/" . $articleOwnerName . "/" . $articleName . "#" . $commentId;
$ap['url'] = $ourUrl . "/" . $articleOwnerName . "/" . $articleName . "#" . $commentId;
$ap['type'] = 'Create';
$ap['cc'] = ['https://www.w3.org/ns/activitystreams#Public'];
if (isset($jsonData['options'])) {
if (isset($jsonData['options']['informFollowers'])) {
if ($jsonData['options']['informFollowers'] === true) {
if ($actorName !== $articleOwnerName) {
$ap['to'][] = $ourUrl . '/' . $articleOwnerName;
}
$ap['to'][] = $ourUrl . '/' . $actorName . '/followers';
}
}
}
$ap['object'] = self::generateObjectJson($ourUrl, $jsonData);
$returnActivity = \Federator\Data\ActivityPub\Factory::newActivityFromJson($ap);
if ($returnActivity === false) {
error_log("ContentNation::jsonToActivity couldn't create comment");
$returnActivity = new \Federator\Data\ActivityPub\Common\Activity('Create');
} else {
$returnActivity->setID($ap['id']);
$returnActivity->setURL($ap['url']);
}
$articleId = $jsonData['object']['articleId']; // Set the article ID for the activity
break;
case 'vote':
$articleName = $jsonData['object']['articleName'] ?? null;
$articleOwnerName = $jsonData['object']['articleOwnerName'] ?? null;
$voteId = $jsonData['object']['id'] ?? null;
$ap['published'] = $jsonData['object']['published'] ?? null;
$ap['actor'] = $ourUrl . '/' . $actorName;
$ap['id'] = $ourUrl . '/' . $articleOwnerName . '/' . $articleName . '#' . $voteId;
$ap['url'] = $ourUrl . '/' . $articleOwnerName . '/' . $articleName . '#' . $voteId;
if ($jsonData['object']['vote']['value'] == 1) {
$ap['type'] = 'Like';
} elseif ($jsonData['object']['vote']['value'] == 0) {
$ap['type'] = 'Dislike';
} else {
error_log("ContentNation::jsonToActivity unknown vote value: {$jsonData['object']['vote']['value']}");
break;
}
$ap['object'] = self::generateObjectJson($ourUrl, $jsonData);
$returnActivity = \Federator\Data\ActivityPub\Factory::newActivityFromJson($ap);
if ($returnActivity === false) {
error_log("ContentNation::jsonToActivity couldn't create vote");
if ($ap['type'] === "Like") {
$returnActivity = new \Federator\Data\ActivityPub\Common\Like();
} elseif ($ap['type'] === "Dislike") {
$returnActivity = new \Federator\Data\ActivityPub\Common\Dislike();
} else {
$returnActivity = new \Federator\Data\ActivityPub\Common\Undo();
}
} else {
$returnActivity->setID($ap['id']);
$returnActivity->setURL($ap['url']);
}
$articleId = $jsonData['object']['articleId']; // Set the article ID for the activity
break;
default:
// Handle unsupported types or fallback to default behavior
throw new \InvalidArgumentException("ContentNation::jsonToActivity Unsupported object type: {$jsonData['type']}");
}
}
return $returnActivity;
}
/**
* Convert jsonData to Activity format
*
* @param string $ourUrl the url of our instance
* @param array<string, mixed> $jsonData the json data from our platfrom
* @return array|string|false the json object data or false
*/
private static function generateObjectJson($ourUrl, $jsonData)
{
$objectType = $jsonData['object']['type'] ?? null;
$actorData = $jsonData['actor'] ?? null;
$actorName = $actorData['name'] ?? null;
$actorUrl = $ourUrl . '/' . $actorName;
if ($objectType === "article") {
$articleName = $jsonData['object']['name'] ?? null;
$articleOwnerName = $jsonData['object']['ownerName'] ?? null;
$updatedOn = $jsonData['object']['modified'] ?? null;
$originalPublished = $jsonData['object']['published'] ?? null;
$update = $updatedOn !== $originalPublished;
$returnJson = [
'type' => 'Article',
'id' => $ourUrl . "/" . $articleOwnerName . "/" . $articleName,
'name' => $jsonData['object']['title'] ?? null,
'published' => $originalPublished,
'summary' => $jsonData['object']['summary'] ?? null,
'content' => $jsonData['object']['content'] ?? null,
'attributedTo' => $actorUrl,
'url' => $ourUrl . "/" . $articleOwnerName . "/" . $articleName,
'cc' => ['https://www.w3.org/ns/activitystreams#Public'],
];
if ($update) {
$returnJson['updated'] = $updatedOn;
}
if (isset($jsonData['object']['tags'])) {
if (is_array($jsonData['object']['tags'])) {
foreach ($jsonData['object']['tags'] as $tag) {
$returnJson['tags'][] = $tag;
}
} elseif (is_string($jsonData['object']['tags']) && $jsonData['object']['tags'] !== '') {
// If it's a single tag as a string, add it as a one-element array
$returnJson['tags'][] = $jsonData['object']['tags'];
}
}
if (isset($jsonData['options'])) {
if (isset($jsonData['options']['informFollowers'])) {
if ($jsonData['options']['informFollowers'] === true) {
$returnJson['to'][] = $ourUrl . '/' . $actorName . '/followers';
}
}
}
} elseif ($objectType === "comment") {
$commentId = $jsonData['object']['id'] ?? null;
$articleName = $jsonData['object']['articleName'] ?? null;
$articleOwnerName = $jsonData['object']['articleOwnerName'] ?? null;
$returnJson = [
'type' => 'Note',
'id' => $ourUrl . "/" . $articleOwnerName . "/" . $articleName . "#" . $commentId,
'url' => $ourUrl . "/" . $articleOwnerName . "/" . $articleName . "#" . $commentId,
'attributedTo' => $actorUrl,
'content' => $jsonData['object']['content'] ?? null,
'summary' => $jsonData['object']['summary'] ?? null,
'published' => $jsonData['object']['published'] ?? null,
'cc' => ['https://www.w3.org/ns/activitystreams#Public'],
];
if (isset($jsonData['options'])) {
if (isset($jsonData['options']['informFollowers'])) {
if ($jsonData['options']['informFollowers'] === true) {
$returnJson['to'][] = $ourUrl . '/' . $actorName . '/followers';
}
}
}
$replyType = $jsonData['object']['inReplyTo']['type'] ?? null;
if ($replyType === "article") {
$returnJson['inReplyTo'] = $ourUrl . "/" . $articleOwnerName . "/" . $articleName;
} elseif ($replyType === "comment") {
$returnJson['inReplyTo'] = $ourUrl . "/" . $articleOwnerName . "/" . $articleName . "#" . $jsonData['object']['inReplyTo']['id'];
} else {
error_log("ContentNation::generateObjectJson for comment - unknown inReplyTo type: {$replyType}");
}
} elseif ($objectType === "vote") {
$votedOn = $jsonData['object']['type'] ?? null;
$articleName = $jsonData['object']['articleName'] ?? null;
$articleOwnerName = $jsonData['object']['articleOwnerName'] ?? null;
$objectId = $ourUrl . '/' . $articleOwnerName . '/' . $articleName;
if ($votedOn === "comment") {
$objectId .= '#' . $jsonData['object']['commentId'];
}
$returnJson = $objectId;
} else {
error_log("ContentNation::generateObjectJson unknown object type: {$objectType}");
return false;
}
return $returnJson;
}
/**
* send CN-friendly json from ActivityPub activity
*
* @param \Federator\Data\FedUser $sender the user of the sender
* @param \Federator\Data\ActivityPub\Common\Activity $activity the activity
* @return boolean did we successfully send the activity?
*/
public function sendActivity($sender, $activity)
{
$targetUrl = $this->service;
// Convert ActivityPub activity to ContentNation JSON format and retrieve target url
$jsonData = self::activityToJson($this->main->getDatabase(), $this->service, $activity, $targetUrl);
if ($jsonData === false) {
error_log("ContentNation::sendActivity failed to convert activity to JSON");
return false;
}
$json = json_encode($jsonData, 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($targetUrl);
if ($parsed === false) {
throw new \Exception('Failed to parse URL: ' . $targetUrl);
}
if (!isset($parsed['host']) || !isset($parsed['path'])) {
throw new \Exception('Invalid target 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}";
$pKeyPath = PROJECT_ROOT . '/' . $this->main->getConfig()['keys']['federatorPrivateKeyPath'];
$privateKeyPem = file_get_contents($pKeyPath);
if ($privateKeyPem === false) {
http_response_code(500);
throw new \Federator\Exceptions\PermissionDenied("Private key couldn't be determined");
}
$pkeyId = openssl_pkey_get_private($privateKeyPem);
if ($pkeyId === false) {
throw new \Exception('Invalid private key');
}
openssl_sign($signatureString, $signature, $pkeyId, OPENSSL_ALGO_SHA256);
$signature_b64 = base64_encode($signature);
$signatureHeader = 'algorithm="rsa-sha256",headers="(request-target) host date digest",signature="' . $signature_b64 . '"';
$ch = curl_init($targetUrl);
if ($ch === false) {
throw new \Exception('Failed to initialize cURL');
}
$headers = [
'Host: ' . $extHost,
'Date: ' . $date,
'Digest: ' . $digest,
'Content-Type: application/json',
'Signature: ' . $signatureHeader,
'Accept: application/json',
'Username: ' . 'ap:' . $sender->id,
];
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);
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 true;
}
/**
* Convert ActivityPub activity to ContentNation JSON format
*
* @param \mysqli $dbh database handle
* @param string $serviceUrl the service URL
* @param \Federator\Data\ActivityPub\Common\Activity $activity the activity
* @param string $targetUrl the target URL for the activity
* @return array<string, mixed>|false the json data or false on failure
*/
private static function activityToJson($dbh, $serviceUrl, \Federator\Data\ActivityPub\Common\Activity $activity, string &$targetUrl)
{
$type = strtolower($activity->getType());
switch ($type) {
case 'create':
case 'update':
$object = $activity->getObject();
if (is_object($object)) {
$objType = strtolower($object->getType());
$articleId = \Federator\DIO\Posts::getOriginalArticleId($dbh, $activity);
if ($articleId === null) {
error_log("ContentNation::activityToJson Failed to get original article ID for create/update activity");
}
switch ($objType) {
case 'article':
// We don't support article create/update at this point in time
error_log("ContentNation::activityToJson Unsupported create/update object type: {$objType}");
break;
case 'note':
$targetUrl = $serviceUrl . '/api/article/' . $articleId . '/comment';
$type = 'comment';
$inReplyTo = $object->getInReplyTo();
if ($inReplyTo !== '') {
$target = $inReplyTo;
} else {
$target = $object->getObject();
}
$comment = null;
if (is_string($target)) {
if (strpos($target, '#') !== false) {
$parts = explode('#', $target);
if (count($parts) > 0) {
$comment = $parts[count($parts) - 1];
}
}
} else {
error_log("ContentNation::activityToJson Unsupported target type for comment with id: " . $activity->getID() . " Type: " . gettype($target));
return false;
}
return [
'type' => $type,
'id' => $activity->getID(),
'parent' => $comment,
'subject' => $object->getSummary(),
'comment' => $object->getContent(),
];
default:
error_log("ContentNation::activityToJson Unsupported create/update object type: {$objType}");
return false;
}
}
break;
case 'like':
case 'dislike':
$articleId = \Federator\DIO\Posts::getOriginalArticleId($dbh, $activity);
if ($articleId === null) {
error_log("ContentNation::activityToJson Failed to get original article ID for vote activity");
}
$voteValue = $type === 'like' ? true : false;
$activityType = 'vote';
$inReplyTo = $activity->getInReplyTo();
if ($inReplyTo !== '') {
$target = $inReplyTo;
} else {
$target = $activity->getObject();
}
$comment = null;
if (is_string($target)) {
if (strpos($target, '#') !== false) {
$parts = explode('#', $target);
if (count($parts) > 0) {
$comment = $parts[count($parts) - 1];
}
}
} else {
error_log("ContentNation::activityToJson Unsupported target type for vote with id: " . $activity->getID() . " Type: " . gettype($target));
return false;
}
$targetUrl = $serviceUrl . '/api/article/' . $articleId . '/vote';
return [
'vote' => $voteValue,
'type' => $activityType,
'id' => $activity->getID(),
'comment' => $comment,
];
case 'undo':
$object = $activity->getObject();
if (is_object($object)) {
$objType = strtolower($object->getType());
switch ($objType) {
case 'like':
case 'dislike':
$articleId = \Federator\DIO\Posts::getOriginalArticleId($dbh, $activity);
if ($articleId === null) {
error_log("ContentNation::activityToJson Failed to get original article ID for undo vote activity");
}
$activityType = 'vote';
$inReplyTo = $object->getInReplyTo();
if ($inReplyTo !== '') {
$target = $inReplyTo;
} else {
$target = $object->getObject();
}
$comment = null;
if (is_string($target)) {
if (strpos($target, '#') !== false) {
$parts = explode('#', $target);
if (count($parts) > 0) {
$comment = $parts[count($parts) - 1];
}
}
} else {
error_log("ContentNation::activityToJson Unsupported target type for undo vote with id: " . $activity->getID() . " Type: " . gettype($target));
return false;
}
$targetUrl = $serviceUrl . '/api/article/' . $articleId . '/vote';
return [
'vote' => null,
'type' => $activityType,
'id' => $object->getID(),
'comment' => $comment,
];
case 'note':
// We don't support comment deletions at this point in time
error_log("ContentNation::activityToJson Unsupported undo object type: {$objType}");
break;
default:
error_log("ContentNation::activityToJson Unsupported create/update object type: {$objType}");
return false;
}
}
break;
default:
error_log("ContentNation::activityToJson Unsupported activity type: {$type}");
return false;
}
return false;
}
/**
* 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 = PROJECT_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;
@ -982,6 +299,5 @@ namespace Federator;
function contentnation_load($main)
{
$cn = new Connector\ContentNation($main);
# echo "contentnation::contentnation_load Loaded new connector, adding to main\n"; // TODO change to proper log
$main->setConnector($cn);
}

View file

@ -19,38 +19,15 @@ class DummyConnector implements Connector
{
}
/**
* get followers of given user
*
* @param string $userId user id @unused-param
* @return \Federator\Data\FedUser[]|false
*/
public function getRemoteFollowersOfUser($userId)
{
return false;
}
/**
* get following of given user
*
* @param string $id user id @unused-param
* @return \Federator\Data\FedUser[]|false
*/
public function getRemoteFollowingForUser($id)
{
return false;
}
/**
* get posts by given user
*
* @param string $id user id @unused-param
* @param string $min min date @unused-param
* @param string $max max date @unused-param
* @return \Federator\Data\ActivityPub\Common\Activity[]|false
* @param string $minId min ID @unused-param
* @param string $maxId max ID @unused-param
* @return \Federator\Data\ActivityPub\Common\APObject[]|false
*/
public function getRemotePostsByUser($id, $min, $max)
public function getRemotePostsByUser($id, $minId, $maxId)
{
return false;
}
@ -69,18 +46,6 @@ class DummyConnector implements Connector
return $stats;
}
/**
* Convert jsonData to Activity format
*
* @param array<string, mixed> $jsonData the json data from our platfrom @unused-param
* @param string $articleId the original id of the article (if applicable)
* (used to identify the article in the remote system) @unused-param
* @return \Federator\Data\ActivityPub\Common\Activity|false
*/
public function jsonToActivity(array $jsonData, &$articleId) {
return false;
}
/**
* get remote user by name
* @param string $_name user or profile name
@ -105,34 +70,10 @@ class DummyConnector implements Connector
// validate $_session and $user
$user = new \Federator\Data\User();
$user->externalid = $_user;
$user->permissions = ['publish'];
$user->permissions = ['PUBLISH'];
$user->session = $_session;
return $user;
}
/**
* send target-friendly json from ActivityPub activity
*
* @param \Federator\Data\FedUser $sender the user of the sender @unused-param
* @param \Federator\Data\ActivityPub\Common\Activity $activity the activity @unused-param
* @return boolean did we successfully send the activity?
*/
public function sendActivity($sender, $activity)
{
return false;
}
/**
* 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;
@ -146,6 +87,5 @@ namespace Federator;
function dummy_load($main)
{
$dummy = new Connector\DummyConnector();
# echo "dummyconnector::dummy_load Loaded new connector, adding to main\n"; // TODO change to proper log
$main->setConnector($dummy);
}

View file

@ -41,23 +41,15 @@ class RedisCache implements Cache
*/
private $userTTL;
/**
* public key cache time to live in secods
*
* @var int $publicKeyPemTTL
*/
private $publicKeyPemTTL;
/**
* constructor
*/
public function __construct()
{
$config = parse_ini_file(PROJECT_ROOT . '/rediscache.ini');
$config = parse_ini_file('../rediscache.ini');
if ($config !== false) {
$this->config = $config;
$this->userTTL = array_key_exists('userttl', $config) ? intval($config['userttl'], 10) : 60;
$this->publicKeyPemTTL = array_key_exists('publickeypemttl', $config) ? intval($config['publickeypemttl'], 10) : 3600;
}
}
@ -67,20 +59,10 @@ class RedisCache implements Cache
*/
private function connect()
{
$this->redis = new \Redis();
$this->redis->pconnect($this->config['host'], intval($this->config['port'], 10));
// @phan-suppress-next-line PhanTypeMismatchArgumentInternalProbablyReal
$this->redis->auth([$this->config['username'], $this->config['password']]);
// Set the Redis backend for Resque
$redisUrl = sprintf(
'redis://%s:%s@%s:%d',
urlencode($this->config['username']),
urlencode($this->config['password']),
$this->config['host'],
intval($this->config['port'], 10)
);
\Resque::setBackend($redisUrl);
$this->redis = new \Redis();
$this->redis->pconnect($this->config['host'], intval($this->config['port'], 10));
// @phan-suppress-next-line PhanTypeMismatchArgumentInternalProbablyReal
$this->redis->auth([$this->config['username'], $this->config['password']]);
}
/**
@ -95,56 +77,16 @@ class RedisCache implements Cache
return $prefix . '_' . md5($input);
}
/**
* get followers of given user
*
* @param string $id user id @unused-param
* @return \Federator\Data\FedUser[]|false
*/
public function getRemoteFollowersOfUser($id)
{
error_log("rediscache::getRemoteFollowersOfUser not implemented");
return false;
}
/**
* get following of given user
*
* @param string $id user id @unused-param
* @return \Federator\Data\FedUser[]|false
*/
public function getRemoteFollowingForUser($id)
{
error_log("rediscache::getRemoteFollowingForUser not implemented");
return false;
}
/**
* Convert jsonData to Activity format
*
* @param array<string, mixed> $jsonData the json data from our platfrom @unused-param
* @param string $articleId the original id of the article (if applicable)
* (used to identify the article in the remote system) @unused-param
* @return \Federator\Data\ActivityPub\Common\Activity|false
*/
public function jsonToActivity(array $jsonData, &$articleId)
{
error_log("rediscache::jsonToActivity not implemented");
return false;
}
/**
* get posts by given user
*
* @param string $id user id @unused-param
* @param string $min min date @unused-param
* @param string $max max date @unused-param
* @param string $minId min ID @unused-param
* @param string $maxId max ID @unused-param
* @return \Federator\Data\ActivityPub\Common\Activity[]|false
* @return \Federator\Data\ActivityPub\Common\APObject[]|false
*/
public function getRemotePostsByUser($id, $min, $max)
public function getRemotePostsByUser($id, $minId, $maxId)
{
error_log("rediscache::getRemotePostsByUser not implemented");
return false;
@ -189,26 +131,6 @@ class RedisCache implements Cache
return $user;
}
/**
* get remote federation user by given name
*
* @param string $_name user/profile name
* @return \Federator\Data\FedUser | false
*/
public function getRemoteFedUserByName(string $_name)
{
if (!$this->connected) {
$this->connect();
}
$key = self::createKey('u', $_name);
$data = $this->redis->get($key);
if ($data === false) {
return false;
}
$user = \Federator\Data\FedUser::createFromJson($data);
return $user;
}
/**
* get remote user by given session
*
@ -230,45 +152,6 @@ class RedisCache implements Cache
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
*
* @param string $user user name @unused-param
* @param \Federator\Data\FedUser[]|false $followers user followers @unused-param
* @return void
*/
public function saveRemoteFollowersOfUser($user, $followers)
{
error_log("rediscache::saveRemoteFollowersOfUser not implemented");
}
/**
* save remote following for user
*
* @param string $user user name @unused-param
* @param \Federator\Data\FedUser[]|false $following user following @unused-param
* @return void
*/
public function saveRemoteFollowingForUser($user, $following)
{
error_log("rediscache::saveRemoteFollowingForUser not implemented");
}
/**
* save remote posts by user
*
@ -310,20 +193,6 @@ class RedisCache implements Cache
$this->redis->setEx($key, $this->userTTL, $serialized);
}
/**
* save remote federation user by given name
*
* @param string $_name user/profile name
* @param \Federator\Data\FedUser $user user data
* @return void
*/
public function saveRemoteFedUserByName(string $_name, \Federator\Data\FedUser $user)
{
$key = self::createKey('u', $_name);
$serialized = $user->toJson();
$this->redis->setEx($key, $this->userTTL, $serialized);
}
/**
* save remote user by given session
*
@ -336,47 +205,7 @@ class RedisCache implements Cache
{
$key = self::createKey('s', $_session . $_user);
$serialized = $user->toJson();
$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);
}
/**
* send target-friendly json from ActivityPub activity
*
* @param \Federator\Data\FedUser $sender the user of the sender @unused-param
* @param \Federator\Data\ActivityPub\Common\Activity $activity the activity @unused-param
* @return boolean did we successfully send the activity?
*/
public function sendActivity($sender, $activity)
{
return false;
}
/**
* 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");
$this->redis->setEx($key, $this->userTTL, $serialized,);
}
}

View file

@ -4,5 +4,4 @@ port = 6379
username = federator
password = redis*change*password
userttl = 10
publickeypemttl = 3600
statsttl = 60

View file

@ -1,3 +0,0 @@
create table follows(`id` varchar(255) unique primary key,`source_user` varchar(255) not null,`target_user` varchar(255) not null,`created_at` timestamp default current_timestamp,unique key `unique_follow` (`source_user`, `target_user`));
create table fedusers(`id` varchar(255) unique primary key,`url` varchar(255) not null,`name` varchar(255) default '',`publickey` text default '',`summary` text default '',`validuntil` timestamp null default null,`type` enum('person', 'group') default 'person',`inboxurl` varchar(255) default null,`sharedinboxurl` varchar(255) default null,`followersurl` varchar(255) default null,`followingurl` varchar(255) default null,`publickeyid` varchar(255) default null,`outboxurl` varchar(255) default null,unique key `unique_feduser` (`url`),unique key `unique_feduser_id` (`url`));
update settings set `value`="2025-05-06" where `key`="database_version";

View file

@ -1,2 +0,0 @@
create table posts(`id` varchar(255) primary key,`url` varchar(255) not null,`user_id` varchar(255) not null,`actor` varchar(255) not null,`type` varchar(255) not null default 'note',`object` text default null,`to` text default null,`cc` text default null,`published` timestamp not null default current_timestamp);
update settings set `value`="2025-05-19" where `key`="database_version";

View file

@ -1,2 +0,0 @@
alter table posts add `article_id` varchar(255) null default null comment 'The optional original article id (of non-federated system, e.g. CN)';
update settings set `value`="2025-05-27" where `key`="database_version";

View file

@ -1,4 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
version="1.0" encoding="UTF-8"?>
<XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0">
<Link rel="lrdd" template="https://{$fqdn}/.well-known/webfinger?resource={ldelim}uri{rdelim}"/>
</XRD>

View file

@ -58,14 +58,14 @@
{rdelim}
{rdelim}
],
"id":"https://{$fqdn}/{$username}",
"id":"https://{$fqdn}/users/{$username}",
"type":"{$type}",
"following":"https://{$fqdn}/{$username}/following",
"followers":"https://{$fqdn}/{$username}/followers",
"inbox":"https://{$fqdn}/{$username}/inbox",
"outbox":"https://{$fqdn}/{$username}/outbox",
"featured":"https://{$fqdn}/{$username}/collections/featured",
"featuredTags":"https://{$fqdn}/{$username}/collections/tags",
"following":"https://{$fqdn}/users/{$username}/following",
"followers":"https://{$fqdn}/users/{$username}/followers",
"inbox":"https://{$fqdn}/users/{$username}/inbox",
"outbox":"https://{$fqdn}/users/{$username}/outbox",
"featured":"https://{$fqdn}/users/{$username}/collections/featured",
"featuredTags":"https://{$fqdn}/users/{$username}/collections/tags",
"preferredUsername":"{$username}",
"name":"{$name}",
"summary":"{$summary}",
@ -74,8 +74,8 @@
"discoverable":true,
"published":"{$registered}",
"publicKey":{ldelim}
"id":"https://{$fqdn}/{$username}#main-key",
"owner":"https://{$fqdn}/{$username}",
"id":"https://{$fqdn}/users/{$username}#main-key",
"owner":"https://{$fqdn}/users/{$username}",
"publicKeyPem":"{$publickey}"
{rdelim},
"tag":[],