master
ブランチ master
LittleBeast / src / Std / Lib / Fedi / Social.php
27.2 KB、573行 生 Blame 履歴
1<?php
2/*************************************************************
3# 076 License
4
5Copyright (c) テクニカル諏訪子
6
7Permission is hereby granted to any person obtaining a copy of the software
8Little Beast (the "Software") to use, modify, merge, copy, publish, distribute,
9sublicense, and/or sell copies of the Software, subject to the following conditions:
10
11 1. **Origin Attribution**:
12 - You must not misrepresent the origin of the Software; you must not claim
13 you created the original Software.
14 - If the Software is used in a product, you must either:
15 a. Provide clear attribution in the product's documentation, user interface,
16 or other visible areas, **OR**
17 b. Pay the original developers a fee they specify in writing.
18 2. **Usage Restriction**:
19 - The Software, or any derivative works, dependencies, or libraries
20 incorporating it, must not be used for censorship or to suppress freedom of
21 speech, expression, or creativity. Prohibited uses include, but are not
22 limited to:
23 - Censorship of so-called "hate speech", visuals, non-mainstream opinions,
24 ideas, or objective reality.
25 - Tools or systems designed to restrict access to information or
26 artistic works.
27 3. **Notice Preservation**:
28 - This license and the above copyright notice must remain intact in all copies
29 of the source code.
30
31THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
32INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
33PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
34HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
35CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
36OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
37 */
38namespace Std\Lib\Fedi;
39
40use Std\Lib\Comments;
41use Result;
42
43/**
44 * ローカルのユーザーの操作(投稿、編集、削除、いいね、ブースト、フォロー、ブロック、通報)
45 *
46 * 全部のメソッドはユーザーがFediverseを使える事を確認してから呼ぶ事(コントローラーで確認する)。
47 */
48final class Social {
49 public const VISIBILITIES = ['public' => '公開', 'unlisted' => '未収載', 'followers' => 'フォロワー限定', 'direct' => 'ダイレクト'];
50
51 //////// 投稿
52
53 /**
54 * 投稿する
55 *
56 * @param string $username 投稿するユーザー
57 * @param string $text 本文(プレーンテキスト)
58 * @param array $opt cw(注意書き)、visibility、inReplyTo(返信先のID)、media(Media::saveの結果のdata)、alts(代替テキスト)
59 * @return Result data:note
60 */
61 public static function post(string $username, string $text, array $opt = []): Result {
62 $text = trim(str_replace("\r\n", "\n", $text));
63 $cw = trim((string)($opt['cw'] ?? ''));
64 $media = $opt['media'] ?? [];
65 if ($text === '' && empty($media)) return Result::Error('本文を入力して下さい。');
66 if (mb_strlen($text) > Fedi::MAX_LENGTH) return Result::Error('本文は'.Fedi::MAX_LENGTH.'文字までです。');
67 if (mb_strlen($cw) > 200) return Result::Error('注意書きは200文字までです。');
68 if (count($media) > Fedi::MAX_ATTACHMENTS) return Result::Error('添付は'.Fedi::MAX_ATTACHMENTS.'枚までです。');
69 $vis = (string)($opt['visibility'] ?? 'public');
70 if (!isset(self::VISIBILITIES[$vis])) $vis = 'public';
71
72 // 返信先(parentObject:取得出来ない返信先。ブログの投稿のNote等、記事のコメントから)
73 $parent = null;
74 $inReplyTo = trim((string)($opt['inReplyTo'] ?? ''));
75 if ($inReplyTo !== '') {
76 $parent = is_array($opt['parentObject'] ?? null) && ($opt['parentObject']['id'] ?? null) === $inReplyTo
77 ? $opt['parentObject'] : Remote::object($inReplyTo);
78 if (!$parent) return Result::Error('返信先の投稿が見つかりません。');
79 }
80
81 [$mentionUrls, $tags] = self::resolveTags($text);
82 $mentioned = array_values(array_unique(array_values($mentionUrls)));
83 $parentAuthor = $parent ? Fedi::id($parent['attributedTo'] ?? null) : null;
84 if ($parentAuthor && $parentAuthor !== Fedi::actorUrl($username)) $mentioned[] = $parentAuthor;
85 $mentioned = array_values(array_unique($mentioned));
86 [$to, $cc] = self::address($username, $vis, $mentioned);
87
88 $attachments = [];
89 foreach (array_values($media) as $i => $m) {
90 $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)];
91 if (!empty($m['width'])) $a['width'] = $m['width'];
92 if (!empty($m['height'])) $a['height'] = $m['height'];
93 $a['path'] = $m['path'];
94 $attachments[] = $a;
95 }
96
97 $note = [
98 'id' => Fedi::newId(),
99 'author' => $username,
100 'source' => $text,
101 'content' => Html::render($text, $mentionUrls),
102 'summary' => $cw,
103 'sensitive' => $cw !== '',
104 'visibility' => $vis,
105 'to' => $to,
106 'cc' => $cc,
107 'inReplyTo' => $parent ? $parent['id'] : null,
108 'tags' => $tags,
109 'attachments' => $attachments,
110 'published' => Fedi::now(),
111 'likes' => [],
112 'boosts' => [],
113 'replies' => [],
114 ];
115 if (!Notes::save($note)) return Result::Error('投稿の保存に失敗しました。');
116
117 $obj = Notes::toObject($note);
118 Timeline::add($obj, null, microtime(true));
119 $create = Notes::toCreate($note);
120 Delivery::enqueue($username, $create, Delivery::audience($username, $create));
121 return Result::Success('投稿しました。', ['note' => $note]);
122 }
123
124 /**
125 * 自分の投稿を編集する(本文と注意書き。公開範囲は変えられない)
126 */
127 public static function edit(string $username, string $id, string $text, string $cw = ''): Result {
128 $note = Notes::get($id);
129 if (!$note || $note['author'] !== $username) return Result::Error('投稿が見つかりません。');
130 $text = trim(str_replace("\r\n", "\n", $text));
131 $cw = trim($cw);
132 if ($text === '' && empty($note['attachments'])) return Result::Error('本文を入力して下さい。');
133 if (mb_strlen($text) > Fedi::MAX_LENGTH) return Result::Error('本文は'.Fedi::MAX_LENGTH.'文字までです。');
134 if (mb_strlen($cw) > 200) return Result::Error('注意書きは200文字までです。');
135
136 [$mentionUrls, $tags] = self::resolveTags($text);
137 // 新しいメンションを宛先に追加する(外さない。既に届いているので)
138 $new = array_diff(array_values($mentionUrls), $note['to'], $note['cc']);
139 if ($note['visibility'] === 'direct') $note['to'] = array_values(array_merge($note['to'], $new));
140 else $note['cc'] = array_values(array_merge($note['cc'], $new));
141
142 $note['source'] = $text;
143 $note['content'] = Html::render($text, $mentionUrls);
144 $note['summary'] = $cw;
145 $note['sensitive'] = $cw !== '';
146 $note['tags'] = $tags;
147 $note['updated'] = Fedi::now();
148 Notes::modify($id, fn($n) => array_merge($n, array_intersect_key($note, array_flip(['source', 'content', 'summary', 'sensitive', 'tags', 'updated', 'to', 'cc']))));
149
150 $obj = Notes::toObject(Notes::get($id));
151 Timeline::refresh($obj);
152 unset($obj['@context']);
153 $update = [
154 '@context' => Fedi::CONTEXT,
155 'id' => Notes::url($note).'#updates/'.time(),
156 'type' => 'Update',
157 'actor' => Fedi::actorUrl($username),
158 'to' => $note['to'],
159 'cc' => $note['cc'],
160 'object' => $obj,
161 ];
162 Delivery::enqueue($username, $update, Delivery::audience($username, $update));
163 // 記事のコメントとしても投稿していれば、コメントも変える
164 Comments::updateRemote($obj, Fedi::actorUrl($username));
165 return Result::Success('編集しました。');
166 }
167
168 /**
169 * 自分の投稿を削除する
170 */
171 public static function delete(string $username, string $id): Result {
172 $note = Notes::get($id);
173 if (!$note || $note['author'] !== $username) return Result::Error('投稿が見つかりません。');
174 $url = Notes::url($note);
175
176 $delete = [
177 '@context' => Fedi::CONTEXT,
178 'id' => "{$url}#delete",
179 'type' => 'Delete',
180 'actor' => Fedi::actorUrl($username),
181 'to' => $note['to'],
182 'cc' => $note['cc'],
183 'object' => ['id' => $url, 'type' => 'Tombstone'],
184 ];
185 // 宛先は消す前に計算する(いいね・ブーストした人にも届ける)
186 $inboxes = Delivery::audience($username, $delete, array_merge(array_keys($note['likes'] ?? []), array_keys($note['boosts'] ?? [])));
187
188 foreach ($note['attachments'] as $a) if (!empty($a['path'])) Media::remove($a['path']);
189 Notes::delete($id);
190 Timeline::remove($url);
191 if (!empty($note['inReplyTo'])) self::forgetReply($note['inReplyTo'], $url);
192
193 Delivery::enqueue($username, $delete, $inboxes);
194 // 記事のコメントとしても投稿していれば、コメントも消す
195 Comments::deleteRemote($url, Fedi::actorUrl($username));
196 return Result::Success('削除しました。');
197 }
198
199 //////// いいね・ブースト
200
201 public static function like(string $username, string $objectId): Result {
202 $obj = Remote::object($objectId);
203 if (!$obj) return Result::Error('投稿が見つかりません。');
204 $author = Fedi::id($obj['attributedTo'] ?? null);
205 if ($author === null) return Result::Error('投稿者が分かりません。');
206 $likes = Fedi::read(Fedi::userPath($username, 'likes.json'));
207 if (isset($likes[$objectId])) return Result::Success();
208
209 $me = Fedi::actorUrl($username);
210 $like = ['@context' => Fedi::CONTEXT, 'id' => "{$me}#likes/".Fedi::newId(), 'type' => 'Like', 'actor' => $me, 'object' => $objectId];
211 Fedi::update(Fedi::userPath($username, 'likes.json'), fn($l) => [$objectId => $like['id']] + $l);
212 Delivery::enqueue($username, $like, self::inboxesOf([$author]));
213 return Result::Success('いいねしました。');
214 }
215
216 public static function unlike(string $username, string $objectId): Result {
217 $likes = Fedi::read(Fedi::userPath($username, 'likes.json'));
218 if (!isset($likes[$objectId])) return Result::Success();
219 $obj = Remote::object($objectId);
220 $me = Fedi::actorUrl($username);
221 $undo = ['@context' => Fedi::CONTEXT, 'id' => $likes[$objectId].'/undo', 'type' => 'Undo', 'actor' => $me,
222 'object' => ['id' => $likes[$objectId], 'type' => 'Like', 'actor' => $me, 'object' => $objectId]];
223 Fedi::update(Fedi::userPath($username, 'likes.json'), function($l) use ($objectId) { unset($l[$objectId]); return $l; });
224 $author = $obj ? Fedi::id($obj['attributedTo'] ?? null) : null;
225 if ($author) Delivery::enqueue($username, $undo, self::inboxesOf([$author]));
226 return Result::Success('いいねを取り消しました。');
227 }
228
229 public static function boost(string $username, string $objectId): Result {
230 $obj = Remote::object($objectId);
231 if (!$obj) return Result::Error('投稿が見つかりません。');
232 $vis = Fedi::visibility(Fedi::ids($obj['to'] ?? []), Fedi::ids($obj['cc'] ?? []));
233 if (!in_array($vis, ['public', 'unlisted'], true)) return Result::Error('公開の投稿だけブースト出来ます。');
234 $boosts = Fedi::read(Fedi::userPath($username, 'boosts.json'));
235 if (isset($boosts[$objectId])) return Result::Success();
236
237 $me = Fedi::actorUrl($username);
238 $author = Fedi::id($obj['attributedTo'] ?? null);
239 $announce = [
240 '@context' => Fedi::CONTEXT,
241 'id' => "{$me}#boosts/".Fedi::newId(),
242 'type' => 'Announce',
243 'actor' => $me,
244 'published' => Fedi::now(),
245 'to' => [Fedi::PUBLIC],
246 'cc' => array_values(array_filter([$me.'/followers', $author])),
247 'object' => $objectId,
248 ];
249 Fedi::update(Fedi::userPath($username, 'boosts.json'), fn($b) => [$objectId => $announce['id']] + $b);
250 if (!Fedi::isLocal($objectId)) Remote::store($obj);
251 Timeline::add($obj, $me);
252 Delivery::enqueue($username, $announce, Delivery::audience($username, $announce));
253 return Result::Success('ブーストしました。');
254 }
255
256 public static function unboost(string $username, string $objectId): Result {
257 $boosts = Fedi::read(Fedi::userPath($username, 'boosts.json'));
258 if (!isset($boosts[$objectId])) return Result::Success();
259 $me = Fedi::actorUrl($username);
260 $obj = Remote::object($objectId);
261 $author = $obj ? Fedi::id($obj['attributedTo'] ?? null) : null;
262 $undo = [
263 '@context' => Fedi::CONTEXT,
264 'id' => $boosts[$objectId].'/undo',
265 'type' => 'Undo',
266 'actor' => $me,
267 'to' => [Fedi::PUBLIC],
268 'cc' => array_values(array_filter([$me.'/followers', $author])),
269 'object' => ['id' => $boosts[$objectId], 'type' => 'Announce', 'actor' => $me, 'object' => $objectId],
270 ];
271 Fedi::update(Fedi::userPath($username, 'boosts.json'), function($b) use ($objectId) { unset($b[$objectId]); return $b; });
272 Timeline::remove($objectId, $me);
273 Delivery::enqueue($username, $undo, Delivery::audience($username, $undo));
274 return Result::Success('ブーストを取り消しました。');
275 }
276
277 public static function hasLiked(string $username, string $objectId): bool {
278 return isset(Fedi::read(Fedi::userPath($username, 'likes.json'))[$objectId]);
279 }
280
281 public static function hasBoosted(string $username, string $objectId): bool {
282 return isset(Fedi::read(Fedi::userPath($username, 'boosts.json'))[$objectId]);
283 }
284
285 //////// フォロー
286
287 public static function follow(string $username, string $target): Result {
288 $me = Fedi::actorUrl($username);
289 if ($target === $me) return Result::Error('自分はフォロー出来ません。');
290 if ($target === Fedi::blogActorUrl()) return Result::Error('このサイトのブログはフォロー出来ません。ブログのフィードをご利用下さい。');
291 if (Actor::isBlocked($username, $target)) return Result::Error('ブロックしているアカウントはフォロー出来ません。');
292 $doc = Remote::actor($target);
293 if (!$doc) return Result::Error('アカウントが見つかりません。');
294 $state = Actor::following($username)[$target]['state'] ?? null;
295 if ($state !== null) return Result::Success($state === 'pending' ? '承認を待っています。' : 'フォローしています。');
296
297 $follow = ['@context' => Fedi::CONTEXT, 'id' => "{$me}#follows/".Fedi::newId(), 'type' => 'Follow', 'actor' => $me, 'object' => $target];
298 Fedi::update(Fedi::userPath($username, 'following.json'), fn($f) => $f + [$target => ['state' => 'pending', 'id' => $follow['id'], 'since' => time()]]);
299 $inbox = Remote::inbox($doc, false);
300 if ($inbox) Delivery::enqueue($username, $follow, [$inbox]);
301 $state = Actor::following($username)[$target]['state'] ?? 'pending';
302 return Result::Success($state === 'accepted' ? 'フォローしました。' : 'フォローをリクエストしました。');
303 }
304
305 /**
306 * フォローした相手の最近の投稿をタイムラインに入れる
307 * フォローする前の投稿は届かないので、これが無いとホームは相手の次の投稿まで空のまま
308 *
309 * @param string $actor リモートのアクター
310 * @return int 入れた件数
311 */
312 public static function backfill(string $actor): int {
313 if (Fedi::isLocal($actor) || empty(Inbox::localFollowersOf($actor))) return 0;
314 $doc = Remote::actor($actor);
315 if (!$doc) return 0;
316 $notes = Remote::outbox($doc);
317 foreach ($notes as $obj) Timeline::add($obj);
318 return count($notes);
319 }
320
321 public static function unfollow(string $username, string $target): Result {
322 $f = Actor::following($username)[$target] ?? null;
323 if ($f === null) return Result::Success();
324 $me = Fedi::actorUrl($username);
325 $undo = ['@context' => Fedi::CONTEXT, 'id' => $f['id'].'/undo', 'type' => 'Undo', 'actor' => $me,
326 'object' => ['id' => $f['id'], 'type' => 'Follow', 'actor' => $me, 'object' => $target]];
327 Fedi::update(Fedi::userPath($username, 'following.json'), function($l) use ($target) { unset($l[$target]); return $l; });
328 $doc = Remote::actor($target);
329 $inbox = $doc ? Remote::inbox($doc, false) : null;
330 if ($inbox) Delivery::enqueue($username, $undo, [$inbox]);
331 return Result::Success('フォローを解除しました。');
332 }
333
334 /**
335 * フォローリクエストを承認・拒否する
336 */
337 public static function answerRequest(string $username, string $actor, bool $accept): Result {
338 $req = Actor::requests($username)[$actor] ?? null;
339 if ($req === null) return Result::Error('リクエストが見つかりません。');
340 Fedi::update(Fedi::userPath($username, 'requests.json'), function($l) use ($actor) { unset($l[$actor]); return $l; });
341 if ($accept) {
342 self::acceptFollow($username, $req['activity']);
343 return Result::Success('承認しました。');
344 }
345 self::rejectFollow($username, $req['activity']);
346 return Result::Success('拒否しました。');
347 }
348
349 /**
350 * フォローを受け入れる(Inboxと承認から使う)
351 */
352 public static function acceptFollow(string $username, array $follow): void {
353 $actor = Fedi::id($follow['actor'] ?? null);
354 $doc = $actor ? Remote::actor($actor) : null;
355 if (!$doc) return;
356 Fedi::update(Fedi::userPath($username, 'followers.json'), fn($l) => [$actor => [
357 'inbox' => Remote::inbox($doc, false),
358 'sharedInbox' => Remote::inbox($doc, true),
359 'since' => time(),
360 ]] + $l);
361 $me = Fedi::actorUrl($username);
362 $accept = ['@context' => Fedi::CONTEXT, 'id' => "{$me}#accepts/".Fedi::newId(), 'type' => 'Accept', 'actor' => $me, 'object' => $follow];
363 $inbox = Remote::inbox($doc, false);
364 if ($inbox) Delivery::enqueue($username, $accept, [$inbox]);
365 Actor::notify($username, 'follow', $actor);
366 }
367
368 public static function rejectFollow(string $username, array $follow): void {
369 $actor = Fedi::id($follow['actor'] ?? null);
370 $doc = $actor ? Remote::actor($actor) : null;
371 $me = Fedi::actorUrl($username);
372 $reject = ['@context' => Fedi::CONTEXT, 'id' => "{$me}#rejects/".Fedi::newId(), 'type' => 'Reject', 'actor' => $me, 'object' => $follow];
373 $inbox = $doc ? Remote::inbox($doc, false) : null;
374 if ($inbox) Delivery::enqueue($username, $reject, [$inbox]);
375 }
376
377 /**
378 * フォロワーを外す(相手のフォローを拒否する)
379 */
380 public static function removeFollower(string $username, string $actor): Result {
381 if (!Actor::isFollowedBy($username, $actor)) return Result::Success();
382 Fedi::update(Fedi::userPath($username, 'followers.json'), function($l) use ($actor) { unset($l[$actor]); return $l; });
383 self::rejectFollow($username, ['type' => 'Follow', 'actor' => $actor, 'object' => Fedi::actorUrl($username)]);
384 return Result::Success('フォロワーから外しました。');
385 }
386
387 //////// ブロック
388
389 public static function block(string $username, string $target): Result {
390 if ($target === Fedi::actorUrl($username)) return Result::Error('自分はブロック出来ません。');
391 Fedi::update(Fedi::userPath($username, 'blocks.json'), function($b) use ($target) {
392 $b += ['actors' => [], 'domains' => []];
393 if (!in_array($target, $b['actors'], true)) $b['actors'][] = $target;
394 return $b;
395 });
396 if (Actor::isFollowing($username, $target) || isset(Actor::following($username)[$target])) self::unfollow($username, $target);
397 if (Actor::isFollowedBy($username, $target)) {
398 Fedi::update(Fedi::userPath($username, 'followers.json'), function($l) use ($target) { unset($l[$target]); return $l; });
399 }
400 $me = Fedi::actorUrl($username);
401 $block = ['@context' => Fedi::CONTEXT, 'id' => "{$me}#blocks/".sha1($target), 'type' => 'Block', 'actor' => $me, 'object' => $target];
402 Delivery::enqueue($username, $block, self::inboxesOf([$target], false));
403 return Result::Success('ブロックしました。');
404 }
405
406 public static function unblock(string $username, string $target): Result {
407 Fedi::update(Fedi::userPath($username, 'blocks.json'), function($b) use ($target) {
408 $b += ['actors' => [], 'domains' => []];
409 $b['actors'] = array_values(array_diff($b['actors'], [$target]));
410 return $b;
411 });
412 $me = Fedi::actorUrl($username);
413 $id = "{$me}#blocks/".sha1($target);
414 $undo = ['@context' => Fedi::CONTEXT, 'id' => "{$id}/undo", 'type' => 'Undo', 'actor' => $me,
415 'object' => ['id' => $id, 'type' => 'Block', 'actor' => $me, 'object' => $target]];
416 Delivery::enqueue($username, $undo, self::inboxesOf([$target], false));
417 return Result::Success('ブロックを解除しました。');
418 }
419
420 /**
421 * ドメインごとブロックする(このユーザーだけ。サイト全体は管理者のblockInstance)
422 */
423 public static function blockDomain(string $username, string $domain, bool $block = true): Result {
424 $domain = strtolower(trim($domain));
425 if (!preg_match('/^[a-z0-9.-]+\.[a-z]{2,}$/', $domain)) return Result::Error('ドメインが不正です。');
426 if ($domain === strtolower(Fedi::host())) return Result::Error('このサイトはブロック出来ません。');
427 Fedi::update(Fedi::userPath($username, 'blocks.json'), function($b) use ($domain, $block) {
428 $b += ['actors' => [], 'domains' => []];
429 $b['domains'] = array_values(array_diff($b['domains'], [$domain]));
430 if ($block) $b['domains'][] = $domain;
431 return $b;
432 });
433 if ($block) {
434 // そのドメインのフォロワーを外す
435 Fedi::update(Fedi::userPath($username, 'followers.json'), fn($l) => array_filter($l, fn($k) => Fedi::domainOf($k) !== $domain, ARRAY_FILTER_USE_KEY));
436 }
437 return Result::Success($block ? "{$domain}をブロックしました。" : "{$domain}のブロックを解除しました。");
438 }
439
440 //////// 通報・管理
441
442 /**
443 * 通報する(このサイトの管理者に記録し、リモートなら相手のサーバーにもFlagを送る)
444 */
445 public static function report(string $username, string $target, array $objects, string $comment, bool $forward = true): Result {
446 $comment = mb_substr(trim($comment), 0, 1000);
447 Fedi::update(Fedi::path('reports.json'), function($l) use ($username, $target, $objects, $comment) {
448 array_unshift($l, ['id' => Fedi::newId(), 'from' => Fedi::actorUrl($username), 'actor' => $target, 'objects' => array_values($objects),
449 'content' => $comment, 'at' => time(), 'remote' => false, 'resolved' => false]);
450 return array_slice($l, 0, 500);
451 });
452 if ($forward && !Fedi::isLocal($target)) {
453 $me = Fedi::actorUrl($username);
454 $flag = ['@context' => Fedi::CONTEXT, 'id' => "{$me}#flags/".Fedi::newId(), 'type' => 'Flag', 'actor' => $me,
455 'object' => array_values(array_merge([$target], $objects)), 'content' => $comment];
456 Delivery::enqueue($username, $flag, self::inboxesOf([$target]));
457 }
458 return Result::Success('通報しました。');
459 }
460
461 /**
462 * サイト全体でドメインをブロックする(管理者)
463 */
464 public static function blockInstance(string $domain, bool $block = true): Result {
465 $domain = strtolower(trim($domain));
466 if (!preg_match('/^[a-z0-9.-]+\.[a-z]{2,}$/', $domain)) return Result::Error('ドメインが不正です。');
467 if ($domain === strtolower(Fedi::host())) return Result::Error('このサイトはブロック出来ません。');
468 Fedi::update(Fedi::path('blocked_domains.json'), function($l) use ($domain, $block) {
469 $l = array_values(array_diff($l, [$domain]));
470 if ($block) $l[] = $domain;
471 sort($l);
472 return $l;
473 });
474 if ($block) {
475 // 全員のフォロワーから外し、タイムラインからも消す
476 foreach (self::localUsernames() as $u) {
477 Fedi::update(Fedi::userPath($u, 'followers.json'), fn($l) => array_filter($l, fn($k) => !Actor::isDomainBlocked($k), ARRAY_FILTER_USE_KEY));
478 }
479 foreach (Timeline::entries() as $e) {
480 if (!$e['local'] && Actor::isDomainBlocked($e['author'])) Remote::forget($e['object']);
481 }
482 }
483 return Result::Success($block ? "{$domain}をブロックしました。" : "{$domain}のブロックを解除しました。");
484 }
485
486 public static function reports(): array {
487 return Fedi::read(Fedi::path('reports.json'));
488 }
489
490 public static function resolveReport(string $id): void {
491 Fedi::update(Fedi::path('reports.json'), fn($l) => array_map(fn($r) => $r['id'] === $id ? ['resolved' => true] + $r : $r, $l));
492 }
493
494 /**
495 * プロフィールの更新をフォロワーに送る(プロフィールを編集した後)
496 */
497 public static function sendProfileUpdate(string $username): void {
498 $u = Fedi::localActor($username);
499 if (!$u || empty(Actor::followers($username))) return;
500 $doc = Actor::document($u);
501 unset($doc['@context']);
502 $me = Fedi::actorUrl($username);
503 $update = ['@context' => Fedi::CONTEXT, 'id' => "{$me}#updates/".time(), 'type' => 'Update', 'actor' => $me,
504 'to' => [Fedi::PUBLIC], 'cc' => ["{$me}/followers"], 'object' => $doc];
505 Delivery::enqueue($username, $update, Delivery::audience($username, $update));
506 }
507
508 /**
509 * Fediverseのデータがあるローカルのユーザー
510 */
511 public static function localUsernames(): array {
512 $out = [];
513 foreach (glob(Fedi::path('users/').'*', GLOB_ONLYDIR) ?: [] as $d) $out[] = basename($d);
514 return $out;
515 }
516
517 //////
518
519 /**
520 * メンションとハッシュタグを解決する
521 *
522 * @return array [「@user@domain」 => アクターのURL, tag(Mention・Hashtag)]
523 */
524 private static function resolveTags(string $text): array {
525 $urls = [];
526 $tags = [];
527 foreach (Html::mentions($text) as $key => [$user, $domain]) {
528 $url = $domain === null || $domain === strtolower(Fedi::host())
529 ? (Fedi::localActor($user) ? Fedi::actorUrl($user) : null)
530 : Remote::webfinger($user, $domain);
531 if ($url === null) continue;
532 $urls[$key] = $url;
533 $tags[] = ['type' => 'Mention', 'href' => $url, 'name' => $domain === null ? "@{$user}@".Fedi::host() : $key];
534 }
535 foreach (Html::hashtags($text) as $tag) {
536 $tags[] = ['type' => 'Hashtag', 'href' => Fedi::base().'/microblog/tag/'.rawurlencode($tag), 'name' => "#{$tag}"];
537 }
538 return [$urls, $tags];
539 }
540
541 /**
542 * 公開範囲からto・cc
543 */
544 private static function address(string $username, string $vis, array $mentioned): array {
545 $followers = Fedi::actorUrl($username).'/followers';
546 return match ($vis) {
547 'public' => [[Fedi::PUBLIC], array_values(array_merge([$followers], $mentioned))],
548 'unlisted' => [[$followers], array_values(array_merge([Fedi::PUBLIC], $mentioned))],
549 'followers' => [[$followers], $mentioned],
550 'direct' => [$mentioned, []],
551 };
552 }
553
554 /** アクターの受付ボックス */
555 private static function inboxesOf(array $actors, bool $shared = true): array {
556 $out = [];
557 foreach ($actors as $a) {
558 $doc = Remote::actor($a);
559 $inbox = $doc ? Remote::inbox($doc, $shared) : null;
560 if ($inbox) $out[] = $inbox;
561 }
562 return $out;
563 }
564
565 /** 削除した返信を返信先の投稿から外す */
566 private static function forgetReply(string $parentId, string $replyId): void {
567 $ids = Fedi::localNoteId($parentId);
568 if ($ids) Notes::modify($ids[1], function($n) use ($replyId) {
569 $n['replies'] = array_values(array_diff($n['replies'] ?? [], [$replyId]));
570 return $n;
571 });
572 }
573}