toPem()); @chmod($file, 0600); file_put_contents(Fedi::userPath($username, 'public.pem'), $key->publicPem()); } } finally { if ($lock) { flock($lock, LOCK_UN); fclose($lock); } } } try { $key = Rsa::fromPem((string)file_get_contents($file)); } catch (\InvalidArgumentException $e) { logger(\LogType::ActivityPub, "「{$username}」の鍵を読めませんでした:".$e->getMessage()); return null; } return $key->isPrivate() ? $key : null; } public static function publicKeyPem(string $username): ?string { $file = Fedi::userPath($username, 'public.pem'); if (!is_file($file) && self::privateKey($username) === null) return null; return is_file($file) ? (string)file_get_contents($file) : null; } public static function keyId(string $username): string { return Fedi::actorUrl($username).'#main-key'; } /** ユーザーの設定(locked:フォローを承認制にする) */ public static function settings(string $username): array { return Fedi::read(Fedi::userPath($username, 'settings.json'), []) + ['locked' => false]; } public static function saveSettings(string $username, array $settings): void { Fedi::update(Fedi::userPath($username, 'settings.json'), fn($s) => array_merge($s, $settings)); } /** * アクターのJSON-LD * * @param \stdClass $user ローカルのユーザー(Fedi::localActor()) * @return array */ public static function document(\stdClass $user): array { $url = Fedi::actorUrl($user->username); $avatar = avatar_url($user); $doc = [ '@context' => Fedi::CONTEXT, 'id' => $url, 'type' => 'Person', 'preferredUsername' => $user->username, 'name' => ($user->displayname ?? '') !== '' ? $user->displayname : $user->username, 'summary' => ($user->bio ?? '') !== '' ? Html::render($user->bio) : '', 'url' => Fedi::base().'/microblog/user/'.$user->username, 'inbox' => "{$url}/inbox", 'outbox' => "{$url}/outbox", 'followers' => "{$url}/followers", 'following' => "{$url}/following", 'endpoints' => ['sharedInbox' => Fedi::sharedInbox()], 'manuallyApprovesFollowers' => (bool)self::settings($user->username)['locked'], 'discoverable' => true, 'published' => Fedi::now((int)($user->regDate ?? time())), 'icon' => [ 'type' => 'Image', 'mediaType' => self::mediaTypeOf($avatar), 'url' => preg_match('#^https?://#i', $avatar) ? $avatar : Fedi::base().$avatar, ], ]; $pem = self::publicKeyPem($user->username); if ($pem !== null) $doc['publicKey'] = ['id' => self::keyId($user->username), 'owner' => $url, 'publicKeyPem' => $pem]; return $doc; } /** * WebFingerの応答 * * @param string $resource acct:ユーザー@ホスト又はアクターのURL * @return array|null 見つからなければnull */ public static function webfinger(string $resource): ?array { $username = null; if (preg_match('/^(?:acct:)?@?([A-Za-z0-9]+)@(.+)$/', $resource, $m) && strtolower($m[2]) === strtolower(Fedi::host())) { $username = $m[1]; } else { $username = Fedi::localUsername($resource); } if ($username === null || Fedi::localActor($username) === null) return null; return [ 'subject' => "acct:{$username}@".Fedi::host(), 'aliases' => [Fedi::actorUrl($username), Fedi::base().'/microblog/user/'.$username], 'links' => [ ['rel' => 'self', 'type' => 'application/activity+json', 'href' => Fedi::actorUrl($username)], ['rel' => 'http://webfinger.net/rel/profile-page', 'type' => 'text/html', 'href' => Fedi::base().'/microblog/user/'.$username], ], ]; } //////// フォロワーとフォロー /** フォロワー(アクターのURL => [inbox, sharedInbox, since]) */ public static function followers(string $username): array { return Fedi::read(Fedi::userPath($username, 'followers.json')); } /** フォロー(アクターのURL => [state(pending/accepted), id, since]) */ public static function following(string $username): array { return Fedi::read(Fedi::userPath($username, 'following.json')); } /** 承認待ちのフォローリクエスト(アクターのURL => [activity, at]) */ public static function requests(string $username): array { return Fedi::read(Fedi::userPath($username, 'requests.json')); } public static function isFollowing(string $username, string $actor): bool { return (self::following($username)[$actor]['state'] ?? '') === 'accepted'; } public static function isFollowedBy(string $username, string $actor): bool { return isset(self::followers($username)[$actor]); } /** * フォロワー・フォローのOrderedCollection(一覧は公開しない。数だけ) */ public static function collection(string $username, string $which): array { $list = $which === 'followers' ? self::followers($username) : array_filter(self::following($username), fn($f) => ($f['state'] ?? '') === 'accepted'); return [ '@context' => 'https://www.w3.org/ns/activitystreams', 'id' => Fedi::actorUrl($username)."/{$which}", 'type' => 'OrderedCollection', 'totalItems' => count($list), ]; } //////// ブロック /** ブロック(actors:アクターのURL、domains:ドメイン) */ public static function blocks(string $username): array { return Fedi::read(Fedi::userPath($username, 'blocks.json')) + ['actors' => [], 'domains' => []]; } /** * このユーザー(又はサイト全体)がブロックしているか */ public static function isBlocked(string $username, string $actor): bool { if (self::isDomainBlocked($actor)) return true; $b = self::blocks($username); return in_array($actor, $b['actors'], true) || in_array(Fedi::domainOf($actor), $b['domains'], true); } /** サイト全体でブロックしているドメイン(管理者) */ public static function blockedDomains(): array { return Fedi::read(Fedi::path('blocked_domains.json')); } public static function isDomainBlocked(string $url): bool { $d = Fedi::domainOf($url); if ($d === '') return false; foreach (self::blockedDomains() as $b) { if ($d === $b || str_ends_with($d, '.'.$b)) return true; } return false; } //////// 通知 /** * 通知を追加する(新しい順、200件まで) * * @param string $username ユーザー * @param string $type follow, follow_request, mention, reply, like, boost * @param string $actor 誰から * @param string|null $object 対象の投稿 */ public static function notify(string $username, string $type, string $actor, ?string $object = null): void { Fedi::update(Fedi::userPath($username, 'notifications.json'), function(array $list) use ($type, $actor, $object): array { // 同じ通知を重複させない foreach ($list as $n) { if ($n['type'] === $type && $n['actor'] === $actor && ($n['object'] ?? null) === $object && $n['at'] > time() - 3600) return $list; } array_unshift($list, ['type' => $type, 'actor' => $actor, 'object' => $object, 'at' => time(), 'read' => false]); return array_slice($list, 0, 200); }); } public static function notifications(string $username): array { return Fedi::read(Fedi::userPath($username, 'notifications.json')); } public static function unreadCount(string $username): int { return count(array_filter(self::notifications($username), fn($n) => !$n['read'])); } public static function markRead(string $username): void { Fedi::update(Fedi::userPath($username, 'notifications.json'), fn($l) => array_map(fn($n) => ['read' => true] + $n, $l)); } ////// private static function mediaTypeOf(string $path): string { return match (strtolower(pathinfo($path, PATHINFO_EXTENSION))) { 'png' => 'image/png', 'gif' => 'image/gif', 'webp' => 'image/webp', default => 'image/jpeg', }; } }