Compare commits

...
Sign in to create a new pull request.

34 commits

Author SHA1 Message Date
Yannis Vogel
9b3ae63c7e
ap prefix for ext-username sent to ContentNation 2025-05-28 18:02:05 +02:00
Yannis Vogel
96bb1efe16
support external services to comment&like on CN
- integrate support to send new posts to CN
- save original article-id in DB (needs db-migration)
- votes and comments on CN-articles and comments are sent to CN, with proper signing and format
- fixed minor issue where delete-activity was not properly working with objects
- fixed minor issue where tombstone wasn't supported (which prevented being able to delete mastodon-posts from the db)
2025-05-28 16:52:20 +02:00
Yannis Vogel
8891234617
"fix" previous commit to make update.php lowercase
windows is case-insensitive so we needed the tmp-commit and this one in order to change the case of the update.php file
2025-05-27 16:43:36 +02:00
Yannis Vogel
90fe8fab5c
tmp commit 2025-05-27 16:42:43 +02:00
Yannis Vogel
f8539e479e
minor hotfix for db-saving
save activity to db *before* mutating it for the target (e.g. save before transforming article into note)
2025-05-26 18:16:41 +02:00
Yannis Vogel
10dec5ebd3
conditionally convert article to note
- fix bug in which inReplyTo isn't correctly set from contentnation-comments
- added dio/article which has functions to convert article to note based on new file
- added formatsupport.json to manage special cases (f.e. includes which servers can handle articles)
2025-05-26 16:17:23 +02:00
Yannis Vogel
30c577c82f
fix following from mastodon -> CN 2025-05-25 20:22:34 +02:00
Yannis Vogel
5c90b4cfc9
initial support for actually sending NewContent
- integrated functionality to actually send new content to federated recipients and followers (IT WORKS!!)
- changed the way we remove a follow to return the removed followId (used in order to build the undo follow activity)
2025-05-23 19:58:47 +02:00
Yannis Vogel
7a5870de95
proper support for undoing votes and articles
- added option to remove the like/dislike from an article/comment with creating the correct Undo activity
- added option to remove article with creating the correct Undo activity
- fixed problem where post might not be accepted/saved when no receiver was set (posterName was previously excluded from receivers)
2025-05-23 13:42:15 +02:00
Yannis Vogel
572bb376c1
hotfix article title not being used 2025-05-22 20:55:21 +02:00
Yannis Vogel
62cfd6ef0d
initial support for articles from CN
- fixed how To and CC field (recipients) are handled in general
- fixed posts in database
- improved some error exceptions and prevented early breaks through try-catch blocks
- we now support CN-articles on our newcontent endpoint, with create and update calls
2025-05-22 20:44:37 +02:00
Yannis Vogel
ba88adcebd
refactored ContentNation data-format
- also fixed issue where we didn't retrieve posts from the DB
- also fixed issue where posts from db were malformatted
- changed contentnation data-converter for comments & votes to support the new data-structure (more consistent, easier to read)
2025-05-21 20:06:43 +02:00
Yannis Vogel
d355b5a7cd
integrate queue for NewContent
- also integrated better support for newContent types
- Integrated saving posts to database (posts gotten via outbox request as well as posts received in the NewContent endpoint)
- proper support for handling comments
- support for likes/dislikes
- support for requesting followers / following endpoints
- better inbox support

