federator/php/federator/dio/followers.php
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

484 lines
17 KiB
PHP

<?php
/**
* SPDX-FileCopyrightText: 2024 Sascha Nitsch (grumpydeveloper) https://contentnation.net/@grumpydevelop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* @author Yannis Vogel (vogeldevelopment)
**/
namespace Federator\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
}
/**
* 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;
}
}