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)
This commit is contained in:
Yannis Vogel 2025-05-26 16:17:23 +02:00
parent 30c577c82f
commit 10dec5ebd3
No known key found for this signature in database
9 changed files with 203 additions and 23 deletions

8
formatsupport.json Normal file
View file

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

View file

@ -62,7 +62,8 @@
const headers = { const headers = {
...(session ? { "X-Session": session } : {}), ...(session ? { "X-Session": session } : {}),
...(profile ? { "X-Profile": profile } : {}) ...(profile ? { "X-Profile": profile } : {}),
"HTTP_HOST": "localhost",
}; };
fetch("http://localhost/" + targetLink, { fetch("http://localhost/" + targetLink, {

View file

@ -241,6 +241,11 @@ class Inbox implements \Federator\Api\FedUsers\FedUsersInterface
return false; return false;
} }
$atPos = strpos($_recipientId, '@');
if ($atPos !== false) {
$_recipientId = substr($_recipientId, 0, $atPos);
}
// get recipient // get recipient
$recipient = \Federator\DIO\User::getUserByName( $recipient = \Federator\DIO\User::getUserByName(
$dbh, $dbh,
@ -265,11 +270,8 @@ class Inbox implements \Federator\Api\FedUsers\FedUsersInterface
switch ($type) { switch ($type) {
case 'Follow': case 'Follow':
$success = false;
$actor = $inboxActivity->getAActor();
if ($actor !== '') {
$success = \Federator\DIO\Followers::addExternalFollow($dbh, $inboxActivity->getID(), $user->id, $recipient->id); $success = \Federator\DIO\Followers::addExternalFollow($dbh, $inboxActivity->getID(), $user->id, $recipient->id);
}
if ($success === false) { if ($success === false) {
error_log("Inbox::postForUser: Failed to add follower for user $user->id"); error_log("Inbox::postForUser: Failed to add follower for user $user->id");
} }

View file

@ -346,25 +346,37 @@ class NewContent implements \Federator\Api\APIInterface
break; break;
case 'Note': case 'Note':
// Undo Note (remove note) // Undo Note (remove note)
if (method_exists($object, 'getID')) {
$noteId = $object->getID(); $noteId = $object->getID();
\Federator\DIO\Posts::deletePost($dbh, $noteId); \Federator\DIO\Posts::deletePost($dbh, $noteId);
}
break; break;
case 'Article': case 'Article':
// Undo Article (remove article) // Undo Article (remove article)
if (method_exists($object, 'getID')) { $idPart = strrchr($recipient->id, '@');
if ($idPart === false) {
error_log("NewContent::postForUser Error in Undo Article. $recipient->id, recipient ID is not valid");
return false;
} else {
$targetUrl = ltrim($idPart, '@');
if ($object instanceof \Federator\Data\ActivityPub\Common\Article) {
$object = \Federator\DIO\Article::conditionalConvertToNote($object, $targetUrl);
$newActivity->setObject($object);
} else {
error_log("NewContent::postForUser Error in Undo Article for recipient $recipient->id, object is not an Article");
}
}
$articleId = $object->getID(); $articleId = $object->getID();
\Federator\DIO\Posts::deletePost($dbh, $articleId); \Federator\DIO\Posts::deletePost($dbh, $articleId);
// also remove latest saved article-update // also remove latest saved article-update
\Federator\DIO\Posts::deletePost($dbh, $articleId . '#update'); \Federator\DIO\Posts::deletePost($dbh, $articleId . '#update');
}
break; break;
} }
} else if (is_string($object)) { } else if (is_string($object)) {
\Federator\DIO\Posts::deletePost($dbh, $object); \Federator\DIO\Posts::deletePost($dbh, $object);
} else { } else {
error_log("NewContent::postForUser Error in Undo for user $user->id, object is not a string or object"); error_log("NewContent::postForUser Error in Undo for recipient $recipient->id, object is not a string or object");
} }
break; break;
@ -376,7 +388,7 @@ class NewContent implements \Federator\Api\APIInterface
// \Federator\DIO\Votes::addVote($dbh, $user->id, $targetId, 'like'); // \Federator\DIO\Votes::addVote($dbh, $user->id, $targetId, 'like');
\Federator\DIO\Posts::savePost($dbh, $user->id, $newActivity); \Federator\DIO\Posts::savePost($dbh, $user->id, $newActivity);
} else { } else {
error_log("NewContent::postForUser Error in Add Like/Dislike for user $user->id, targetId is not a string"); error_log("NewContent::postForUser Error in Add Like/Dislike for recipient $recipient->id, targetId is not a string");
return false; return false;
} }
break; break;
@ -387,7 +399,27 @@ class NewContent implements \Federator\Api\APIInterface
if (is_object($object)) { if (is_object($object)) {
switch ($object->getType()) { switch ($object->getType()) {
case 'Note': case 'Note':
\Federator\DIO\Posts::savePost($dbh, $user->id, $newActivity);
break;
case 'Article': case 'Article':
$idPart = strrchr($recipient->id, '@');
if ($idPart === false) {
error_log("NewContent::postForUser Error in Create/Update Article. $recipient->id, recipient ID is not valid");
return false;
} else {
$targetUrl = ltrim($idPart, '@');
if ($object instanceof \Federator\Data\ActivityPub\Common\Article) {
$object = \Federator\DIO\Article::conditionalConvertToNote($object, $targetUrl);
$newActivity->setObject($object);
} else {
error_log("NewContent::postForUser Error in Create/Update Article for recipient $recipient->id, object is not an Article");
}
}
\Federator\DIO\Posts::savePost($dbh, $user->id, $newActivity);
break;
default: default:
\Federator\DIO\Posts::savePost($dbh, $user->id, $newActivity); \Federator\DIO\Posts::savePost($dbh, $user->id, $newActivity);
break; break;

View file

@ -376,6 +376,17 @@ class APObject implements \JsonSerializable
$this->summary = $summary; $this->summary = $summary;
return $this; return $this;
} }
/**
* get summary
*
* @return string summary
*/
public function getSummary()
{
return $this->summary;
}
/** /**
* set type * set type
* *
@ -459,6 +470,16 @@ class APObject implements \JsonSerializable
return $this; return $this;
} }
/**
* get name
*
* @return string name
*/
public function getName() : string
{
return $this->name;
}
/** /**
* add Image * add Image
* *

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

@ -393,11 +393,11 @@ class Followers
* *
* @param \mysqli $dbh database handle * @param \mysqli $dbh database handle
* @param string $followId the follow ID to use (should be an external url) * @param string $followId the follow ID to use (should be an external url)
* @param string $sourceUser source user id * @param string $sourceUserId source user id
* @param string $targetUserId target user id * @param string $targetUserId target user id
* @return boolean true on success, false on failure * @return boolean true on success, false on failure
*/ */
public static function addExternalFollow($dbh, $followId, $sourceUser, $targetUserId) public static function addExternalFollow($dbh, $followId, $sourceUserId, $targetUserId)
{ {
// Check if we already follow this user // Check if we already follow this user
$sql = 'select id from follows where source_user = ? and target_user = ?'; $sql = 'select id from follows where source_user = ? and target_user = ?';
@ -405,7 +405,7 @@ class Followers
if ($stmt === false) { if ($stmt === false) {
throw new \Federator\Exceptions\ServerError("Followers::addExternalFollow Failed to prepare statement"); throw new \Federator\Exceptions\ServerError("Followers::addExternalFollow Failed to prepare statement");
} }
$stmt->bind_param("ss", $sourceUser, $targetUserId); $stmt->bind_param("ss", $sourceUserId, $targetUserId);
$foundId = 0; $foundId = 0;
$ret = $stmt->bind_result($foundId); $ret = $stmt->bind_result($foundId);
$stmt->execute(); $stmt->execute();
@ -423,7 +423,7 @@ class Followers
if ($stmt === false) { if ($stmt === false) {
throw new \Federator\Exceptions\ServerError("Followers::addExternalFollow Failed to prepare insert statement"); throw new \Federator\Exceptions\ServerError("Followers::addExternalFollow Failed to prepare insert statement");
} }
$stmt->bind_param("sss", $followId, $sourceUser, $targetUserId); $stmt->bind_param("sss", $followId, $sourceUserId, $targetUserId);
$stmt->execute(); $stmt->execute();
$stmt->close(); $stmt->close();
return true; return true;

View file

@ -78,11 +78,59 @@ class Posts
} }
} }
$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 // save posts to DB
foreach ($posts as $post) { foreach ($posts as $post) {
if ($post->getID() !== "") { if ($post->getID() !== "") {
self::savePost($dbh, $userid, $post); 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) { if ($cache !== null) {

View file

@ -635,9 +635,9 @@ class ContentNation implements Connector
} }
$replyType = $jsonData['object']['inReplyTo']['type'] ?? null; $replyType = $jsonData['object']['inReplyTo']['type'] ?? null;
if ($replyType === "article") { if ($replyType === "article") {
$returnJson['object']['inReplyTo'] = $ourUrl . "/" . $articleOwnerName . "/" . $articleName; $returnJson['inReplyTo'] = $ourUrl . "/" . $articleOwnerName . "/" . $articleName;
} elseif ($replyType === "comment") { } elseif ($replyType === "comment") {
$returnJson['object']['inReplyTo'] = $ourUrl . "/" . $articleOwnerName . "/" . $articleName . "#" . $jsonData['object']['inReplyTo']['id']; $returnJson['inReplyTo'] = $ourUrl . "/" . $articleOwnerName . "/" . $articleName . "#" . $jsonData['object']['inReplyTo']['id'];
} else { } else {
error_log("ContentNation::generateObjectJson for comment - unknown inReplyTo type: {$replyType}"); error_log("ContentNation::generateObjectJson for comment - unknown inReplyTo type: {$replyType}");
} }