database needs changes, don't forget to run migration
2025-05-20 16:34:50 +02:00
Yannis Vogel
767f51cc5b
queue-redis connection + fqdn->CN follow-support
- the resque queue integration is now connected to our redis instance.
- IMPORTANT NOTICE: In order for this to work, we needed to change the plugins file under vendor/resque/php-resque/lib/Resque/Redis.php #137 ($this->driver->auth($password, $user);    (this change is neccessary for our implementation, as they normally don't support the $user attribute as it would be upstream breaking change). I might create a fork for this, to which we bind with composer
- The inboxJob now inherits from api instead of creating its own api instance
- inbox now actually works with follows and Undo-follows from mastodon->CN (adding the follower to our followers-db and removing it on undo).
- fixed bug where paths for templates was incorrectly adjusted
- better/proper support for comment-activity in getExternalPosts
2025-05-18 08:51:18 +02:00
Yannis Vogel
6cf9a030a4
added queue support and paths-refactoring
- added php-resque for queue job and worker support
- removed all document_root dependencies (as that doesn't work in the queue-environment
- added project_root global variable for more robust setup
- some file-constistency fixed
- config referring paths are now starting in project-root
- inbox now does the heavy-duty on a queue
2025-05-15 17:39:12 +02:00
Sascha Nitsch
49a4bee76a small bug fixes 2025-05-07 17:01:11 +02:00
Yannis Vogel
da18d37a79
very rudimental follower-support
- fixed rewrites to properly support @username/outbox, username/outbox, users/username/outbox, ...
- initial support for sending follow to e.g. mastodon (code commented out in api.php)
- database migration for follows table and fedusers table
- save retrieved fedusers in cache and db
- depend more on configs externaldomain, less on server_name
- properly implemented following-logic in dio
- made postForuUser static in newContent and inbox
- provide rsaprivate for signing reuests -> actPub-Servers
- change contenttype depending on use-case
- changed user json-template to have loop-back between user-template and webfinger (mastodon f.e. needs this)
2025-05-07 07:57:04 +02:00
Yannis Vogel
ce7aa5c72d
remove debug/todos for live tests 2025-04-30 22:49:20 +02:00
Yannis Vogel
8ea9bdcf9a
major structure rework
- checkSignature for our fqdn now happens in the connector
- cache public key for 1 hour
- change author on some files
- refactored outbox to api/v1/newcontent (CN->Federator communicates to here)
- disabled post for outbox (as that should go to v1/newcontent)
- initial support for receiving votes from CN, not fully working/clean yet
2025-04-30 22:44:50 +02:00
Yannis Vogel
10a3b1e0f9
remove key-files (don't belong in git) and minor cleanup 2025-04-29 07:41:24 +02:00
Yannis Vogel
352377887c
added proper application-signing
- we now check whether the request was sent from contentnation and approve the request with contentnations pub key
- fix issue where inbox wasn't gotten as it also got a username
- rough initial draft of parsing of connector-specific data (plugins/federator/contentnation::jsonToActivity)
- added key paths and headername to config
2025-04-23 21:07:26 +02:00
Yannis Vogel
0edc4c0db6
remove mastodon.ini 2025-04-23 14:02:54 +02:00
Yannis Vogel
21f73fb56f
cleanup and removal of mastodon-integration
- In-&Outbox now use factories to convert data
- initial creation of followers-data (wip, not working)
- fixed some issue where connector would always fail (as we deliberately always pass username@domain and the regex there always fails)
- deleted all mastodon-files (mastodon.ini and mastodon plugin)
- minor fix where toObject of create would override id with an empty string if no url is set
2025-04-23 14:02:20 +02:00
Yannis Vogel
4d36fc3c61
fix remaining phan errors
- also cleaned up dummy
- added new rewrite for apache for sharedInbox (your.fqdn/inbox)
- fixed json-formatting of publicKey when requesting user via api
2025-04-22 14:30:26 +02:00
Yannis Vogel
d9b02bd95b
fix phan errors
fix lots of phan errors, now we only have phan errors left from temporary implementations. These will be fixed in the next commits as we properly re-integrate and remove temporary-code
2025-04-21 21:06:03 +02:00
Yannis Vogel
957b4f5266
fix line endings
fix line endings to be in LF format
2025-04-21 14:56:44 +02:00
Yannis Vogel
64ee4c518f
forgot to clean-up outbox logging as well. 2025-04-17 12:40:50 +02:00
Yannis Vogel
f6d6c74bc6
minor logging cleanup 2025-04-17 12:38:45 +02:00
Yannis Vogel
20c2db4b35
support outbox post
- we can now receive content into outbox (f.e. from contentnation) and post it into the outbox-user-logfile for now
- we now verify post-requests according to ActivityPub standard
- minor bugfix where user specified by passed user-parameter is possibly not included in postForUser-call
2025-04-17 12:13:34 +02:00
Yannis Vogel
305ded4986
WIP inbox-support
- includes hacky following-mechanic in order to simulate a follow on mastodon (not properly working, need to also inject the user this creates into the followers db for the target mastodon-user)
- created endpoint for inbox. SharedInbox is used when no user is provided (/api/federator/fedusers/inbox'), the regular inbox link now works (/users/username/inbox).
- Retrieve all followers of sender and, if they're part of our system, send the activity into their personal inbox
- Support Announce and Undo Activity-Types
- Inbox currently converts to proper ActPub-objects and saves data to log-files
2025-04-15 16:42:46 +02:00
Yannis Vogel
823283183e
minor fixes
- permissions are always lower-case
- revert webfinger changes to initial state
- re-add setFirst and setLast for outbox
- we now save the current host (f.e. https://contentnation.net) to save users with their respective host (a request to fedusers/grumpydevelop will then result in saving the user as grumpydevelop@contentnation.net)
2025-04-10 19:23:09 +02:00
Yannis Vogel
721e37882d
revert to single-connector support
- removed support for multiple connector-plugins
- fixed issue where outbox wasn't properly retrieved from contentnation
2025-04-10 09:02:45 +02:00
Yannis Vogel
1a7b8264a1
fixes for outbox
- mastodon now properly sets available type-attributes
- database should now save user with his platform (as that's the actual unique identifier, plain username isn't)
- properly retrieve items from mastodon and print them on api-call
(example call for mastodon: federator/fedusers/gutenberg_org@mastodon.social/outbox?page=0)
2025-04-09 12:52:27 +02:00
Yannis Vogel
530caa7ea6
initial mastodon support and minor improvements
- reworked plugin handling. Main now registers and keeps all connectors and based on request we select the correct one and pass it (mainly for clean async-request-handling)
- added outbox functionality for mastodon
- changed image-mime-type approach to retrieve mime-type from the url, this way we don't need to store the images on our server/download each image
- added host for connector for better debugging
- minor bug-fixes
2025-04-08 22:03:19 +02:00
67 changed files with 5007 additions and 261 deletions

16
.gitattributes vendored Normal file
View file

@ -0,0 +1,16 @@
# 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,3 +5,4 @@ php-docs
.phpdoc
phpdoc
html
/cache

View file

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

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 >redis*change*password
user federator on ~u_* +get +set ~s_* +get +setex ~m_* +get +setex ~publickey_* +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,9 +52,11 @@ 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 ^api/federator/(.+)$ federator.php?_call=$1 [L,END]
RewriteRule ^inbox[/]?$ /federator.php?_call=fedusers/inbox [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,7 +3,8 @@
"description": "A federation service",
"type": "project",
"require": {
"smarty/smarty": "^5.3"
"smarty/smarty": "^5.3",
"resque/php-resque": "^1.3.6"
},
"license": "GPL-3.0-or-later",
"authors": [
@ -14,5 +15,10 @@
],
"require-dev": {
"phan/phan": "^5.4"
},
"autoload": {
"psr-4": {
"Federator\\": "php/federator/"
}
}
}

View file

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

10
contentnation.ini Normal file
View file

@ -0,0 +1,10 @@
[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'

8
formatsupport.json Normal file
View file

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

View file

@ -14,6 +14,7 @@ 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();

113
htdocs/index.html Normal file
View file

@ -0,0 +1,113 @@
<!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>

10
phan-stubs.php Normal file
View file

@ -0,0 +1,10 @@
<?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";
Main::__construct();
parent::__construct();
}
/**
@ -69,11 +69,12 @@ 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) {
@ -100,12 +101,49 @@ 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;
}
@ -168,7 +206,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) {
@ -191,6 +229,96 @@ 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
*
@ -198,7 +326,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);
@ -212,7 +340,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);
}
@ -228,13 +356,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\Main $main
* @var \Federator\Api $main
*/
private $main;
@ -49,24 +49,34 @@ 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
return $this->returnUserProfile($paths[1]);
// /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;
}
}
break;
case 3:
// /users/username/(inbox|outbox|following|followers)
switch ($paths[2]) {
case 'following':
// $handler = new FedUsers\Following();
$handler = new FedUsers\Following($this->main);
break;
case 'followers':
// $handler = new FedUsers\Followers();
$handler = new FedUsers\Followers($this->main);
break;
case 'inbox':
// $handler = new FedUsers\Inbox();
$handler = new FedUsers\Inbox($this->main);
break;
case 'outbox':
$handler = new FedUsers\Outbox($this->main);
@ -82,10 +92,10 @@ class FedUsers implements APIInterface
$ret = false;
switch ($method) {
case 'GET':
$ret = $handler->get($paths[1]);
$ret = $handler->get($_username);
break;
case 'POST':
$ret = $handler->post($paths[1]);
$ret = $handler->post($_username);
break;
}
if ($ret !== false) {
@ -115,18 +125,24 @@ 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' => $_SERVER['SERVER_NAME'],
'fqdn' => $domain,
'name' => $user->name,
'username' => $user->id,
'publickey' => str_replace("\n", "\\n", $user->publicKey),
'publickey' => trim($jsonKey, '"'),
'registered' => gmdate('Y-m-d\TH:i:s\Z', $user->registered), // 2021-03-25T00:00:00Z
'summary' => $user->summary,
'type' => $user->type
'type' => ucfirst($user->type) // capitalized user type
];
$this->response = $this->main->renderTemplate('user.json', $data);
return true;

View file

@ -0,0 +1,120 @@
<?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

@ -0,0 +1,120 @@
<?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

@ -0,0 +1,441 @@
<?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\Main $main
* @var \Federator\Api $main
*/
private $main;
/**
* constructor
* @param \Federator\Main $main main instance
* @param \Federator\Api $main main instance
*/
public function __construct($main)
{
@ -32,11 +32,14 @@ class Outbox implements \Federator\Api\FedUsers\FedUsersInterface
/**
* handle get call
*
* @param string $_user user to fetch outbox for
* @param string|null $_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();
@ -50,6 +53,7 @@ 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", "");
@ -61,31 +65,32 @@ class Outbox implements \Federator\Api\FedUsers\FedUsersInterface
} else {
$items = [];
}
$host = $_SERVER['SERVER_NAME'];
$id = 'https://' . $host .'/' . $_user . '/outbox';
$config = $this->main->getConfig();
$domain = $config['generic']['externaldomain'];
$id = 'https://' . $domain . '/users/' . $_user . '/outbox';
$outbox->setPartOf($id);
$outbox->setID($id);
if ($page !== '') {
$id .= '?page=' . urlencode($page);
}
if ($page === '' || $outbox->count() == 0) {
$outbox->setFirst($id);
$outbox->setFirst($id . '?page=0');
$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);
return json_encode($obj, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
}
/**
* handle post call
*
* @param string $_user user to add data to outbox @unused-param
* @param string|null $_user user to add data to outbox @unused-param
* @return string|false response
*/
public function post($_user)

View file

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

View file

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

View file

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

View file

@ -51,6 +51,7 @@ 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],
@ -58,6 +59,7 @@ class WebFinger
$this->main->getCache()
);
if ($user->id == 0) {
echo "not found";
throw new \Federator\Exceptions\FileNotFound();
}
$data = [

View file

@ -13,6 +13,24 @@ 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
*
@ -39,6 +57,15 @@ 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
*
@ -48,4 +75,29 @@ 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,16 +13,34 @@ 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 $minId min ID
* @param string $maxId max ID
* @param string $min min date
* @param string $max max date
* @return \Federator\Data\ActivityPub\Common\APObject[]|false
* @return \Federator\Data\ActivityPub\Common\Activity[]|false
*/
public function getRemotePostsByUser($id, $minId, $maxId);
public function getRemotePostsByUser($id, $min, $max);
/**
* get remote user by given name
@ -47,4 +65,32 @@ 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

@ -0,0 +1,41 @@
<?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

@ -0,0 +1,18 @@
<?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,6 +74,7 @@ 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)) {
@ -102,7 +103,7 @@ class Activity extends APObject
/**
* get Child Object
*
* @return APObject|null
* @return APObject|string|null
*/
public function getObject()
{

View file

@ -0,0 +1,41 @@
<?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|null $object
* @var APObject|string|null $object
*/
private $object = null;
@ -347,7 +347,7 @@ class APObject implements \JsonSerializable
/**
* set child object
*
* @param APObject $object
* @param APObject|string $object
* @return void
*/
public function setObject($object)
@ -358,7 +358,7 @@ class APObject implements \JsonSerializable
/**
* get child object
*
* @return APObject|null child object
* @return APObject|string|null child object
*/
public function getObject()
{
@ -376,6 +376,17 @@ class APObject implements \JsonSerializable
$this->summary = $summary;
return $this;
}
/**
* get summary
*
* @return string summary
*/
public function getSummary()
{
return $this->summary;
}
/**
* set type
*
@ -459,6 +470,16 @@ class APObject implements \JsonSerializable
return $this;
}
/**
* get name
*
* @return string name
*/
public function getName() : string
{
return $this->name;
}
/**
* add Image
*
@ -647,7 +668,7 @@ class APObject implements \JsonSerializable
if (array_key_exists('duration', $json)) {
try {
$this->duration = new \DateInterval($json['duration']);
} catch (\Exception $unused_e) {
} catch (\Throwable $unused_e) {
error_log("error parsing duration ". $json['duration']);
}
}
@ -750,8 +771,8 @@ class APObject implements \JsonSerializable
if (array_key_exists('mediaType', $json)) {
$this->mediaType = $json['mediaType'];
}
if (array_key_exists('object', $json)) {
$this->object = \Federator\Data\ActivityPub\Factory::newFromJson($json['object'], "");
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('sensitive', $json)) {
$this->sensitive = $json['sensitive'];
@ -773,7 +794,7 @@ class APObject implements \JsonSerializable
* {@inheritDoc}
* @see JsonSerializable::jsonSerialize()
*/
public function jsonSerialize()
public function jsonSerialize(): mixed
{
return $this->toObject();
}
@ -875,7 +896,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;
@ -890,7 +911,7 @@ class APObject implements \JsonSerializable
$return['mediaType'] = $this->mediaType;
}
if ($this->object !== null) {
$return['object'] = $this->object->toObject();
$return['object'] = is_string($this->object) ? $this->object : $this->object->toObject();
}
if ($this->atomURI !== '') {
$return['atomUri'] = $this->atomURI;

View file

@ -51,17 +51,27 @@ class Collection extends APObject
return parent::fromJson($json);
}
public function count() : int
/**
* set total items
*
* @param int $totalItems total items
*/
public function setTotalItems(int $totalItems): void
{
$this->totalItems = $totalItems;
}
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,7 +26,9 @@ class Create extends Activity
$return = parent::toObject();
$return['type'] = 'Create';
// overwrite id from url
$return['id'] = $this->getURL();
if ($this->getURL() !== '') {
$return['id'] = $this->getURL();
}
return $return;
}

View file

@ -0,0 +1,36 @@
<?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

@ -0,0 +1,18 @@
<?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

@ -0,0 +1,55 @@
<?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

@ -0,0 +1,53 @@
<?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

@ -0,0 +1,53 @@
<?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

@ -0,0 +1,50 @@
<?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

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

View file

@ -4,6 +4,7 @@
* SPDX-License-Identifier: GPL-3.0-or-later
*
* @author Sascha Nitsch (grumpydeveloper)
* @author Yannis Vogel (vogeldevelopment)
**/
namespace Federator\Data\ActivityPub\Common;
@ -26,6 +27,18 @@ 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

@ -0,0 +1,18 @@
<?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

@ -0,0 +1,45 @@
<?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

@ -0,0 +1,27 @@
<?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,6 +31,9 @@ class Factory
}
$return = null;
switch ($json['type']) {
case 'Announce':
$return = new Common\Announce();
break;
case 'Article':
$return = new Common\Article();
break;
@ -39,21 +42,39 @@ 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 'Question':
$return = new \Common\Question();
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();
break;
case 'Video':
$return = new \Common\Video();
$return = new Common\Video();
break;*/
default:
error_log("newFromJson: unknown type: '" . $json['type'] . "' " . $jsonstring);
@ -77,9 +98,7 @@ class Factory
}
//$return = false;
switch ($json['type']) {
case 'MakePhanHappy':
break;
/* case 'Accept':
case 'Accept':
$return = new Common\Accept();
break;
case 'Announce':
@ -91,18 +110,30 @@ 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;*/
$return = new Common\Undo();
break;
case 'Update':
$return = new Common\Update();
break;
default:
error_log("newActivityFromJson " . print_r($json, true));
error_log("newActivityFromJson unsupported type: " . print_r($json, true));
}
/*if ($return !== false && $return->fromJson($json) !== null) {
if (isset($return) && $return->fromJson($json) !== null) {
return $return;
}*/
}
return false;
}
}

