castopod/app/Libraries/ActivityPub/Controllers/ActorController.php

362 lines
12 KiB
PHP

<?php
/**
* @copyright 2021 Podlibre
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
namespace ActivityPub\Controllers;
use ActivityPub\Config\ActivityPub;
use CodeIgniter\HTTP\RedirectResponse;
use CodeIgniter\HTTP\ResponseInterface;
use CodeIgniter\Exceptions\PageNotFoundException;
use ActivityPub\Entities\Note;
use ActivityPub\Objects\OrderedCollectionObject;
use ActivityPub\Objects\OrderedCollectionPage;
use App\Entities\Actor;
use CodeIgniter\Controller;
use CodeIgniter\I18n\Time;
class ActorController extends Controller
{
/**
* @var string[]
*/
protected $helpers = ['activitypub'];
protected Actor $actor;
protected ActivityPub $config;
public function __construct()
{
$this->config = config('ActivityPub');
}
public function _remap(string $method, string ...$params): mixed
{
if (
count($params) > 0 &&
!($this->actor = model('ActorModel')->getActorByUsername(
$params[0],
))
) {
throw PageNotFoundException::forPageNotFound();
}
unset($params[0]);
return $this->$method(...$params);
}
public function index(): RedirectResponse
{
$actorObjectClass = $this->config->actorObject;
$actorObject = new $actorObjectClass($this->actor);
return $this->response
->setContentType('application/activity+json')
->setBody($actorObject->toJSON());
}
/**
* Handles incoming requests from fediverse servers
*/
public function inbox(): ResponseInterface
{
// get json body and parse it
$payload = $this->request->getJSON();
// retrieve payload actor from database or create it if it doesn't exist
$payloadActor = get_or_create_actor_from_uri($payload->actor);
// store activity to database
$activityId = model('ActivityModel')->newActivity(
$payload->type,
$payloadActor->id,
$this->actor->id,
null,
json_encode($payload, JSON_THROW_ON_ERROR),
);
// switch/case on activity type
/** @phpstan-ignore-next-line */
switch ($payload->type) {
case 'Create':
if ($payload->object->type == 'Note') {
if (!$payload->object->inReplyTo) {
return $this->response->setStatusCode(501)->setJSON([]);
}
$replyToNote = model('NoteModel')->getNoteByUri(
$payload->object->inReplyTo,
);
// TODO: strip content from html to retrieve message
// remove all html tags and reconstruct message with mentions?
extract_text_from_html($payload->object->content);
$reply = new Note([
'uri' => $payload->object->id,
'actor_id' => $payloadActor->id,
'in_reply_to_id' => $replyToNote->id,
'message' => $payload->object->content,
'published_at' => Time::parse(
$payload->object->published,
),
]);
$noteId = model('NoteModel')->addReply($reply, true, false);
model('ActivityModel')->update($activityId, [
'note_id' => service('uuid')
->fromBytes($noteId)
->getString(),
]);
return $this->response->setStatusCode(200)->setJSON([]);
}
// return not handled undo error (501 = not implemented)
return $this->response->setStatusCode(501)->setJSON([]);
case 'Delete':
$noteToDelete = model('NoteModel')->getNoteByUri(
$payload->object->id,
);
model('NoteModel')->removeNote($noteToDelete, false);
return $this->response->setStatusCode(200)->setJSON([]);
case 'Follow':
// add to followers table
model('FollowModel')->addFollower(
$payloadActor,
$this->actor,
false,
);
// Automatically accept follow by returning accept activity
accept_follow($this->actor, $payloadActor, $payload->id);
// TODO: return 202 (Accepted) followed!
return $this->response->setStatusCode(202)->setJSON([]);
case 'Like':
// get favourited note
$note = model('NoteModel')->getNoteByUri($payload->object);
// Like side-effect
model('FavouriteModel')->addFavourite(
$payloadActor,
$note,
false,
);
model('ActivityModel')->update($activityId, [
'note_id' => $note->id,
]);
return $this->response->setStatusCode(200)->setJSON([]);
case 'Announce':
$note = model('NoteModel')->getNoteByUri($payload->object);
model('ActivityModel')->update($activityId, [
'note_id' => $note->id,
]);
model('NoteModel')->reblog($payloadActor, $note, false);
return $this->response->setStatusCode(200)->setJSON([]);
case 'Undo':
// switch/case on the type of activity to undo
/** @phpstan-ignore-next-line */
switch ($payload->object->type) {
case 'Follow':
// revert side-effect by removing follow from database
model('FollowModel')->removeFollower(
$payloadActor,
$this->actor,
false,
);
// TODO: undo has been accepted! (202 - Accepted)
return $this->response->setStatusCode(202)->setJSON([]);
case 'Like':
$note = model('NoteModel')->getNoteByUri(
$payload->object->object,
);
// revert side-effect by removing favourite from database
model('FavouriteModel')->removeFavourite(
$payloadActor,
$note,
false,
);
model('ActivityModel')->update($activityId, [
'note_id' => $note->id,
]);
return $this->response->setStatusCode(200)->setJSON([]);
case 'Announce':
$note = model('NoteModel')->getNoteByUri(
$payload->object->object,
);
$reblogNote = model('NoteModel')
->where([
'actor_id' => $payloadActor->id,
'reblog_of_id' => service('uuid')
->fromString($note->id)
->getBytes(),
])
->first();
model('NoteModel')->undoReblog($reblogNote, false);
model('ActivityModel')->update($activityId, [
'note_id' => $note->id,
]);
return $this->response->setStatusCode(200)->setJSON([]);
default:
// return not handled undo error (501 = not implemented)
return $this->response->setStatusCode(501)->setJSON([]);
}
default:
// return not handled activity error (501 = not implemented)
return $this->response->setStatusCode(501)->setJSON([]);
}
}
public function outbox(): RedirectResponse
{
// get published activities by publication date
$actorActivity = model('ActivityModel')
->where('actor_id', $this->actor->id)
->where('`created_at` <= NOW()', null, false)
->orderBy('created_at', 'DESC');
$pageNumber = $this->request->getGet('page');
if (!isset($pageNumber)) {
$actorActivity->paginate(12);
$pager = $actorActivity->pager;
$collection = new OrderedCollectionObject(null, $pager);
} else {
$paginatedActivity = $actorActivity->paginate(
12,
'default',
$pageNumber,
);
$pager = $actorActivity->pager;
$orderedItems = [];
foreach ($paginatedActivity as $activity) {
$orderedItems[] = $activity->payload;
}
$collection = new OrderedCollectionPage($pager, $orderedItems);
}
return $this->response
->setContentType('application/activity+json')
->setBody($collection->toJSON());
}
public function followers(): RedirectResponse
{
// get followers for a specific actor
$followers = model('ActorModel')
->join(
'activitypub_follows',
'activitypub_follows.actor_id = id',
'inner',
)
->where('activitypub_follows.target_actor_id', $this->actor->id)
->orderBy('activitypub_follows.created_at', 'DESC');
$pageNumber = $this->request->getGet('page');
if (!isset($pageNumber)) {
$followers->paginate(12);
$pager = $followers->pager;
$followersCollection = new OrderedCollectionObject(null, $pager);
} else {
$paginatedFollowers = $followers->paginate(
12,
'default',
$pageNumber,
);
$pager = $followers->pager;
$orderedItems = [];
foreach ($paginatedFollowers as $follower) {
$orderedItems[] = $follower->uri;
}
$followersCollection = new OrderedCollectionPage(
$pager,
$orderedItems,
);
}
return $this->response
->setContentType('application/activity+json')
->setBody($followersCollection->toJSON());
}
public function attemptFollow(): RedirectResponse|ResponseInterface
{
$rules = [
'handle' =>
'regex_match[/^@?(?P<username>[\w\.\-]+)@(?P<host>[\w\.\-]+)(?P<port>:[\d]+)?$/]',
];
if (!$this->validate($rules)) {
return redirect()
->back()
->withInput()
->with('errors', $this->validator->getErrors());
}
helper('text');
// get webfinger data from actor
// parse activityPub id to get actor and domain
// check if actor and domain exist
if (
!($parts = split_handle($this->request->getPost('handle'))) ||
!($data = get_webfinger_data($parts['username'], $parts['domain']))
) {
return redirect()
->back()
->withInput()
->with('error', lang('ActivityPub.follow.accountNotFound'));
}
$ostatusKey = array_search(
'http://ostatus.org/schema/1.0/subscribe',
array_column($data->links, 'rel'),
);
if (!$ostatusKey) {
// TODO: error, couldn't subscribe to activitypub account
// The instance doesn't allow its users to follow others
return $this->response->setJSON([]);
}
return redirect()->to(
str_replace(
'{uri}',
urlencode($this->actor->uri),
$data->links[$ostatusKey]->template,
),
);
}
public function activity(string $activityId): RedirectResponse
{
if (
!($activity = model('ActivityModel')->getActivityById($activityId))
) {
throw PageNotFoundException::forPageNotFound();
}
return $this->response
->setContentType('application/activity+json')
->setBody(json_encode($activity->payload, JSON_THROW_ON_ERROR));
}
}