| 1 | <?php |
| 2 | /************************************************************* |
| 3 | # 076 License |
| 4 | |
| 5 | Copyright (c) テクニカル諏訪子 |
| 6 | |
| 7 | Permission is hereby granted to any person obtaining a copy of the software |
| 8 | Little Beast (the "Software") to use, modify, merge, copy, publish, distribute, |
| 9 | sublicense, 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 | |
| 31 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, |
| 32 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A |
| 33 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT |
| 34 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF |
| 35 | CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE |
| 36 | OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. |
| 37 | */ |
| 38 | namespace Std\Lib\Fedi; |
| 39 | |
| 40 | use Std\Lib\Comments; |
| 41 | use Result; |
| 42 | |
| 43 | /** |
| 44 | * ローカルのユーザーの操作(投稿、編集、削除、いいね、ブースト、フォロー、ブロック、通報) |
| 45 | * |
| 46 | * 全部のメソッドはユーザーがFediverseを使える事を確認してから呼ぶ事(コントローラーで確認する)。 |
| 47 | */ |
| 48 | final 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 | } |