View file

@ -0,0 +1,151 @@
<?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

@ -0,0 +1,68 @@
<?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

@ -0,0 +1,242 @@
<?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

@ -0,0 +1,525 @@
<?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,47 +13,326 @@ namespace Federator\DIO;
*/
class Posts
{
/**
* get posts by user
*
* @param \mysqli $dbh @unused-param
* database handle
* @param string $id
* @param string $userid
* user id
* @param \Federator\Connector\Connector $connector
* connector to fetch use with
* @param \Federator\Cache\Cache|null $cache
* optional caching service
* @param string $minId
* minimum ID
* @param string $maxId
* maximum ID
* @return \Federator\Data\ActivityPub\Common\APObject[]
* @param string $min
* minimum date
* @param string $max
* maximum date
* @return \Federator\Data\ActivityPub\Common\Activity[]
*/
public static function getPostsByUser($dbh, $id, $connector, $cache, $minId, $maxId)
public static function getPostsByUser($dbh, $userid, $connector, $cache, $min, $max)
{
// ask cache
if ($cache !== null) {
$posts = $cache->getRemotePostsByUser($id, $minId, $maxId);
$posts = $cache->getRemotePostsByUser($userid, $min, $max);
if ($posts !== false) {
return $posts;
}
}
$posts = [];
// TODO: check our db
$posts = self::getPostsFromDb($dbh, $userid, $min, $max);
if ($posts === false) {
$posts = [];
}
if ($posts === []) {
// ask connector for user-id
$posts = $connector->getRemotePostsByUser($id, $minId, $maxId);
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($id, $posts);
$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;
}
}
$stmt->close();
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,8 +17,7 @@ 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();
throw new \Federator\Exceptions\ServerError("User::addLocalUser Failed to prepare statement");
}
$stmt->bind_param("s", $_user);
$validuntil = 0;
@ -42,6 +42,7 @@ 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,';
@ -49,7 +50,7 @@ class User
$sql .= ' values (?, ?, ?, ?, now() + interval 1 day, ?, ?, ?, ?, ?, ?, ?, ?)';
$stmt = $dbh->prepare($sql);
if ($stmt === false) {
throw new \Federator\Exceptions\ServerError();
throw new \Federator\Exceptions\ServerError("User::addLocalUser Failed to prepare create statement");
}
$registered = gmdate('Y-m-d H:i:s', $user->registered);
$stmt->bind_param(
@ -73,7 +74,7 @@ class User
$sql .= ' iconmediatype=?, iconurl=?, imagemediatype=?, imageurl=? where id=?';
$stmt = $dbh->prepare($sql);
if ($stmt === false) {
throw new \Federator\Exceptions\ServerError();
throw new \Federator\Exceptions\ServerError("User::addLocalUser Failed to prepare update statement");
}
$registered = gmdate('Y-m-d H:i:s', $user->registered);
$stmt->bind_param(
@ -100,18 +101,42 @@ 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();
throw new \Federator\Exceptions\ServerError("User::extendUser Failed to prepare statement");
}
$stmt->bind_param("s", $_user);
$validuntil = 0;
@ -145,6 +170,7 @@ class User
public static function getUserByName($dbh, $_name, $connector, $cache)
{
$user = false;
// ask cache
if ($cache !== null) {
$user = $cache->getRemoteUserByName($_name);
@ -157,7 +183,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();
throw new \Federator\Exceptions\ServerError("User::getUserByName Failed to prepare statement");
}
$stmt->bind_param("s", $_name);
$user = new \Federator\Data\User();
@ -179,6 +205,7 @@ class User
$stmt->fetch();
}
$stmt->close();
if ($user->id === null) {
// ask connector for user-id
$ruser = $connector->getRemoteUserByName($_name);

View file

@ -0,0 +1,97 @@
<?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

@ -0,0 +1,70 @@
<?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

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

View file

@ -33,6 +33,12 @@ 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
*
@ -72,9 +78,9 @@ class Main
*/
public function __construct()
{
require_once($_SERVER['DOCUMENT_ROOT'] . '../vendor/autoload.php');
require_once(PROJECT_ROOT . '/vendor/autoload.php');
$this->responseCode = 200;
$rootDir = $_SERVER['DOCUMENT_ROOT'] . '../';
$rootDir = PROJECT_ROOT . '/';
$config = parse_ini_file($rootDir . 'config.ini', true);
if ($config !== false) {
$this->config = $config;
@ -93,7 +99,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);
@ -139,20 +145,28 @@ 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()
{
@ -172,10 +186,10 @@ class Main
/**
* load plugins
*/
public function loadPlugins() : void
public function loadPlugins(): void
{
if (array_key_exists('plugins', $this->config)) {
$basepath = $_SERVER['DOCUMENT_ROOT'] . '../plugins/federator/';
$basepath = PROJECT_ROOT . '/plugins/federator/';
$plugins = $this->config['plugins'];
foreach ($plugins as $name => $file) {
require_once($basepath . $file);
@ -199,8 +213,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) {
@ -218,10 +232,10 @@ class Main
*/
public function renderTemplate($template, $data)
{
$rootDir = PROJECT_ROOT . '/';
$smarty = new \Smarty\Smarty();
$root = $_SERVER['DOCUMENT_ROOT'];
$smarty->setCompileDir($root . $this->config['templates']['compiledir']);
$smarty->setTemplateDir((string)realpath($root . $this->config['templates']['path']));
$smarty->setCompileDir($rootDir . $this->config['templates']['compiledir']);
$smarty->setTemplateDir((string) realpath($rootDir . $this->config['templates']['path']));
$smarty->assign('database', $this->dbh);
$smarty->assign('maininstance', $this);
foreach ($data as $key => $value) {
@ -232,27 +246,57 @@ 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;
}
@ -270,7 +314,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);
@ -281,7 +325,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,14 +24,13 @@ class Maintenance
{
date_default_timezone_set("Europe/Berlin");
spl_autoload_register(static function (string $className) {
$root = $_SERVER['DOCUMENT_ROOT'];
include $root . '../php/' . str_replace("\\", "/", strtolower($className)) . '.php';
include PROJECT_ROOT . '/php/' . str_replace("\\", "/", strtolower($className)) . '.php';
});
if ($argc < 2) {
self::printUsage();
}
// pretend that we are running from web directory
$_SERVER['DOCUMENT_ROOT'] = realpath('../../htdocs') . '/';
define('PROJECT_ROOT', dirname(__DIR__, 2));
$main = new \Federator\Main();
switch ($argv[1]) {
case 'dbupgrade':
@ -71,7 +70,7 @@ class Maintenance
}
}
echo "current version: $version\n";
$root = $_SERVER['DOCUMENT_ROOT'] . '../';
$root = PROJECT_ROOT . '/';
$updateFolder = opendir($root . 'sql');
if ($updateFolder === false) {
die();

View file

@ -0,0 +1,23 @@
<?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,12 +41,68 @@ class ContentNation implements Connector
*/
public function __construct($main)
{
$config = parse_ini_file($_SERVER['DOCUMENT_ROOT'] . '../contentnation.ini', true);
$config = parse_ini_file(PROJECT_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;
}
/**
@ -55,11 +111,14 @@ class ContentNation implements Connector
* @param string $userId user id
* @param string $min min date
* @param string $max max date
* @return \Federator\Data\ActivityPub\Common\APObject[]|false
* @return \Federator\Data\ActivityPub\Common\Activity[]|false
*/
public function getRemotePostsByUser($userId, $min, $max)
{
$remoteURL = $this->service . '/api/profile/' . $userId . '/activities';
if (preg_match("#^([^@]+)@([^/]+)#", $userId, $matches) == 1) {
$userId = $matches[1];
}
$remoteURL = $this->service . '/api/profile/' . urlencode($userId) . '/activities';
if ($min !== '') {
$remoteURL .= '&minTS=' . urlencode($min);
}
@ -78,110 +137,122 @@ class ContentNation implements Connector
$posts = [];
if (array_key_exists('activities', $r)) {
$activities = $r['activities'];
$host = $_SERVER['SERVER_NAME'];
$config = $this->main->getConfig();
$domain = $config['generic']['externaldomain'];
$ourUrl = 'https://' . $domain;
$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->setURL('https://'.$host . '/' . $activity['language'] . '/' . $userId . '/'
. $activity['name']);
$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']);
$apArticle = new \Federator\Data\ActivityPub\Common\Article();
if (array_key_exists('tags', $activity)) {
foreach ($activity['tags'] as $tag) {
$href = 'https://' . $host . '/' . $activity['language']
. '/search.htm?tagsearch=' . urlencode($tag);
$href = $ourUrl . '/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('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');
->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');
$articleimage = $activity['imagealt'] ??
$this->main->translate($activity['language'], 'article', 'image');
$idurl = 'https://' . $host . '/' . $activity['language']
. '/' . $userId . '/'. $activity['name'];
$idurl = $ourUrl . '/' . $userId . '/' . $activity['name'];
$apArticle->setID($idurl)
->setURL($idurl);
->setURL($idurl);
$image = $activity['image'] ?? $activity['profileimg'];
$mediaType = @mime_content_type($imgpath . $activity['profile'] . '/' . $image) | 'text/plain';
$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';
$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':
// echo "comment\n";
// print_r($activity);
break; // Comment
case 'Vote':
$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']]
)
);
$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 {
$like = new \Federator\Data\ActivityPub\Common\Activity('Dislike');
$like->setSummary(
$this->main->translate(
$activity['articlelang'],
'vote',
'dislike',
[$activity['username']]
)
);
$note->setInReplyTo($ourUrl . '/' . $activity['articleOwnerName'] . '/' . $activity['articleName'] . "#" . $commentJson['parent']);
}
$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);
$url = $ourUrl . '/' . $activity['articleOwnerName'] . '/' . $activity['articleName'] . '#' . $activity['id'];
$create->setURL($url);
$create->setID($url);
$create->setObject($note);
$posts[] = $create;
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;
break; // Vote
}
}
@ -212,7 +283,7 @@ class ContentNation implements Connector
return $stats;
}
/**
/**
* get remote user by given name
*
* @param string $_name user/profile name
@ -221,10 +292,16 @@ class ContentNation implements Connector
public function getRemoteUserByName(string $_name)
{
// validate name
if (preg_match("/^[a-zA-Z0-9_\-]+$/", $_name) != 1) {
if (preg_match("/^[a-zA-Z@0-9\._\-]+$/", $_name) != 1) {
return false;
}
$remoteURL = $this->service . '/api/users/info?user=' . urlencode($_name);
// 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);
$headers = ['Accept: application/json'];
[$response, $info] = \Federator\Main::getFromRemote($remoteURL, $headers);
if ($info['http_code'] != 200) {
@ -260,7 +337,7 @@ class ContentNation implements Connector
if (preg_match("/^[a-z0-9]{16}$/", $_session) != 1) {
return false;
}
if (preg_match("/^[a-zA-Z0-9_\-]+$/", $_user) != 1) {
if (preg_match("/^[a-zA-Z@0-9\._\-]+$/", $_user) != 1) {
return false;
}
$remoteURL = $this->service . '/api/users/permissions?profile=' . urlencode($_user);
@ -271,7 +348,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);
@ -286,6 +363,612 @@ 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;
@ -299,5 +982,6 @@ 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,15 +19,38 @@ 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 $minId min ID @unused-param
* @param string $maxId max ID @unused-param
* @return \Federator\Data\ActivityPub\Common\APObject[]|false
* @param string $min min date @unused-param
* @param string $max max date @unused-param
* @return \Federator\Data\ActivityPub\Common\Activity[]|false
*/
public function getRemotePostsByUser($id, $minId, $maxId)
public function getRemotePostsByUser($id, $min, $max)
{
return false;
}
@ -46,6 +69,18 @@ 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
@ -70,10 +105,34 @@ 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;
@ -87,5 +146,6 @@ 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,15 +41,23 @@ 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('../rediscache.ini');
$config = parse_ini_file(PROJECT_ROOT . '/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;
}
}
@ -59,10 +67,20 @@ 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']]);
$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);
}
/**
@ -77,16 +95,56 @@ 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 $minId min ID @unused-param
* @param string $maxId max ID @unused-param
* @param string $min min date @unused-param
* @param string $max max date @unused-param
* @return \Federator\Data\ActivityPub\Common\APObject[]|false
* @return \Federator\Data\ActivityPub\Common\Activity[]|false
*/
public function getRemotePostsByUser($id, $minId, $maxId)
public function getRemotePostsByUser($id, $min, $max)
{
error_log("rediscache::getRemotePostsByUser not implemented");
return false;
@ -131,6 +189,26 @@ 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
*
@ -152,6 +230,45 @@ 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
*
@ -193,6 +310,20 @@ 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
*
@ -205,7 +336,47 @@ class RedisCache implements Cache
{
$key = self::createKey('s', $_session . $_user);
$serialized = $user->toJson();
$this->redis->setEx($key, $this->userTTL, $serialized,);
$this->redis->setEx($key, $this->userTTL, $serialized);
}
/**
* Save the public key for a given keyId
*
* @param string $keyId The keyId (e.g., actor URL + #main-key)
* @param string $publicKeyPem The public key PEM to cache
* @return void
*/
public function savePublicKey(string $keyId, string $publicKeyPem)
{
if (!$this->connected) {
$this->connect();
}
$key = self::createKey('publickey', $keyId);
$this->redis->setEx($key, $this->publicKeyPemTTL, $publicKeyPem);
}
/**
* 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");
}
}

View file

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

3
sql/2025-05-06.sql Normal file
View file

@ -0,0 +1,3 @@
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";

2
sql/2025-05-19.sql Normal file
View file

@ -0,0 +1,2 @@
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";

2
sql/2025-05-27.sql Normal file
View file

@ -0,0 +1,2 @@
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 @@
version="1.0" encoding="UTF-8"?>
<?xml 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}/users/{$username}",
"id":"https://{$fqdn}/{$username}",
"type":"{$type}",
"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",
"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",
"preferredUsername":"{$username}",
"name":"{$name}",
"summary":"{$summary}",
@ -74,8 +74,8 @@
"discoverable":true,
"published":"{$registered}",
"publicKey":{ldelim}
"id":"https://{$fqdn}/users/{$username}#main-key",
"owner":"https://{$fqdn}/users/{$username}",
"id":"https://{$fqdn}/{$username}#main-key",
"owner":"https://{$fqdn}/{$username}",
"publicKeyPem":"{$publickey}"
{rdelim},
"tag":[],