master
ブランチ master
LittleBeast / src / Site / Controller / Fediverse.php
17.1 KB、458行 生 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 Site\Controller;
39
40use Site\Controller\BlogPost;
41use Site\Controller\Mods;
42use Std\Lib\Activitypub;
43use Std\Lib\Fedi\Actor;
44use Std\Lib\Fedi\Fedi;
45use Std\Lib\Fedi\Inbox;
46use Std\Lib\Fedi\Notes;
47use Std\Lib\Fedi\Timeline;
48use LogType;
49
50class Fediverse extends BlogPost {
51 use Mods;
52
53 /**
54 * @param array $params パラメータ配列
55 * @return void
56 */
57 public function apfinger(array $params): void {
58 try {
59 // マルチユーザーでは「resource」でユーザーを探す(ブログのアクター以外)
60 $resource = is_string($_GET['resource'] ?? null) ? $_GET['resource'] : '';
61 if (Fedi::enabled() && $resource !== '' && !$this->isBlogResource($resource)) {
62 // Gitlerのリポジトリ(acct:所有者.名前@ホスト)
63 $jrd = Actor::webfinger($resource) ?? (\Std\Lib\Gitler\Gitler::enabled() ? \Std\Lib\Gitler\Federation::webfinger($resource) : null);
64 if ($jrd === null) $this->json(404, ['error' => '見つかりません'], 'application/jrd+json');
65 $this->json(200, $jrd, 'application/jrd+json');
66 }
67 header('Content-Type: application/jrd+json');
68 $ap = new Activitypub();
69 echo $ap->getWebfinger();
70 exit;
71 } catch (\Exception $e) {
72 header('Content-Type: text/plain; charset=utf-8');
73 echo 'フェディバースの作成に失敗: '.$e->getMessage();
74 exit;
75 }
76 }
77
78 /**
79 * @param array $params パラメータ配列
80 * @return void
81 */
82 public function apactor(array $params): void {
83 try {
84 header('Content-Type: application/activity+json');
85 $ap = new Activitypub();
86 echo $ap->getActor();
87 exit;
88 } catch (\Exception $e) {
89 header('Content-Type: text/plain; charset=utf-8');
90 echo 'フェディバースの作成に失敗: '.$e->getMessage();
91 exit;
92 }
93 }
94
95 /**
96 * @param array $params パラメータ配列
97 * @return void
98 */
99 public function apinbox(array $params): void {
100 if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
101 header('HTTP/1.1 405 Method Not Allowed');
102 header('Allow: POST');
103 exit;
104 }
105
106 $maxBody = 262144;
107 $input = file_get_contents('php://input', false, null, 0, $maxBody + 1);
108 if ($input === false || strlen($input) > $maxBody) {
109 header('HTTP/1.1 413 Payload Too Large');
110 exit;
111 }
112
113 $activity = json_decode($input, true);
114 if (!is_array($activity) || !isset($activity['type']) || !is_string($activity['type'])) {
115 header('HTTP/1.1 400 Bad Request');
116 header('Content-Type: application/activity+json');
117 echo json_encode(['error' => '不正なアクティビティ']);
118 exit;
119 }
120
121 try {
122 header('Content-Type: application/activity+json');
123 // 投稿はコメント(ブログの投稿への返信)の処理で必要になった時だけ読む
124 $ap = new Activitypub(fn() => $this->federatedPosts());
125
126 if (!$ap->verifyInboxSignature($input, $activity)) {
127 // 削除されたアカウントのDeleteは鍵がもう取得出来ないので検証出来ない。拒否すると何日も再送されるので受け流す
128 if ($activity['type'] === 'Delete') {
129 header('HTTP/1.1 202 Accepted');
130 echo json_encode(['status' => '無視しました']);
131 exit;
132 }
133 header('HTTP/1.1 401 Unauthorized');
134 echo json_encode(['error' => '署名の検証に失敗']);
135 exit;
136 }
137
138 logger(\LogType::ActivityPub, "受付に入れた:".json_encode($activity));
139 // ブログの投稿への返信は記事のコメントにする(マルチユーザーでも、ユーザー宛ての処理は続ける)
140 $ap->handleComments($activity);
141 // マルチユーザーでは共有の受付ボックス(ブログのアクター宛てはInboxからActivitypubに渡す)
142 if (Fedi::enabled()) {
143 [$code, $body] = Inbox::handle($activity);
144 $this->json($code, $body);
145 }
146 $ap->postInbox($activity);
147 exit;
148 } catch (\Exception $e) {
149 header('Content-Type: text/plain; charset=utf-8');
150 echo 'フェディバースの作成に失敗: '.$e->getMessage();
151 exit;
152 }
153 }
154
155 /**
156 * ユーザーのアクター(/ap/users/{name})。ブラウザからはプロフィールのページに移動する
157 *
158 * @param array $params パラメータ配列
159 * @return void
160 */
161 public function apuser(array $params): void {
162 $u = $this->actorOr404($params);
163 if ($this->wantsHtml()) {
164 header('Location: /microblog/user/'.$u->username);
165 exit;
166 }
167 $this->json(200, Actor::document($u));
168 }
169
170 /**
171 * ユーザーの受付ボックス(POST /ap/users/{name}/inbox)。処理は共有の受付ボックスと同じ
172 *
173 * @param array $params パラメータ配列
174 * @return void
175 */
176 public function apuserinbox(array $params): void {
177 $this->actorOr404($params);
178 $this->apinbox($params);
179 }
180
181 /**
182 * ユーザーの送信ボックス(公開・未収載の投稿とブースト、20件ずつ)
183 *
184 * @param array $params パラメータ配列
185 * @return void
186 */
187 public function apuseroutbox(array $params): void {
188 $u = $this->actorOr404($params);
189 $url = Fedi::actorUrl($u->username).'/outbox';
190 $me = Fedi::actorUrl($u->username);
191 $entries = array_values(array_filter(Timeline::entries(), fn($e) => $e['actor'] === $me && in_array($e['vis'], ['public', 'unlisted'], true)));
192 usort($entries, fn($a, $b) => $b['t'] <=> $a['t']);
193 $total = count($entries);
194
195 if (!isset($_GET['page'])) {
196 $this->json(200, ['@context' => 'https://www.w3.org/ns/activitystreams', 'id' => $url, 'type' => 'OrderedCollection',
197 'totalItems' => $total, 'first' => "{$url}?page=1", 'last' => "{$url}?page=".max(1, (int)ceil($total / 20))]);
198 }
199
200 $page = max(1, (int)$_GET['page']);
201 $boostIds = Fedi::read(Fedi::userPath($u->username, 'boosts.json'));
202 $items = [];
203 foreach (array_slice($entries, ($page - 1) * 20, 20) as $e) {
204 if ($e['type'] === 'boost') {
205 $items[] = ['id' => $boostIds[$e['object']] ?? $me.'#boosts/'.sha1($e['object']), 'type' => 'Announce', 'actor' => $me, 'published' => Fedi::now((int)$e['t']),
206 'to' => [Fedi::PUBLIC], 'cc' => [$me.'/followers'], 'object' => $e['object']];
207 continue;
208 }
209 $ids = Fedi::localNoteId($e['object']);
210 $note = $ids ? Notes::get($ids[1]) : null;
211 if (!$note) continue;
212 $create = Notes::toCreate($note);
213 unset($create['@context'], $create['object']['@context']);
214 $items[] = $create;
215 }
216 $data = ['@context' => Fedi::CONTEXT, 'id' => "{$url}?page={$page}", 'type' => 'OrderedCollectionPage', 'partOf' => $url, 'orderedItems' => $items];
217 if ($page * 20 < $total) $data['next'] = "{$url}?page=".($page + 1);
218 if ($page > 1) $data['prev'] = "{$url}?page=".($page - 1);
219 $this->json(200, $data);
220 }
221
222 /**
223 * フォロワー・フォロー(数だけ公開する)
224 *
225 * @param array $params パラメータ配列
226 * @return void
227 */
228 public function apuserfollowers(array $params): void {
229 $u = $this->actorOr404($params);
230 $this->json(200, Actor::collection($u->username, 'followers'));
231 }
232
233 public function apuserfollowing(array $params): void {
234 $u = $this->actorOr404($params);
235 $this->json(200, Actor::collection($u->username, 'following'));
236 }
237
238 /**
239 * 投稿(/ap/users/{name}/notes/{id})
240 * 公開・未収載以外は、署名したアクターが宛先かフォロワーの場合だけ返す
241 *
242 * @param array $params パラメータ配列
243 * @return void
244 */
245 public function apnote(array $params): void {
246 [$u, $note] = $this->noteOr404($params);
247 $url = Notes::url($note);
248 if ($this->wantsHtml()) {
249 header('Location: '.Notes::htmlUrl($url));
250 exit;
251 }
252 $this->json(200, Notes::toObject($note));
253 }
254
255 public function apnoteactivity(array $params): void {
256 [$u, $note] = $this->noteOr404($params);
257 $this->json(200, Notes::toCreate($note));
258 }
259
260 /**
261 * NodeInfo(サーバーのソフトウェアの情報。他のサーバーが表示に使う)
262 *
263 * @param array $params パラメータ配列
264 * @return void
265 */
266 public function nodeinfo(array $params): void {
267 $this->json(200, ['links' => [['rel' => 'http://nodeinfo.diaspora.software/ns/schema/2.0', 'href' => Fedi::base().'/nodeinfo/2.0']]],
268 'application/json');
269 }
270
271 public function nodeinfoSchema(array $params): void {
272 $users = 0;
273 foreach (glob((Fedi::$userDir ?? ROOT.'/data/user/').'*.json') ?: [] as $f) {
274 $d = json_decode((string)file_get_contents($f));
275 if ($d instanceof \stdClass && can_federate($d)) $users++;
276 }
277 $this->json(200, [
278 'version' => '2.0',
279 'software' => ['name' => 'littlebeast', 'version' => '1.0'],
280 'protocols' => ['activitypub'],
281 'services' => ['inbound' => [], 'outbound' => ['atom1.0']],
282 'openRegistrations' => AUTH_REGISTER_ENABLED,
283 'usage' => ['users' => ['total' => $users], 'localPosts' => count(glob(Fedi::path('notes/').'*.json') ?: [])],
284 'metadata' => ['nodeName' => SITEINFO['title']],
285 ], 'application/json; profile="http://nodeinfo.diaspora.software/ns/schema/2.0#"');
286 }
287
288 ///////
289
290 /** ローカルのアクター、無ければ404 */
291 private function actorOr404(array $params): \stdClass {
292 $u = Fedi::enabled() ? Fedi::localActor((string)($params['name'] ?? '')) : null;
293 if ($u === null) $this->json(404, ['error' => '見つかりません']);
294 return $u;
295 }
296
297 /** 見る事が出来る投稿、無ければ404 */
298 private function noteOr404(array $params): array {
299 $u = $this->actorOr404($params);
300 $note = Notes::get((string)($params['id'] ?? ''));
301 if (!$note || $note['author'] !== $u->username) $this->json(404, ['error' => '見つかりません']);
302 if (!in_array($note['visibility'], ['public', 'unlisted'], true)) {
303 $signer = (new Activitypub())->signer();
304 $ok = $signer !== null && (in_array($signer, array_merge($note['to'], $note['cc']), true)
305 || ($note['visibility'] === 'followers' && Actor::isFollowedBy($u->username, $signer)));
306 if (!$ok) $this->json(404, ['error' => '見つかりません']);
307 }
308 return [$u, $note];
309 }
310
311 /** ブラウザからのアクセスか(ActivityPubのJSONを求めていない) */
312 private function wantsHtml(): bool {
313 $accept = (string)($_SERVER['HTTP_ACCEPT'] ?? '');
314 return str_contains($accept, 'text/html') && !str_contains($accept, 'activity+json') && !str_contains($accept, 'ld+json');
315 }
316
317 /** ブログのアクターのresource(acct:actor@host又は/ap/actor) */
318 private function isBlogResource(string $resource): bool {
319 if ($resource === Fedi::blogActorUrl()) return true;
320 return strtolower($resource) === strtolower('acct:'.FEDIINFO['actor'].'@'.Fedi::host());
321 }
322
323 private function json(int $code, array $data, string $type = 'application/activity+json'): never {
324 $status = [200 => 'OK', 202 => 'Accepted', 400 => 'Bad Request', 401 => 'Unauthorized', 403 => 'Forbidden',
325 404 => 'Not Found', 500 => 'Internal Server Error'][$code] ?? '';
326 header("HTTP/1.1 {$code} {$status}");
327 header("Content-Type: {$type}");
328 echo json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
329 exit;
330 }
331
332 /**
333 * @param array $params パラメータ配列
334 * @return void
335 */
336 public function apactivity(array $params): void {
337 $uuid = '';
338 if (isset($params['uuid'])) $uuid = $params['uuid'];
339
340 try {
341 header('Content-Type: application/activity+json');
342 $posts = $this->federatedPosts();
343 $ap = new Activitypub($posts);
344 $json = $ap->getActivity($uuid, $params['section'] ?? 'blog');
345 if ($json === '') {
346 header('HTTP/1.1 404 Not Found');
347 echo json_encode(['error' => '見つかりません']);
348 exit;
349 }
350 echo $json;
351 exit;
352 } catch (\Exception $e) {
353 header('Content-Type: text/plain; charset=utf-8');
354 echo 'フェディバースの作成に失敗: '.$e->getMessage();
355 exit;
356 }
357 }
358
359 /**
360 * @param array $params パラメータ配列
361 * @return void
362 */
363 public function apobject(array $params): void {
364 try {
365 header('Content-Type: application/activity+json');
366 $ap = new Activitypub($this->federatedPosts());
367 $json = $ap->getObject($params['uuid'] ?? '', $params['section'] ?? 'blog');
368 if ($json === '') {
369 header('HTTP/1.1 404 Not Found');
370 echo json_encode(['error' => '見つかりません']);
371 exit;
372 }
373 echo $json;
374 exit;
375 } catch (\Exception $e) {
376 header('Content-Type: text/plain; charset=utf-8');
377 echo 'フェディバースの作成に失敗: '.$e->getMessage();
378 exit;
379 }
380 }
381
382 /**
383 * 投稿への返信のコレクション(/ap/objects/{uuid}/replies)
384 *
385 * @param array $params パラメータ配列
386 * @return void
387 */
388 public function apreplies(array $params): void {
389 try {
390 header('Content-Type: application/activity+json');
391 $ap = new Activitypub($this->federatedPosts());
392 $json = $ap->getReplies($params['uuid'] ?? '', $params['section'] ?? 'blog');
393 if ($json === '') {
394 header('HTTP/1.1 404 Not Found');
395 echo json_encode(['error' => '見つかりません']);
396 exit;
397 }
398 echo $json;
399 exit;
400 } catch (\Exception $e) {
401 header('Content-Type: text/plain; charset=utf-8');
402 echo 'フェディバースの作成に失敗: '.$e->getMessage();
403 exit;
404 }
405 }
406
407 /**
408 * @param array $params パラメータ配列
409 * @return void
410 */
411 public function apoutbox(array $params): void {
412 try {
413 header('Content-Type: application/activity+json');
414 $posts = $this->federatedPosts();
415 $ap = new Activitypub($posts);
416 echo $ap->getOutbox();
417 exit;
418 } catch (\Exception $e) {
419 header('Content-Type: text/plain; charset=utf-8');
420 echo 'フェディバースの作成に失敗: '.$e->getMessage();
421 exit;
422 }
423 }
424
425 /**
426 * @param array $params パラメータ配列
427 * @return void
428 */
429 public function apfollowers(array $params): void {
430 try {
431 header('Content-Type: application/activity+json');
432 $ap = new Activitypub();
433 echo $ap->getFollowers();
434 exit;
435 } catch (\Exception $e) {
436 header('Content-Type: text/plain; charset=utf-8');
437 echo 'フェディバースの作成に失敗: '.$e->getMessage();
438 exit;
439 }
440 }
441
442 /**
443 * @param array $params パラメータ配列
444 * @return void
445 */
446 public function apfollowing(array $params): void {
447 try {
448 header('Content-Type: application/activity+json');
449 $ap = new Activitypub();
450 echo $ap->getFollowing();
451 exit;
452 } catch (\Exception $e) {
453 header('Content-Type: text/plain; charset=utf-8');
454 echo 'フェディバースの作成に失敗: '.$e->getMessage();
455 exit;
456 }
457 }
458}