レポジトリ種類: SVN
<?php
namespace Site\Lib;
use Site\Lib\Curl;
/**
* ActivityPubプロトコルの実装クラス
*
* このクラスはActivityPubプロトコルを利用して分散型
* ソーシャルネットワーキングを実装します。
*/
class ActivityPub {
private string $domain;
private string $actor;
private string $actorNick;
private string $desc;
private string $icon;
private array $posts = [];
/**
* コンストラクタ
*
* @param array $posts 投稿データの配列
*/
public function __construct(array $posts = []) {
$this->domain = $_SERVER['SERVER_NAME'];
$this->actor = FEDIINFO['actor'];
$this->actorNick = FEDIINFO['actorNick'];
$this->desc = FEDIINFO['desc'];
$this->icon = "https://{$this->domain}".FEDIINFO['icon'];
$this->posts = $posts;
}
/**
* ActivityPubアクタープロフィールを受け取る
*
* @return string アクターオブジェクト
* @throws \Exception 公開鍵の読み込みに失敗した場合
*/
public function getActor(): string {
$pubkey = file_get_contents(FEDIINFO['pubkey']);
if ($pubkey === false) {
throw new \Exception('公開鍵の受取に失敗。パス:'.FEDIINFO['pubkey']);
}
$actor = [
'@context' => [
'https://www.w3.org/ns/activitystreams',
'https://w3id.org/security/v1',
],
'id' => "https://{$this->domain}/ap/actor",
'name' => $this->actorNick,
'summary' => $this->desc,
'manuallyApprovesFollowers' => false,
'icon' => [
'type' => 'Image',
'mediaType' => 'image/png',
'url' => $this->icon,
],
'image' => [
'type' => 'Image',
'url' =>
"https://{$this->domain}/static/article/o_53803618dc1691.28179609.jpg",
'mediaType' => 'image/jpeg',
],
'type' => 'Person',
'url' => "https://{$this->domain}",
'preferredUsername' => $this->actor,
'inbox' => "https://{$this->domain}/ap/inbox",
'outbox' => "https://{$this->domain}/ap/outbox",
'followers' => "https://{$this->domain}/ap/followers",
'following' => "https://{$this->domain}/ap/following",
'published' => '2025-03-28T18:00:00Z',
'updated' => gmdate('c'),
'publicKey' => [
'id' => "https://{$this->domain}/ap/actor#main-key",
'owner' => "https://{$this->domain}/ap/actor",
'publicKeyPem' => $pubkey,
],
];
return json_encode($actor, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
}
/**
* 特定のUUIDに対応するActivityを取得する
*
* @param string $uuid 取得するアクティビティのUUID
* @return string JSONエンコードされたアクティビティデータ
*/
public function getActivity(string $uuid): string {
$items = [];
foreach ($this->posts as $post) {
if ($post['uuid'] != $uuid) continue;
$items = [
'@context' => [
'https://www.w3.org/ns/activitystreams',
'https://w3id.org/security/v1',
[
'Emoji' => 'toot:Emoji',
'EmojiReact' => 'litepub:EmojiReact',
'Hashtag' => 'as:Hashtag',
'litepub' => 'http://litepub.social/ns#',
'sensitive' => 'as:sensitive',
'toot' => 'http://joinmastodon.org/ns#',
],
],
'id' => "https://{$this->domain}/ap/activities/create/{$post['uuid']}",
'type' => 'Create',
'actor' => "https://{$this->domain}/ap/actor",
'cc' => [
"https://{$this->domain}/ap/followers",
],
'published' => date("Y-m-d\TH:i:s.u\Z", strtotime($post['date'])),
'to' => ['https://www.w3.org/ns/activitystreams#Public'],
'object' => [
'id' => "https://{$this->domain}/ap/objects/{$post['uuid']}",
'type' => 'Note',
'name' => $post['title'],
'attributedTo' => "https://{$this->domain}/ap/actor",
'cc' => [
"https://{$this->domain}/ap/followers",
],
'to' => ['https://www.w3.org/ns/activitystreams#Public'],
'content' =>
$post['preview']."<br /><br /><a href=\"https://{$this->domain}/blog/{$post['slug']}\">読み続き</a>",
'url' => "https://{$this->domain}/blog/{$post['slug']}",
'published' => date("Y-m-d\TH:i:s.u\Z", strtotime($post['date'])),
'replies' => "https://{$this->domain}/ap/objects/{$uuid}/replies",
'sensitive' => false,
],
];
if (isset($post['category']) && !empty($post['category'])) {
$item['tag'] = [];
foreach ($post['category'] as $cat) {
$items['tag'][] = $cat;
}
}
if (isset($post['thumbnail']) && $post['thumbnail'] != '') {
$imgurl = "https://technicalsuwako.moe/static/article/{$post['thumbnail']}";
$imgpath = ROOT."/public/static/article/{$post['thumbnail']}";
$imgraw = file_get_contents($imgpath);
$items['attachment'] = [
[
'digestMultibase' => 'z'.base58btc_encode(hash('sha256', $imgraw, true)),
'mediaType' => mime_content_type($imgpath),
'type' => "Image",
'url' => $imgurl,
],
];
}
break;
}
return json_encode($items, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
}
/**
* アウトボックスデータを取得する
*
* @return string JSONエンコードされたアウトボックスデータ
*/
public function getOutbox(): string {
$items = [];
$counter = 0;
foreach ($this->posts as $post) {
$uid = $post['uuid'];
$items[] = [
'@context' => [
'https://www.w3.org/ns/activitystreams',
'https://w3id.org/security/v1',
[
'Emoji' => 'toot:Emoji',
'EmojiReact' => 'litepub:EmojiReact',
'Hashtag' => 'as:Hashtag',
'litepub' => 'http://litepub.social/ns#',
'sensitive' => 'as:sensitive',
'toot' => 'http://joinmastodon.org/ns#',
],
],
'id' => "https://{$this->domain}/ap/activities/create/{$uid}",
'type' => 'Create',
'actor' => "https://{$this->domain}/ap/actor",
'cc' => [
"https://{$this->domain}/ap/followers",
],
'published' => date("Y-m-d\TH:i:s.u\Z", strtotime($post['date'])),
'to' => ['https://www.w3.org/ns/activitystreams#Public'],
'object' => [
'id' => "https://{$this->domain}/ap/objects/{$uid}",
'type' => 'Note',
'name' => $post['title'],
'attributedTo' => "https://{$this->domain}/ap/actor",
'cc' => [
"https://{$this->domain}/ap/followers",
],
'to' => ['https://www.w3.org/ns/activitystreams#Public'],
'content' =>
$post['preview']."<br /><br /><a href=\"https://{$this->domain}/blog/{$post['slug']}\">読み続き</a>",
'url' => "https://{$this->domain}/blog/{$post['slug']}",
'published' => date("Y-m-d\TH:i:s.u\Z", strtotime($post['date'])),
'replies' => "https://{$this->domain}/ap/objects/{$uid}/replies",
'sensitive' => false,
],
];
if (isset($post['category']) && !empty($post['category'])) {
$items[$counter]['tag'] = [];
foreach ($post['category'] as $cat) {
$items[$counter]['tag'][] = $cat;
}
}
if (isset($post['thumbnail']) && $post['thumbnail'] != '') {
$imgurl = "https://technicalsuwako.moe/static/article/{$post['thumbnail']}";
$imgpath = ROOT."/public/static/article/{$post['thumbnail']}";
$imgraw = file_get_contents($imgpath);
$items[$counter]['attachment'] = [
[
'digestMultibase' => 'z'.base58btc_encode(hash('sha256', $imgraw, true)),
'mediaType' => mime_content_type($imgpath),
'type' => "Image",
'url' => $imgurl,
],
];
}
$counter++;
}
$outbox = [
'@context' => [
'https://www.w3.org/ns/activitystreams',
'https://w3id.org/security/v1',
],
'id' => "https://{$this->domain}/ap/outbox",
'type' => 'OrderedCollection',
'totalItems' => count($items),
'orderedItems' => $items,
];
return json_encode($outbox, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
}
/**
* WebFingerデータを取得する
*
* @return string JSONエンコードされたWebFingerデータ
*/
public function getWebfinger(): string {
$webfinger = [
'subject' => "acct:{$this->actor}@{$this->domain}",
'links' => [
[
'rel' => 'self',
'type' => 'application/activity+json',
'href' => "https://{$this->domain}/ap/actor",
],
],
];
return json_encode($webfinger, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
}
/**
* フォロワーのリストを取得する
*
* @return string JSONエンコードされたフォロワーのリスト
*/
public function getFollowers(): string {
$f = array_filter(explode("\n", file_get_contents(ROOT.'/data/followers.txt')));
$followers = [
'@context' => [
'https://www.w3.org/ns/activitystreams',
'https://w3id.org/security/v1',
],
'id' => "https://{$this->domain}/ap/followers",
'type' => 'OrderredCollection',
'totalItems' => count($f),
'orderedItems' => $f,
];
return json_encode($followers, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
}
/**
* フォローしているアカウントのリストを取得する
*
* @return string JSONエンコードされたフォローリスト
*/
public function getFollowing(): string {
$f = array_filter(explode("\n", file_get_contents(ROOT.'/data/following.txt')));
$following = [
'@context' => [
'https://www.w3.org/ns/activitystreams',
'https://w3id.org/security/v1',
],
'id' => "https://{$this->domain}/ap/following",
'type' => 'OrderredCollection',
'totalItems' => count($f),
'orderedItems' => $f,
];
return json_encode($following, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
}
/**
* インボックスにアクティビティを投稿する
*
* @param array $activity 処理するアクティビティデータ
* @return void
*/
public function postInbox(array $activity): void {
switch ($activity['type']) {
case 'Follow':
$this->acceptFollower($activity);
break;
default:
header('HTTP/1.1 501 Not Implemented');
header('Content-Type: application/activity+json');
echo json_encode(['error' =>
'未対応なアクティビティタイプ: '.$activity['type']]);
exit;
}
header('HTTP/1.1 200 OK');
header('Content-Type: application/activity+json');
echo json_encode(['status' => 'OK'], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
exit;
}
/**
* アクタープロフィールの更新アクティビティを作成する
*
* @return string JSONエンコードされた更新アクティビティ
*/
public function update(): string {
$update = [
'@context' => [
'https://www.w3.org/ns/activitystreams',
'https://w3id.org/security/v1',
],
'id' => "https://{$this->domain}/ap/activities/update/".uuid(),
'type' => 'Update',
'actor' => "https://{$this->domain}/ap/actor",
'to' => ['https://www.w3.org/ns/activitystreams#Public'],
'object' => json_decode($this->getActor(), true),
];
return json_encode($update, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
}
/**
* アクター更新をフォロワーに送信する
*
* @param array $params パラメータ配列
* @return void
*/
public function sendActorUpdate(array $params): void {
$f = array_filter(explode("\n", file_get_contents(ROOT.'/data/followers.txt')));
$ap = new Activitypub();
$inboxes = implode("\n", $f);
$update = json_decode($ap->update(), true);
foreach ($f as $inbox) {
$this->sendActivity($inbox, $update);
}
}
// 機能性メソッド
/**
* 指定されたインボックスURLにアクティビティを送信する
*
* @param string $inboxUrl 送信先のインボックスURL
* @param array $activity 送信するアクティビティデータ
* @return void
*/
private function sendActivity(string $inboxUrl, array $activity): void {
$privFile = FEDIINFO['privkey'];
$priv = file_get_contents($privFile);
if ($priv === false) {
logger(\LogType::ActivityPub, "エラー:秘密鍵「{$privFile}」の読込に失敗");
header('HTTP/1.1 500 Internal Server Error');
header('Content-Type: application/activity+json');
echo json_encode(['error' => '秘密鍵の読込に失敗']);
exit;
}
$body = json_encode($activity, JSON_UNESCAPED_SLASHES);
$digest = base64_encode(hash('sha256', $body, true));
$date = gmdate('D, d M Y H:i:s \G\M\T');
$host = parse_url($inboxUrl, PHP_URL_HOST);
$headers = [
'Host' => $host,
'Date' => $date,
'Content-Type' => 'application/activity+json',
'Digest' => "SHA-256=$digest",
];
$stringToSign = "host: {$headers['Host']}\n"
."date: {$headers['Date']}\n"
."digest: {$headers['Digest']}";
logger(\LogType::ActivityPub, "署名対象: {$stringToSign}");
if (!openssl_sign($stringToSign, $signature, $priv, OPENSSL_ALGO_SHA256)) {
$error = openssl_error_string();
logger(\LogType::ActivityPub, "エラー:署名に失敗: {$error}");
header('HTTP/1.1 500 Internal Server Error');
header('Content-Type: application/activity+json');
echo json_encode(['error' => '署名に失敗']);
exit;
}
$sigValue = base64_encode($signature);
$headers['Signature'] = "keyId=\"https://{$this->domain}/ap/actor#main-key\",";
$headers['Signature'] .= 'algorithm="rsa-sha256",';
$headers['Signature'] .= 'headers="host date digest",';
$headers['Signature'] .= 'signature="'.$sigValue.'"';
logger(\LogType::ActivityPub,
"署名: {$headers['Signature']}\n送信データ: {$body}");
$curl = new Curl($inboxUrl);
$curl->setMethod('POST')
->setPostRaw($body)
->setHeaders(array_map(fn($k, $v) => "$k: $v",
array_keys($headers), $headers))
->setCaInfo('/etc/ssl/cert.pem')
->setVerbose(true)
->setStderr(fopen(ROOT.'/log/ap_log.txt', 'a'));
$success = $curl->execute();
$res = $curl->getResponseBody();
$code = $curl->getResponseCode();
$err = $curl->getError();
var_dump(print_r($res));
logger(\LogType::ActivityPub,
"アクティビティは「{$inboxUrl}」に送信しました: HTTP {$code}");
logger(\LogType::ActivityPub, "エラー: {$err}");
logger(\LogType::ActivityPub, "レスポンス: {$res}");
}
/**
* フォロワーを受け入れる
*
* @param array $activity フォローアクティビティデータ
* @return void
*/
private function acceptFollower(array $activity): void {
$followerActor = $activity['actor'] ?? null;
if (!$followerActor) {
header('HTTP/1.1 400 Bad Request');
header('Content-Type: application/activity+json');
echo json_encode(['error' => 'アクターがない']);
exit;
}
$this->storeFollower($followerActor);
$inbox = $this->getInboxFromActor($followerActor);
if (!$inbox) {
header('HTTP/1.1 500 Internal Server Error');
header('Content-Type: application/activity+json');
echo json_encode(['error' => 'フォロワーの受付ボックスの受取に失敗']);
exit;
}
$accept = [
'@context' => [
'https://www.w3.org/ns/activitystreams',
'https://w3id.org/security/v1',
],
'id' => "https://{$this->domain}/ap/activities/".uniqid(),
'type' => 'Accept',
'actor' => "https://{$this->domain}/ap/actor",
'object' => $activity,
];
$this->sendActivity($inbox, $accept);
}
/**
* フォロワーをストレージに保存する
*
* @param string $followerActor フォロワーのアクターURL
* @return void
*/
private function storeFollower(string $followerActor): void {
$file = ROOT.'/data/followers.txt';
if (!file_exists($file)) {
touch($file);
chmod($file, 0644);
}
$followers = $this->getFollowersList();
if (!in_array($followerActor, $followers)) {
file_put_contents($file, "$followerActor\n", FILE_APPEND);
}
}
/**
* フォロワーのリストを配列として取得する
*
* @return array フォロワーのURLの配列
*/
private function getFollowersList(): array {
$file = ROOT.'/data/followers.txt';
$f = array_filter(explode("\n", file_get_contents($file)));
return file_exists($file)
? array_filter(explode("\n", file_get_contents($file))) : [];
}
/**
* アクターのインボックスURLを取得する
*
* @param string $actor アクターのURL
* @return string|null インボックスURL、取得に失敗した場合はnull
*/
private function getInboxFromActor(string $actor): ?string {
$curl = new Curl($actor);
$curl->setHeaders(['Accept: application/activity+json'])
->setFollowRedirects(true)
->setMaxRedirects(5)
->setCaInfo('/etc/ssl/cert.pem');
logger(\LogType::ActivityPub, "アクターURLにリクエスト: {$actor}");
$success = $curl->execute();
if (!$success) {
logger(\LogType::ActivityPub, "アクターリクエストに失敗: " . $curl->getError());
return null;
}
$res = $curl->getResponseBody();
$code = $curl->getResponseCode();
$err = $curl->getError();
if ($code !== 200) {
logger(\LogType::ActivityPub, "アクター取得に失敗: HTTP {$code}, エラー: {$err}");
return null;
}
$data = json_decode($res, true);
return $data['inbox'] ?? null;
}
}