'公開', 'unlisted' => '未収載', 'followers' => 'フォロワー限定', 'direct' => 'ダイレクト']; //////// 投稿 /** * 投稿する * * @param string $username 投稿するユーザー * @param string $text 本文(プレーンテキスト) * @param array $opt cw(注意書き)、visibility、inReplyTo(返信先のID)、media(Media::saveの結果のdata)、alts(代替テキスト) * @return Result data:note */ public static function post(string $username, string $text, array $opt = []): Result { $text = trim(str_replace("\r\n", "\n", $text)); $cw = trim((string)($opt['cw'] ?? '')); $media = $opt['media'] ?? []; if ($text === '' && empty($media)) return Result::Error('本文を入力して下さい。'); if (mb_strlen($text) > Fedi::MAX_LENGTH) return Result::Error('本文は'.Fedi::MAX_LENGTH.'文字までです。'); if (mb_strlen($cw) > 200) return Result::Error('注意書きは200文字までです。'); if (count($media) > Fedi::MAX_ATTACHMENTS) return Result::Error('添付は'.Fedi::MAX_ATTACHMENTS.'枚までです。'); $vis = (string)($opt['visibility'] ?? 'public'); if (!isset(self::VISIBILITIES[$vis])) $vis = 'public'; // 返信先(parentObject:取得出来ない返信先。ブログの投稿のNote等、記事のコメントから) $parent = null; $inReplyTo = trim((string)($opt['inReplyTo'] ?? '')); if ($inReplyTo !== '') { $parent = is_array($opt['parentObject'] ?? null) && ($opt['parentObject']['id'] ?? null) === $inReplyTo ? $opt['parentObject'] : Remote::object($inReplyTo); if (!$parent) return Result::Error('返信先の投稿が見つかりません。'); } [$mentionUrls, $tags] = self::resolveTags($text); $mentioned = array_values(array_unique(array_values($mentionUrls))); $parentAuthor = $parent ? Fedi::id($parent['attributedTo'] ?? null) : null; if ($parentAuthor && $parentAuthor !== Fedi::actorUrl($username)) $mentioned[] = $parentAuthor; $mentioned = array_values(array_unique($mentioned)); [$to, $cc] = self::address($username, $vis, $mentioned); $attachments = []; foreach (array_values($media) as $i => $m) { $a = ['type' => str_starts_with($m['type'], 'video/') ? 'Video' : 'Image', 'mediaType' => $m['type'], 'url' => $m['url'], 'name' => mb_substr((string)($opt['alts'][$i] ?? ''), 0, 1500)]; if (!empty($m['width'])) $a['width'] = $m['width']; if (!empty($m['height'])) $a['height'] = $m['height']; $a['path'] = $m['path']; $attachments[] = $a; } $note = [ 'id' => Fedi::newId(), 'author' => $username, 'source' => $text, 'content' => Html::render($text, $mentionUrls), 'summary' => $cw, 'sensitive' => $cw !== '', 'visibility' => $vis, 'to' => $to, 'cc' => $cc, 'inReplyTo' => $parent ? $parent['id'] : null, 'tags' => $tags, 'attachments' => $attachments, 'published' => Fedi::now(), 'likes' => [], 'boosts' => [], 'replies' => [], ]; if (!Notes::save($note)) return Result::Error('投稿の保存に失敗しました。'); $obj = Notes::toObject($note); Timeline::add($obj, null, microtime(true)); $create = Notes::toCreate($note); Delivery::enqueue($username, $create, Delivery::audience($username, $create)); return Result::Success('投稿しました。', ['note' => $note]); } /** * 自分の投稿を編集する(本文と注意書き。公開範囲は変えられない) */ public static function edit(string $username, string $id, string $text, string $cw = ''): Result { $note = Notes::get($id); if (!$note || $note['author'] !== $username) return Result::Error('投稿が見つかりません。'); $text = trim(str_replace("\r\n", "\n", $text)); $cw = trim($cw); if ($text === '' && empty($note['attachments'])) return Result::Error('本文を入力して下さい。'); if (mb_strlen($text) > Fedi::MAX_LENGTH) return Result::Error('本文は'.Fedi::MAX_LENGTH.'文字までです。'); if (mb_strlen($cw) > 200) return Result::Error('注意書きは200文字までです。'); [$mentionUrls, $tags] = self::resolveTags($text); // 新しいメンションを宛先に追加する(外さない。既に届いているので) $new = array_diff(array_values($mentionUrls), $note['to'], $note['cc']); if ($note['visibility'] === 'direct') $note['to'] = array_values(array_merge($note['to'], $new)); else $note['cc'] = array_values(array_merge($note['cc'], $new)); $note['source'] = $text; $note['content'] = Html::render($text, $mentionUrls); $note['summary'] = $cw; $note['sensitive'] = $cw !== ''; $note['tags'] = $tags; $note['updated'] = Fedi::now(); Notes::modify($id, fn($n) => array_merge($n, array_intersect_key($note, array_flip(['source', 'content', 'summary', 'sensitive', 'tags', 'updated', 'to', 'cc'])))); $obj = Notes::toObject(Notes::get($id)); Timeline::refresh($obj); unset($obj['@context']); $update = [ '@context' => Fedi::CONTEXT, 'id' => Notes::url($note).'#updates/'.time(), 'type' => 'Update', 'actor' => Fedi::actorUrl($username), 'to' => $note['to'], 'cc' => $note['cc'], 'object' => $obj, ]; Delivery::enqueue($username, $update, Delivery::audience($username, $update)); // 記事のコメントとしても投稿していれば、コメントも変える Comments::updateRemote($obj, Fedi::actorUrl($username)); return Result::Success('編集しました。'); } /** * 自分の投稿を削除する */ public static function delete(string $username, string $id): Result { $note = Notes::get($id); if (!$note || $note['author'] !== $username) return Result::Error('投稿が見つかりません。'); $url = Notes::url($note); $delete = [ '@context' => Fedi::CONTEXT, 'id' => "{$url}#delete", 'type' => 'Delete', 'actor' => Fedi::actorUrl($username), 'to' => $note['to'], 'cc' => $note['cc'], 'object' => ['id' => $url, 'type' => 'Tombstone'], ]; // 宛先は消す前に計算する(いいね・ブーストした人にも届ける) $inboxes = Delivery::audience($username, $delete, array_merge(array_keys($note['likes'] ?? []), array_keys($note['boosts'] ?? []))); foreach ($note['attachments'] as $a) if (!empty($a['path'])) Media::remove($a['path']); Notes::delete($id); Timeline::remove($url); if (!empty($note['inReplyTo'])) self::forgetReply($note['inReplyTo'], $url); Delivery::enqueue($username, $delete, $inboxes); // 記事のコメントとしても投稿していれば、コメントも消す Comments::deleteRemote($url, Fedi::actorUrl($username)); return Result::Success('削除しました。'); } //////// いいね・ブースト public static function like(string $username, string $objectId): Result { $obj = Remote::object($objectId); if (!$obj) return Result::Error('投稿が見つかりません。'); $author = Fedi::id($obj['attributedTo'] ?? null); if ($author === null) return Result::Error('投稿者が分かりません。'); $likes = Fedi::read(Fedi::userPath($username, 'likes.json')); if (isset($likes[$objectId])) return Result::Success(); $me = Fedi::actorUrl($username); $like = ['@context' => Fedi::CONTEXT, 'id' => "{$me}#likes/".Fedi::newId(), 'type' => 'Like', 'actor' => $me, 'object' => $objectId]; Fedi::update(Fedi::userPath($username, 'likes.json'), fn($l) => [$objectId => $like['id']] + $l); Delivery::enqueue($username, $like, self::inboxesOf([$author])); return Result::Success('いいねしました。'); } public static function unlike(string $username, string $objectId): Result { $likes = Fedi::read(Fedi::userPath($username, 'likes.json')); if (!isset($likes[$objectId])) return Result::Success(); $obj = Remote::object($objectId); $me = Fedi::actorUrl($username); $undo = ['@context' => Fedi::CONTEXT, 'id' => $likes[$objectId].'/undo', 'type' => 'Undo', 'actor' => $me, 'object' => ['id' => $likes[$objectId], 'type' => 'Like', 'actor' => $me, 'object' => $objectId]]; Fedi::update(Fedi::userPath($username, 'likes.json'), function($l) use ($objectId) { unset($l[$objectId]); return $l; }); $author = $obj ? Fedi::id($obj['attributedTo'] ?? null) : null; if ($author) Delivery::enqueue($username, $undo, self::inboxesOf([$author])); return Result::Success('いいねを取り消しました。'); } public static function boost(string $username, string $objectId): Result { $obj = Remote::object($objectId); if (!$obj) return Result::Error('投稿が見つかりません。'); $vis = Fedi::visibility(Fedi::ids($obj['to'] ?? []), Fedi::ids($obj['cc'] ?? [])); if (!in_array($vis, ['public', 'unlisted'], true)) return Result::Error('公開の投稿だけブースト出来ます。'); $boosts = Fedi::read(Fedi::userPath($username, 'boosts.json')); if (isset($boosts[$objectId])) return Result::Success(); $me = Fedi::actorUrl($username); $author = Fedi::id($obj['attributedTo'] ?? null); $announce = [ '@context' => Fedi::CONTEXT, 'id' => "{$me}#boosts/".Fedi::newId(), 'type' => 'Announce', 'actor' => $me, 'published' => Fedi::now(), 'to' => [Fedi::PUBLIC], 'cc' => array_values(array_filter([$me.'/followers', $author])), 'object' => $objectId, ]; Fedi::update(Fedi::userPath($username, 'boosts.json'), fn($b) => [$objectId => $announce['id']] + $b); if (!Fedi::isLocal($objectId)) Remote::store($obj); Timeline::add($obj, $me); Delivery::enqueue($username, $announce, Delivery::audience($username, $announce)); return Result::Success('ブーストしました。'); } public static function unboost(string $username, string $objectId): Result { $boosts = Fedi::read(Fedi::userPath($username, 'boosts.json')); if (!isset($boosts[$objectId])) return Result::Success(); $me = Fedi::actorUrl($username); $obj = Remote::object($objectId); $author = $obj ? Fedi::id($obj['attributedTo'] ?? null) : null; $undo = [ '@context' => Fedi::CONTEXT, 'id' => $boosts[$objectId].'/undo', 'type' => 'Undo', 'actor' => $me, 'to' => [Fedi::PUBLIC], 'cc' => array_values(array_filter([$me.'/followers', $author])), 'object' => ['id' => $boosts[$objectId], 'type' => 'Announce', 'actor' => $me, 'object' => $objectId], ]; Fedi::update(Fedi::userPath($username, 'boosts.json'), function($b) use ($objectId) { unset($b[$objectId]); return $b; }); Timeline::remove($objectId, $me); Delivery::enqueue($username, $undo, Delivery::audience($username, $undo)); return Result::Success('ブーストを取り消しました。'); } public static function hasLiked(string $username, string $objectId): bool { return isset(Fedi::read(Fedi::userPath($username, 'likes.json'))[$objectId]); } public static function hasBoosted(string $username, string $objectId): bool { return isset(Fedi::read(Fedi::userPath($username, 'boosts.json'))[$objectId]); } //////// フォロー public static function follow(string $username, string $target): Result { $me = Fedi::actorUrl($username); if ($target === $me) return Result::Error('自分はフォロー出来ません。'); if ($target === Fedi::blogActorUrl()) return Result::Error('このサイトのブログはフォロー出来ません。ブログのフィードをご利用下さい。'); if (Actor::isBlocked($username, $target)) return Result::Error('ブロックしているアカウントはフォロー出来ません。'); $doc = Remote::actor($target); if (!$doc) return Result::Error('アカウントが見つかりません。'); $state = Actor::following($username)[$target]['state'] ?? null; if ($state !== null) return Result::Success($state === 'pending' ? '承認を待っています。' : 'フォローしています。'); $follow = ['@context' => Fedi::CONTEXT, 'id' => "{$me}#follows/".Fedi::newId(), 'type' => 'Follow', 'actor' => $me, 'object' => $target]; Fedi::update(Fedi::userPath($username, 'following.json'), fn($f) => $f + [$target => ['state' => 'pending', 'id' => $follow['id'], 'since' => time()]]); $inbox = Remote::inbox($doc, false); if ($inbox) Delivery::enqueue($username, $follow, [$inbox]); $state = Actor::following($username)[$target]['state'] ?? 'pending'; return Result::Success($state === 'accepted' ? 'フォローしました。' : 'フォローをリクエストしました。'); } /** * フォローした相手の最近の投稿をタイムラインに入れる * フォローする前の投稿は届かないので、これが無いとホームは相手の次の投稿まで空のまま * * @param string $actor リモートのアクター * @return int 入れた件数 */ public static function backfill(string $actor): int { if (Fedi::isLocal($actor) || empty(Inbox::localFollowersOf($actor))) return 0; $doc = Remote::actor($actor); if (!$doc) return 0; $notes = Remote::outbox($doc); foreach ($notes as $obj) Timeline::add($obj); return count($notes); } public static function unfollow(string $username, string $target): Result { $f = Actor::following($username)[$target] ?? null; if ($f === null) return Result::Success(); $me = Fedi::actorUrl($username); $undo = ['@context' => Fedi::CONTEXT, 'id' => $f['id'].'/undo', 'type' => 'Undo', 'actor' => $me, 'object' => ['id' => $f['id'], 'type' => 'Follow', 'actor' => $me, 'object' => $target]]; Fedi::update(Fedi::userPath($username, 'following.json'), function($l) use ($target) { unset($l[$target]); return $l; }); $doc = Remote::actor($target); $inbox = $doc ? Remote::inbox($doc, false) : null; if ($inbox) Delivery::enqueue($username, $undo, [$inbox]); return Result::Success('フォローを解除しました。'); } /** * フォローリクエストを承認・拒否する */ public static function answerRequest(string $username, string $actor, bool $accept): Result { $req = Actor::requests($username)[$actor] ?? null; if ($req === null) return Result::Error('リクエストが見つかりません。'); Fedi::update(Fedi::userPath($username, 'requests.json'), function($l) use ($actor) { unset($l[$actor]); return $l; }); if ($accept) { self::acceptFollow($username, $req['activity']); return Result::Success('承認しました。'); } self::rejectFollow($username, $req['activity']); return Result::Success('拒否しました。'); } /** * フォローを受け入れる(Inboxと承認から使う) */ public static function acceptFollow(string $username, array $follow): void { $actor = Fedi::id($follow['actor'] ?? null); $doc = $actor ? Remote::actor($actor) : null; if (!$doc) return; Fedi::update(Fedi::userPath($username, 'followers.json'), fn($l) => [$actor => [ 'inbox' => Remote::inbox($doc, false), 'sharedInbox' => Remote::inbox($doc, true), 'since' => time(), ]] + $l); $me = Fedi::actorUrl($username); $accept = ['@context' => Fedi::CONTEXT, 'id' => "{$me}#accepts/".Fedi::newId(), 'type' => 'Accept', 'actor' => $me, 'object' => $follow]; $inbox = Remote::inbox($doc, false); if ($inbox) Delivery::enqueue($username, $accept, [$inbox]); Actor::notify($username, 'follow', $actor); } public static function rejectFollow(string $username, array $follow): void { $actor = Fedi::id($follow['actor'] ?? null); $doc = $actor ? Remote::actor($actor) : null; $me = Fedi::actorUrl($username); $reject = ['@context' => Fedi::CONTEXT, 'id' => "{$me}#rejects/".Fedi::newId(), 'type' => 'Reject', 'actor' => $me, 'object' => $follow]; $inbox = $doc ? Remote::inbox($doc, false) : null; if ($inbox) Delivery::enqueue($username, $reject, [$inbox]); } /** * フォロワーを外す(相手のフォローを拒否する) */ public static function removeFollower(string $username, string $actor): Result { if (!Actor::isFollowedBy($username, $actor)) return Result::Success(); Fedi::update(Fedi::userPath($username, 'followers.json'), function($l) use ($actor) { unset($l[$actor]); return $l; }); self::rejectFollow($username, ['type' => 'Follow', 'actor' => $actor, 'object' => Fedi::actorUrl($username)]); return Result::Success('フォロワーから外しました。'); } //////// ブロック public static function block(string $username, string $target): Result { if ($target === Fedi::actorUrl($username)) return Result::Error('自分はブロック出来ません。'); Fedi::update(Fedi::userPath($username, 'blocks.json'), function($b) use ($target) { $b += ['actors' => [], 'domains' => []]; if (!in_array($target, $b['actors'], true)) $b['actors'][] = $target; return $b; }); if (Actor::isFollowing($username, $target) || isset(Actor::following($username)[$target])) self::unfollow($username, $target); if (Actor::isFollowedBy($username, $target)) { Fedi::update(Fedi::userPath($username, 'followers.json'), function($l) use ($target) { unset($l[$target]); return $l; }); } $me = Fedi::actorUrl($username); $block = ['@context' => Fedi::CONTEXT, 'id' => "{$me}#blocks/".sha1($target), 'type' => 'Block', 'actor' => $me, 'object' => $target]; Delivery::enqueue($username, $block, self::inboxesOf([$target], false)); return Result::Success('ブロックしました。'); } public static function unblock(string $username, string $target): Result { Fedi::update(Fedi::userPath($username, 'blocks.json'), function($b) use ($target) { $b += ['actors' => [], 'domains' => []]; $b['actors'] = array_values(array_diff($b['actors'], [$target])); return $b; }); $me = Fedi::actorUrl($username); $id = "{$me}#blocks/".sha1($target); $undo = ['@context' => Fedi::CONTEXT, 'id' => "{$id}/undo", 'type' => 'Undo', 'actor' => $me, 'object' => ['id' => $id, 'type' => 'Block', 'actor' => $me, 'object' => $target]]; Delivery::enqueue($username, $undo, self::inboxesOf([$target], false)); return Result::Success('ブロックを解除しました。'); } /** * ドメインごとブロックする(このユーザーだけ。サイト全体は管理者のblockInstance) */ public static function blockDomain(string $username, string $domain, bool $block = true): Result { $domain = strtolower(trim($domain)); if (!preg_match('/^[a-z0-9.-]+\.[a-z]{2,}$/', $domain)) return Result::Error('ドメインが不正です。'); if ($domain === strtolower(Fedi::host())) return Result::Error('このサイトはブロック出来ません。'); Fedi::update(Fedi::userPath($username, 'blocks.json'), function($b) use ($domain, $block) { $b += ['actors' => [], 'domains' => []]; $b['domains'] = array_values(array_diff($b['domains'], [$domain])); if ($block) $b['domains'][] = $domain; return $b; }); if ($block) { // そのドメインのフォロワーを外す Fedi::update(Fedi::userPath($username, 'followers.json'), fn($l) => array_filter($l, fn($k) => Fedi::domainOf($k) !== $domain, ARRAY_FILTER_USE_KEY)); } return Result::Success($block ? "{$domain}をブロックしました。" : "{$domain}のブロックを解除しました。"); } //////// 通報・管理 /** * 通報する(このサイトの管理者に記録し、リモートなら相手のサーバーにもFlagを送る) */ public static function report(string $username, string $target, array $objects, string $comment, bool $forward = true): Result { $comment = mb_substr(trim($comment), 0, 1000); Fedi::update(Fedi::path('reports.json'), function($l) use ($username, $target, $objects, $comment) { array_unshift($l, ['id' => Fedi::newId(), 'from' => Fedi::actorUrl($username), 'actor' => $target, 'objects' => array_values($objects), 'content' => $comment, 'at' => time(), 'remote' => false, 'resolved' => false]); return array_slice($l, 0, 500); }); if ($forward && !Fedi::isLocal($target)) { $me = Fedi::actorUrl($username); $flag = ['@context' => Fedi::CONTEXT, 'id' => "{$me}#flags/".Fedi::newId(), 'type' => 'Flag', 'actor' => $me, 'object' => array_values(array_merge([$target], $objects)), 'content' => $comment]; Delivery::enqueue($username, $flag, self::inboxesOf([$target])); } return Result::Success('通報しました。'); } /** * サイト全体でドメインをブロックする(管理者) */ public static function blockInstance(string $domain, bool $block = true): Result { $domain = strtolower(trim($domain)); if (!preg_match('/^[a-z0-9.-]+\.[a-z]{2,}$/', $domain)) return Result::Error('ドメインが不正です。'); if ($domain === strtolower(Fedi::host())) return Result::Error('このサイトはブロック出来ません。'); Fedi::update(Fedi::path('blocked_domains.json'), function($l) use ($domain, $block) { $l = array_values(array_diff($l, [$domain])); if ($block) $l[] = $domain; sort($l); return $l; }); if ($block) { // 全員のフォロワーから外し、タイムラインからも消す foreach (self::localUsernames() as $u) { Fedi::update(Fedi::userPath($u, 'followers.json'), fn($l) => array_filter($l, fn($k) => !Actor::isDomainBlocked($k), ARRAY_FILTER_USE_KEY)); } foreach (Timeline::entries() as $e) { if (!$e['local'] && Actor::isDomainBlocked($e['author'])) Remote::forget($e['object']); } } return Result::Success($block ? "{$domain}をブロックしました。" : "{$domain}のブロックを解除しました。"); } public static function reports(): array { return Fedi::read(Fedi::path('reports.json')); } public static function resolveReport(string $id): void { Fedi::update(Fedi::path('reports.json'), fn($l) => array_map(fn($r) => $r['id'] === $id ? ['resolved' => true] + $r : $r, $l)); } /** * プロフィールの更新をフォロワーに送る(プロフィールを編集した後) */ public static function sendProfileUpdate(string $username): void { $u = Fedi::localActor($username); if (!$u || empty(Actor::followers($username))) return; $doc = Actor::document($u); unset($doc['@context']); $me = Fedi::actorUrl($username); $update = ['@context' => Fedi::CONTEXT, 'id' => "{$me}#updates/".time(), 'type' => 'Update', 'actor' => $me, 'to' => [Fedi::PUBLIC], 'cc' => ["{$me}/followers"], 'object' => $doc]; Delivery::enqueue($username, $update, Delivery::audience($username, $update)); } /** * Fediverseのデータがあるローカルのユーザー */ public static function localUsernames(): array { $out = []; foreach (glob(Fedi::path('users/').'*', GLOB_ONLYDIR) ?: [] as $d) $out[] = basename($d); return $out; } ////// /** * メンションとハッシュタグを解決する * * @return array [「@user@domain」 => アクターのURL, tag(Mention・Hashtag)] */ private static function resolveTags(string $text): array { $urls = []; $tags = []; foreach (Html::mentions($text) as $key => [$user, $domain]) { $url = $domain === null || $domain === strtolower(Fedi::host()) ? (Fedi::localActor($user) ? Fedi::actorUrl($user) : null) : Remote::webfinger($user, $domain); if ($url === null) continue; $urls[$key] = $url; $tags[] = ['type' => 'Mention', 'href' => $url, 'name' => $domain === null ? "@{$user}@".Fedi::host() : $key]; } foreach (Html::hashtags($text) as $tag) { $tags[] = ['type' => 'Hashtag', 'href' => Fedi::base().'/microblog/tag/'.rawurlencode($tag), 'name' => "#{$tag}"]; } return [$urls, $tags]; } /** * 公開範囲からto・cc */ private static function address(string $username, string $vis, array $mentioned): array { $followers = Fedi::actorUrl($username).'/followers'; return match ($vis) { 'public' => [[Fedi::PUBLIC], array_values(array_merge([$followers], $mentioned))], 'unlisted' => [[$followers], array_values(array_merge([Fedi::PUBLIC], $mentioned))], 'followers' => [[$followers], $mentioned], 'direct' => [$mentioned, []], }; } /** アクターの受付ボックス */ private static function inboxesOf(array $actors, bool $shared = true): array { $out = []; foreach ($actors as $a) { $doc = Remote::actor($a); $inbox = $doc ? Remote::inbox($doc, $shared) : null; if ($inbox) $out[] = $inbox; } return $out; } /** 削除した返信を返信先の投稿から外す */ private static function forgetReply(string $parentId, string $replyId): void { $ids = Fedi::localNoteId($parentId); if ($ids) Notes::modify($ids[1], function($n) use ($replyId) { $n['replies'] = array_values(array_diff($n['replies'] ?? [], [$replyId])); return $n; }); } }