マイクロブログ

守矢諏訪子 <suwako@076.moe>、2026-11-10 22:15
コミット 0d792b60bab3018d1475348dedfee586bc7dd652 ・親 2680b67 ・ファイルを見る

38個のファイル、+4968 -24

変更 .gitignore
@@ -3,9 +3,12 @@
33 data/*.json
44 data/cron
55 data/cache
6+data/ap
7+data/ratelimit
68 data/user/*.json
79 log/*.txt
810 public/static/user
11+public/static/media
912 !public/static/user/.kara
1013 config/config.php
1114 public/static/*.pem
変更 README-JP.md
@@ -207,6 +207,23 @@
207207 * `apfederate`:新しいブログ投稿を ActivityPub のフォロワーに送る
208208 * `apupdate`:プロフィール(アクター)の更新を ActivityPub のフォロワーに送る
209209 * `authpurge`:7日以内にメールアドレスを確認しなかったアカウントを削除する(`AUTH_VERIFY_EMAIL` が有効の場合)
210+* `apdeliver`:マイクロブログのアクティビティを他のサーバーに配信する(マルチユーザーモード、毎分実行)
211+* `apcleanup`:90日より古いリモートの投稿とキャッシュを削除する(マルチユーザーモード、1日1回実行)
212+
213+### マイクロブログ(マルチユーザーの Fediverse)
214+既定では ActivityPub はシングルユーザーで、ブログのアクター(`/ap/actor`)が新しいブログ投稿をフォロワーに送ります。\
215+`FEDIINFO['isSingleUser']` を `false` にすると(`AUTH_ENABLED` と `FEDIINFO['domain']` も必要)マルチユーザーモードになります:
216+* ブログのアクターは今まで通り動きます。
217+* Fediverse のロールを持つユーザーはそれぞれアクター(`@ユーザー名@ドメイン`)になります。ロールは BAN されたユーザー以外の全員に付与され、管理者はユーザーのプロフィールの編集画面で外せます。
218+* `/microblog` にはホーム・ローカル・連合のタイムライン、投稿(注意書き、公開範囲、画像4枚まで)、返信、いいね、ブースト、フォロー(承認制も可)、ブロック、通報、検索(`@user@host`、投稿の URL、`#タグ`、文字)があります。JavaScript は不要です。
219+* 管理者は `/microblog/admin` でサーバーごとブロックしたり、通報を確認したり出来ます。
220+
221+配信は待ち行列に入るので、cron のジョブを追加して下さい:
222+```sh
223+php cron.php add "* * * * *" apdeliver
224+php cron.php add "@daily" apcleanup
225+```
226+アップロードした画像(PNG・JPEG・GIF、4 MiB まで。PHP の `upload_max_filesize` もご確認下さい)からはメタデータ(Exif、テキスト)を取り除きます。
210227
211228 ## 必要な PHP モジュール
212229 * php_gmp
変更 README.md
@@ -207,6 +207,23 @@
207207 * `apfederate`: sends new blog posts to ActivityPub followers
208208 * `apupdate`: sends profile (actor) updates to ActivityPub followers
209209 * `authpurge`: deletes accounts whose email address was not verified within 7 days (when `AUTH_VERIFY_EMAIL` is enabled)
210+* `apdeliver`: delivers microblog activities to other servers (multi-user mode, run every minute)
211+* `apcleanup`: deletes remote posts and caches older than 90 days (multi-user mode, run daily)
212+
213+### Microblog (multi-user Fediverse)
214+By default ActivityPub is single-user: the blog actor (`/ap/actor`) sends new blog posts to its followers.\
215+Setting `FEDIINFO['isSingleUser']` to `false` (with `AUTH_ENABLED` and `FEDIINFO['domain']`) turns on multi-user mode:
216+* The blog actor keeps working as before.
217+* Every user with the Fediverse role becomes their own actor (`@username@domain`). The role is given to everyone except banned users; an admin can take it away on the user's profile edit page.
218+* `/microblog` has Home, Local and Federated timelines, posting (content warnings, visibility, up to 4 images), replies, likes, boosts, follows (with optional follow approval), blocks, reports and search (`@user@host`, post URLs, `#tags` and text). No JavaScript is needed.
219+* Admins can block whole servers and read reports at `/microblog/admin`.
220+
221+Deliveries are queued, so add the cron jobs:
222+```sh
223+php cron.php add "* * * * *" apdeliver
224+php cron.php add "@daily" apcleanup
225+```
226+Uploaded images (PNG/JPEG/GIF, up to 4 MiB; check PHP's `upload_max_filesize`) have their metadata (Exif, text chunks) removed.
210227
211228 ## Required PHP module
212229 * php_gmp
@@ -52,6 +52,10 @@
5252 ]);
5353 define('FEDIINFO', [
5454 'domain' => '',
55+ // falseにするとマルチユーザーモード:ブログのアクターはそのままで、
56+ // Fediverseのロールを持つユーザーはそれぞれアクター(@ユーザー名@ドメイン)になり、/microblogが使える。
57+ // AUTH_ENABLEDとcron(apdeliver)も必要
58+ 'isSingleUser' => true,
5559 'actor' => 'loli',
5660 'actorNick' => 'ロリ',
5761 'desc' => '',
変更 cron.php
@@ -71,6 +71,13 @@
7171 $cron->register('apupdate', fn() => (new Activitypub())->sendActorUpdate());
7272 }
7373
74+if (ACTIVITYPUB_MULTIUSER) {
75+ // マイクロブログの配信(毎分)
76+ $cron->register('apdeliver', fn() => \Std\Lib\Fedi\Delivery::run());
77+ // 90日より古いリモートの投稿とキャッシュを消す(1日1回)
78+ $cron->register('apcleanup', fn() => \Std\Lib\Fedi\Timeline::cleanup());
79+}
80+
7481 if (AUTH_ENABLED && AUTH_VERIFY_EMAIL) {
7582 // メールアドレスを7日以内に確認しなかったアカウントを削除する
7683 $cron->register('authpurge', fn() => (new Auth())->purgeUnverified());
@@ -0,0 +1,208 @@
1+/* マイクロブログ(/microblog)。JavaScriptなし */
2+.mb-nav {
3+ display: flex;
4+ flex-wrap: wrap;
5+ gap: 8px;
6+ justify-content: space-between;
7+ align-items: center;
8+ border-bottom: 1px solid #c016c6;
9+ padding-bottom: 8px;
10+ margin-bottom: 8px;
11+}
12+
13+.mb-tabs a {
14+ padding: 2px 8px;
15+ text-decoration: none;
16+}
17+
18+.mb-tabs a.active {
19+ border: 1px solid #ea79d8;
20+ border-radius: 10px;
21+ background-color: #550f75;
22+}
23+
24+.mb-search input {
25+ width: 22em;
26+ max-width: 60vw;
27+}
28+
29+.mb-compose {
30+ display: flex;
31+ flex-direction: column;
32+ gap: 4px;
33+ border: 1px solid #c016c6;
34+ border-radius: 2px;
35+ padding: 8px;
36+ margin: 8px 0;
37+}
38+
39+.mb-compose textarea, .mb-compose input[type="text"], .mb-report textarea, .mb-confirm textarea {
40+ width: 100%;
41+ box-sizing: border-box;
42+}
43+
44+.mb-compose-bottom {
45+ display: flex;
46+ justify-content: flex-end;
47+ gap: 8px;
48+}
49+
50+.mb-file {
51+ display: flex;
52+ gap: 4px;
53+ margin: 2px 0;
54+}
55+
56+.mb-note {
57+ border: 1px solid #550f75;
58+ border-radius: 2px;
59+ padding: 8px;
60+ margin: 8px 0;
61+}
62+
63+.mb-focus .mb-note {
64+ border-color: #ea79d8;
65+}
66+
67+.mb-ancestor .mb-note, .mb-replyitem .mb-note {
68+ margin-left: 16px;
69+}
70+
71+.mb-author, .mb-row {
72+ display: flex;
73+ align-items: center;
74+ gap: 8px;
75+}
76+
77+.mb-row {
78+ margin: 6px 0;
79+}
80+
81+.mb-row.unread {
82+ border-left: 3px solid #ea79d8;
83+ padding-left: 4px;
84+}
85+
86+.mb-avatar {
87+ width: 40px;
88+ height: 40px;
89+ border-radius: 4px;
90+ object-fit: cover;
91+}
92+
93+.mb-avatar-large {
94+ width: 100px;
95+ height: 100px;
96+ border-radius: 6px;
97+ object-fit: cover;
98+}
99+
100+.mb-profile {
101+ display: flex;
102+ gap: 16px;
103+ align-items: flex-start;
104+ margin-bottom: 16px;
105+}
106+
107+.mb-profile h1 {
108+ margin: 0;
109+}
110+
111+.mb-name {
112+ font-weight: bold;
113+ text-decoration: none;
114+}
115+
116+.mb-handle, .mb-small, .mb-meta {
117+ color: #b0a0b0;
118+ font-size: small;
119+}
120+
121+.mb-content {
122+ overflow-wrap: anywhere;
123+ margin: 4px 0;
124+}
125+
126+.mb-content p {
127+ margin: 4px 0;
128+}
129+
130+.mb-content .invisible {
131+ display: none;
132+}
133+
134+.mb-content .ellipsis::after {
135+ content: "…";
136+}
137+
138+.mb-media {
139+ display: flex;
140+ flex-wrap: wrap;
141+ gap: 4px;
142+}
143+
144+.mb-media img, .mb-media video {
145+ max-width: 280px;
146+ max-height: 280px;
147+ border-radius: 2px;
148+}
149+
150+.mb-cw summary, .mb-confirm summary, .mb-report summary {
151+ cursor: pointer;
152+ color: #ea79d8;
153+}
154+
155+.mb-actions {
156+ display: flex;
157+ flex-wrap: wrap;
158+ gap: 6px;
159+ align-items: flex-start;
160+ margin-top: 4px;
161+}
162+
163+.mb-actions form {
164+ display: inline;
165+}
166+
167+.mb-actions button, .mb-button, .mb-row button, .mb-compose button {
168+ background: #232023;
169+ color: #fcfcfc;
170+ border: 1px solid #c016c6;
171+ border-radius: 2px;
172+ padding: 2px 8px;
173+ cursor: pointer;
174+ font-size: small;
175+ text-decoration: none;
176+}
177+
178+.mb-actions button.on, .mb-row button.on, .mb-compose button {
179+ background: #550f75;
180+}
181+
182+button.danger {
183+ border-color: #ee4030;
184+ color: #ee4030;
185+}
186+
187+.mb-more {
188+ text-align: center;
189+}
190+
191+.mb-report-item {
192+ border: 1px solid #550f75;
193+ padding: 8px;
194+ margin: 8px 0;
195+}
196+
197+.mb-report-item.resolved {
198+ opacity: 0.5;
199+}
200+
201+@media screen and (max-width: 768px) {
202+ .mb-profile {
203+ flex-direction: column;
204+ }
205+ .mb-media img, .mb-media video {
206+ max-width: 100%;
207+ }
208+}
変更 route.php
@@ -43,6 +43,7 @@
4343 use Site\Controller\Atom;
4444 use Site\Controller\Fediverse;
4545 use Site\Controller\Home;
46+use Site\Controller\Microblog;
4647 use Site\Controller\Notfound;
4748 use Site\Controller\Op;
4849 use Site\Controller\Page;
@@ -76,6 +77,35 @@
7677 $routes[] = Route::add('GET', 'ap/objects/{uuid}', Fediverse::class.'@apobject');
7778 $routes[] = Route::add('POST', 'ap/inbox', Fediverse::class.'@apinbox');
7879 $routes[] = Route::add('GET', 'ap/actor', Fediverse::class.'@apactor');
80+}
81+
82+if (ACTIVITYPUB_MULTIUSER) {
83+ $routes[] = Route::add('GET', '.well-known/nodeinfo', Fediverse::class.'@nodeinfo');
84+ $routes[] = Route::add('GET', 'nodeinfo/2.0', Fediverse::class.'@nodeinfoSchema');
85+ $routes[] = Route::add('GET', 'ap/users/{name}', Fediverse::class.'@apuser');
86+ $routes[] = Route::add('POST', 'ap/users/{name}/inbox', Fediverse::class.'@apuserinbox');
87+ $routes[] = Route::add('GET', 'ap/users/{name}/outbox', Fediverse::class.'@apuseroutbox');
88+ $routes[] = Route::add('GET', 'ap/users/{name}/followers', Fediverse::class.'@apuserfollowers');
89+ $routes[] = Route::add('GET', 'ap/users/{name}/following', Fediverse::class.'@apuserfollowing');
90+ $routes[] = Route::add('GET', 'ap/users/{name}/notes/{id}', Fediverse::class.'@apnote');
91+ $routes[] = Route::add('GET', 'ap/users/{name}/notes/{id}/activity', Fediverse::class.'@apnoteactivity');
92+
93+ $routes[] = Route::add('GET', 'microblog', Microblog::class.'@home');
94+ $routes[] = Route::add('GET', 'microblog/local', Microblog::class.'@local');
95+ $routes[] = Route::add('GET', 'microblog/federated', Microblog::class.'@federated');
96+ $routes[] = Route::add('GET', 'microblog/tag/{tag}', Microblog::class.'@tag');
97+ $routes[] = Route::add('GET', 'microblog/user/{name}', Microblog::class.'@user');
98+ $routes[] = Route::add('GET', 'microblog/remote', Microblog::class.'@remote');
99+ $routes[] = Route::add('GET', 'microblog/note', Microblog::class.'@note');
100+ $routes[] = Route::add('GET', 'microblog/search', Microblog::class.'@search');
101+ $routes[] = Route::add('GET', 'microblog/notifications', Microblog::class.'@notifications');
102+ $routes[] = Route::add('GET', 'microblog/blocks', Microblog::class.'@blocks');
103+ $routes[] = Route::add('GET', 'microblog/edit', Microblog::class.'@edit');
104+ $routes[] = Route::add('POST', 'microblog/edit', Microblog::class.'@edit');
105+ $routes[] = Route::add('POST', 'microblog/post', Microblog::class.'@post');
106+ $routes[] = Route::add('POST', 'microblog/action', Microblog::class.'@action');
107+ $routes[] = Route::add('GET', 'microblog/admin', Microblog::class.'@admin');
108+ $routes[] = Route::add('POST', 'microblog/admin', Microblog::class.'@admin');
79109 }
80110
81111 if (AUTH_ENABLED) {
@@ -40,6 +40,11 @@
4040 use Site\Controller\BlogPost;
4141 use Site\Controller\Mods;
4242 use Std\Lib\Activitypub;
43+use Std\Lib\Fedi\Actor;
44+use Std\Lib\Fedi\Fedi;
45+use Std\Lib\Fedi\Inbox;
46+use Std\Lib\Fedi\Notes;
47+use Std\Lib\Fedi\Timeline;
4348 use LogType;
4449
4550 class Fediverse extends BlogPost {
@@ -51,6 +56,13 @@
5156 */
5257 public function apfinger(array $params): void {
5358 try {
59+ // マルチユーザーでは「resource」でユーザーを探す(ブログのアクター以外)
60+ $resource = is_string($_GET['resource'] ?? null) ? $_GET['resource'] : '';
61+ if (Fedi::enabled() && $resource !== '' && !$this->isBlogResource($resource)) {
62+ $jrd = Actor::webfinger($resource);
63+ if ($jrd === null) $this->json(404, ['error' => '見つかりません'], 'application/jrd+json');
64+ $this->json(200, $jrd, 'application/jrd+json');
65+ }
5466 header('Content-Type: application/jrd+json');
5567 $ap = new Activitypub();
5668 echo $ap->getWebfinger();
@@ -90,7 +102,7 @@
90102 exit;
91103 }
92104
93- $maxBody = 65536;
105+ $maxBody = 262144;
94106 $input = file_get_contents('php://input', false, null, 0, $maxBody + 1);
95107 if ($input === false || strlen($input) > $maxBody) {
96108 header('HTTP/1.1 413 Payload Too Large');
@@ -122,6 +134,11 @@
122134 }
123135
124136 logger(\LogType::ActivityPub, "受付に入れた:".json_encode($activity));
137+ // マルチユーザーでは共有の受付ボックス(ブログのアクター宛てはInboxからActivitypubに渡す)
138+ if (Fedi::enabled()) {
139+ [$code, $body] = Inbox::handle($activity);
140+ $this->json($code, $body);
141+ }
125142 $ap->postInbox($activity);
126143 exit;
127144 } catch (\Exception $e) {
@@ -129,6 +146,183 @@
129146 echo 'フェディバースの作成に失敗: '.$e->getMessage();
130147 exit;
131148 }
149+ }
150+
151+ /**
152+ * ユーザーのアクター(/ap/users/{name})。ブラウザからはプロフィールのページに移動する
153+ *
154+ * @param array $params パラメータ配列
155+ * @return void
156+ */
157+ public function apuser(array $params): void {
158+ $u = $this->actorOr404($params);
159+ if ($this->wantsHtml()) {
160+ header('Location: /microblog/user/'.$u->username);
161+ exit;
162+ }
163+ $this->json(200, Actor::document($u));
164+ }
165+
166+ /**
167+ * ユーザーの受付ボックス(POST /ap/users/{name}/inbox)。処理は共有の受付ボックスと同じ
168+ *
169+ * @param array $params パラメータ配列
170+ * @return void
171+ */
172+ public function apuserinbox(array $params): void {
173+ $this->actorOr404($params);
174+ $this->apinbox($params);
175+ }
176+
177+ /**
178+ * ユーザーの送信ボックス(公開・未収載の投稿とブースト、20件ずつ)
179+ *
180+ * @param array $params パラメータ配列
181+ * @return void
182+ */
183+ public function apuseroutbox(array $params): void {
184+ $u = $this->actorOr404($params);
185+ $url = Fedi::actorUrl($u->username).'/outbox';
186+ $me = Fedi::actorUrl($u->username);
187+ $entries = array_values(array_filter(Timeline::entries(), fn($e) => $e['actor'] === $me && in_array($e['vis'], ['public', 'unlisted'], true)));
188+ usort($entries, fn($a, $b) => $b['t'] <=> $a['t']);
189+ $total = count($entries);
190+
191+ if (!isset($_GET['page'])) {
192+ $this->json(200, ['@context' => 'https://www.w3.org/ns/activitystreams', 'id' => $url, 'type' => 'OrderedCollection',
193+ 'totalItems' => $total, 'first' => "{$url}?page=1", 'last' => "{$url}?page=".max(1, (int)ceil($total / 20))]);
194+ }
195+
196+ $page = max(1, (int)$_GET['page']);
197+ $boostIds = Fedi::read(Fedi::userPath($u->username, 'boosts.json'));
198+ $items = [];
199+ foreach (array_slice($entries, ($page - 1) * 20, 20) as $e) {
200+ if ($e['type'] === 'boost') {
201+ $items[] = ['id' => $boostIds[$e['object']] ?? $me.'#boosts/'.sha1($e['object']), 'type' => 'Announce', 'actor' => $me, 'published' => Fedi::now((int)$e['t']),
202+ 'to' => [Fedi::PUBLIC], 'cc' => [$me.'/followers'], 'object' => $e['object']];
203+ continue;
204+ }
205+ $ids = Fedi::localNoteId($e['object']);
206+ $note = $ids ? Notes::get($ids[1]) : null;
207+ if (!$note) continue;
208+ $create = Notes::toCreate($note);
209+ unset($create['@context'], $create['object']['@context']);
210+ $items[] = $create;
211+ }
212+ $data = ['@context' => Fedi::CONTEXT, 'id' => "{$url}?page={$page}", 'type' => 'OrderedCollectionPage', 'partOf' => $url, 'orderedItems' => $items];
213+ if ($page * 20 < $total) $data['next'] = "{$url}?page=".($page + 1);
214+ if ($page > 1) $data['prev'] = "{$url}?page=".($page - 1);
215+ $this->json(200, $data);
216+ }
217+
218+ /**
219+ * フォロワー・フォロー(数だけ公開する)
220+ *
221+ * @param array $params パラメータ配列
222+ * @return void
223+ */
224+ public function apuserfollowers(array $params): void {
225+ $u = $this->actorOr404($params);
226+ $this->json(200, Actor::collection($u->username, 'followers'));
227+ }
228+
229+ public function apuserfollowing(array $params): void {
230+ $u = $this->actorOr404($params);
231+ $this->json(200, Actor::collection($u->username, 'following'));
232+ }
233+
234+ /**
235+ * 投稿(/ap/users/{name}/notes/{id})
236+ * 公開・未収載以外は、署名したアクターが宛先かフォロワーの場合だけ返す
237+ *
238+ * @param array $params パラメータ配列
239+ * @return void
240+ */
241+ public function apnote(array $params): void {
242+ [$u, $note] = $this->noteOr404($params);
243+ $url = Notes::url($note);
244+ if ($this->wantsHtml()) {
245+ header('Location: '.Notes::htmlUrl($url));
246+ exit;
247+ }
248+ $this->json(200, Notes::toObject($note));
249+ }
250+
251+ public function apnoteactivity(array $params): void {
252+ [$u, $note] = $this->noteOr404($params);
253+ $this->json(200, Notes::toCreate($note));
254+ }
255+
256+ /**
257+ * NodeInfo(サーバーのソフトウェアの情報。他のサーバーが表示に使う)
258+ *
259+ * @param array $params パラメータ配列
260+ * @return void
261+ */
262+ public function nodeinfo(array $params): void {
263+ $this->json(200, ['links' => [['rel' => 'http://nodeinfo.diaspora.software/ns/schema/2.0', 'href' => Fedi::base().'/nodeinfo/2.0']]],
264+ 'application/json');
265+ }
266+
267+ public function nodeinfoSchema(array $params): void {
268+ $users = 0;
269+ foreach (glob((Fedi::$userDir ?? ROOT.'/data/user/').'*.json') ?: [] as $f) {
270+ $d = json_decode((string)file_get_contents($f));
271+ if ($d instanceof \stdClass && can_federate($d)) $users++;
272+ }
273+ $this->json(200, [
274+ 'version' => '2.0',
275+ 'software' => ['name' => 'littlebeast', 'version' => '1.0'],
276+ 'protocols' => ['activitypub'],
277+ 'services' => ['inbound' => [], 'outbound' => ['atom1.0']],
278+ 'openRegistrations' => AUTH_REGISTER_ENABLED,
279+ 'usage' => ['users' => ['total' => $users], 'localPosts' => count(glob(Fedi::path('notes/').'*.json') ?: [])],
280+ 'metadata' => ['nodeName' => SITEINFO['title']],
281+ ], 'application/json; profile="http://nodeinfo.diaspora.software/ns/schema/2.0#"');
282+ }
283+
284+ ///////
285+
286+ /** ローカルのアクター、無ければ404 */
287+ private function actorOr404(array $params): \stdClass {
288+ $u = Fedi::enabled() ? Fedi::localActor((string)($params['name'] ?? '')) : null;
289+ if ($u === null) $this->json(404, ['error' => '見つかりません']);
290+ return $u;
291+ }
292+
293+ /** 見る事が出来る投稿、無ければ404 */
294+ private function noteOr404(array $params): array {
295+ $u = $this->actorOr404($params);
296+ $note = Notes::get((string)($params['id'] ?? ''));
297+ if (!$note || $note['author'] !== $u->username) $this->json(404, ['error' => '見つかりません']);
298+ if (!in_array($note['visibility'], ['public', 'unlisted'], true)) {
299+ $signer = (new Activitypub())->signer();
300+ $ok = $signer !== null && (in_array($signer, array_merge($note['to'], $note['cc']), true)
301+ || ($note['visibility'] === 'followers' && Actor::isFollowedBy($u->username, $signer)));
302+ if (!$ok) $this->json(404, ['error' => '見つかりません']);
303+ }
304+ return [$u, $note];
305+ }
306+
307+ /** ブラウザからのアクセスか(ActivityPubのJSONを求めていない) */
308+ private function wantsHtml(): bool {
309+ $accept = (string)($_SERVER['HTTP_ACCEPT'] ?? '');
310+ return str_contains($accept, 'text/html') && !str_contains($accept, 'activity+json') && !str_contains($accept, 'ld+json');
311+ }
312+
313+ /** ブログのアクターのresource(acct:actor@host又は/ap/actor) */
314+ private function isBlogResource(string $resource): bool {
315+ if ($resource === Fedi::blogActorUrl()) return true;
316+ return strtolower($resource) === strtolower('acct:'.FEDIINFO['actor'].'@'.Fedi::host());
317+ }
318+
319+ private function json(int $code, array $data, string $type = 'application/activity+json'): never {
320+ $status = [200 => 'OK', 202 => 'Accepted', 400 => 'Bad Request', 401 => 'Unauthorized', 403 => 'Forbidden',
321+ 404 => 'Not Found', 500 => 'Internal Server Error'][$code] ?? '';
322+ header("HTTP/1.1 {$code} {$status}");
323+ header("Content-Type: {$type}");
324+ echo json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
325+ exit;
132326 }
133327
134328 /**
@@ -0,0 +1,659 @@
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 Site\Controller;
39+
40+use Site\Controller\Mods;
41+use Std\Lib\Auth;
42+use Std\Lib\Template;
43+use Std\Lib\Fedi\Actor;
44+use Std\Lib\Fedi\Fedi;
45+use Std\Lib\Fedi\Html;
46+use Std\Lib\Fedi\Media;
47+use Std\Lib\Fedi\Notes;
48+use Std\Lib\Fedi\Remote;
49+use Std\Lib\Fedi\Social;
50+use Std\Lib\Fedi\Timeline;
51+use Std\Lib\Fedi\Delivery;
52+use Roles;
53+
54+/**
55+ * マイクロブログ(/microblog)。JavaScriptは使わず、全部の操作はフォームで行う
56+ */
57+class Microblog {
58+ use Mods;
59+
60+ private ?\stdClass $user = null;
61+ /** Fediverseを使えるログイン中のユーザー名 */
62+ private ?string $me = null;
63+
64+ public function __construct() {
65+ if (!Fedi::enabled()) {
66+ header('Location: /404');
67+ exit;
68+ }
69+ $this->user = (new Auth())->getLoggedInUser();
70+ if ($this->user && can_federate($this->user) && (($this->user->verified ?? true) !== false)) $this->me = $this->user->username;
71+ }
72+
73+ //////// タイムライン
74+
75+ public function home(array $params): void {
76+ if ($this->me === null) $this->redirect('/microblog/local');
77+ $this->timeline('home', 'ホーム');
78+ }
79+
80+ public function local(array $params): void {
81+ $this->timeline('local', 'ローカル');
82+ }
83+
84+ public function federated(array $params): void {
85+ $this->timeline('federated', '連合');
86+ }
87+
88+ public function tag(array $params): void {
89+ $tag = mb_strtolower((string)($params['tag'] ?? ''));
90+ [$items, $next] = Timeline::query('tag', $this->me, ['tag' => $tag, 'before' => $this->before()]);
91+ $this->render('microblog/timeline', "#{$tag}", [
92+ 'scope' => 'tag',
93+ 'heading' => "#{$tag}",
94+ 'items' => $this->present($items),
95+ 'next' => $next !== null ? "/microblog/tag/".rawurlencode($tag)."?before={$next}" : null,
96+ ]);
97+ }
98+
99+ private function timeline(string $scope, string $title): void {
100+ [$items, $next] = Timeline::query($scope, $this->me, ['before' => $this->before()]);
101+ $path = $scope === 'home' ? '/microblog' : "/microblog/{$scope}";
102+ $this->render('microblog/timeline', $title, [
103+ 'scope' => $scope,
104+ 'heading' => $title,
105+ 'items' => $this->present($items),
106+ 'next' => $next !== null ? "{$path}?before={$next}" : null,
107+ 'replyTo' => null,
108+ ]);
109+ }
110+
111+ //////// プロフィール
112+
113+ /** ローカルのユーザーの投稿(/microblog/user/{name}) */
114+ public function user(array $params): void {
115+ $name = (string)($params['name'] ?? '');
116+ $u = Fedi::localActor($name);
117+ if (!$u) $this->notFound();
118+ $url = Fedi::actorUrl($name);
119+ [$items, $next] = Timeline::query('user', $this->me, ['user' => $name, 'before' => $this->before()]);
120+ $this->render('microblog/profile', $u->displayname ?: $u->username, [
121+ 'profile' => [
122+ 'url' => $url,
123+ 'name' => $u->displayname ?: $u->username,
124+ 'handle' => "@{$name}@".Fedi::host(),
125+ 'avatar' => avatar_url($u),
126+ 'bio' => ($u->bio ?? '') !== '' ? Html::render($u->bio) : '',
127+ 'local' => true,
128+ 'profilePage' => "/profile/{$name}",
129+ 'locked' => (bool)Actor::settings($name)['locked'],
130+ 'followers' => count(Actor::followers($name)),
131+ 'following' => count(array_filter(Actor::following($name), fn($f) => $f['state'] === 'accepted')),
132+ ],
133+ 'relation' => $this->relation($url),
134+ 'items' => $this->present($items),
135+ 'next' => $next !== null ? "/microblog/user/{$name}?before={$next}" : null,
136+ ]);
137+ }
138+
139+ /** リモートのアクター(/microblog/remote?actor=URL)。送信ボックスから最近の投稿を取得する */
140+ public function remote(array $params): void {
141+ $url = (string)($_GET['actor'] ?? '');
142+ $local = Fedi::localUsername($url);
143+ if ($local !== null) $this->redirect("/microblog/user/{$local}");
144+ if (!Remote::isSafeUrl($url) || Actor::isDomainBlocked($url)) $this->notFound();
145+ // 他のサーバーへの問い合わせはログインしたユーザーだけ(誰でも取得させられると、踏み台にされる)
146+ $doc = $this->me !== null ? Remote::actor($url, isset($_GET['refresh'])) : Remote::cachedActor($url);
147+ if (!$doc) $this->notFound();
148+
149+ $items = [];
150+ foreach ($this->me !== null ? $this->fetchOutbox($doc) : [] as $obj) {
151+ $items[] = ['entry' => ['type' => 'note', 'actor' => $url, 'object' => $obj['id']], 'object' => $obj];
152+ }
153+ $this->render('microblog/profile', (string)($doc['name'] ?? $doc['preferredUsername'] ?? ''), [
154+ 'profile' => [
155+ 'url' => $url,
156+ 'name' => (string)(($doc['name'] ?? '') ?: ($doc['preferredUsername'] ?? '')),
157+ 'handle' => Remote::handle($doc),
158+ 'avatar' => self::safeUrl($doc['icon']['url'] ?? (is_string($doc['icon'] ?? null) ? $doc['icon'] : '')) ?: '/static/user/noicon.png',
159+ 'bio' => Html::sanitize((string)($doc['summary'] ?? '')),
160+ 'local' => false,
161+ 'profilePage' => self::safeUrl(is_string($doc['url'] ?? null) ? $doc['url'] : ''),
162+ 'locked' => (bool)($doc['manuallyApprovesFollowers'] ?? false),
163+ 'followers' => null,
164+ 'following' => null,
165+ ],
166+ 'relation' => $this->relation($url),
167+ 'items' => $this->present($items),
168+ 'next' => null,
169+ ]);
170+ }
171+
172+ //////// 投稿のページ
173+
174+ /** 投稿とスレッド(/microblog/note?id=URL) */
175+ public function note(array $params): void {
176+ $id = (string)($_GET['id'] ?? '');
177+ $obj = $this->visibleObject($id);
178+ if (!$obj) $this->notFound();
179+
180+ // 返信先を辿る(5件まで)
181+ $ancestors = [];
182+ $parent = Fedi::id($obj['inReplyTo'] ?? null);
183+ for ($i = 0; $i < 5 && $parent !== null; $i++) {
184+ $p = $this->visibleObject($parent, $this->me !== null);
185+ if (!$p) break;
186+ array_unshift($ancestors, ['entry' => ['type' => 'note', 'actor' => Fedi::id($p['attributedTo']), 'object' => $p['id']], 'object' => $p]);
187+ $parent = Fedi::id($p['inReplyTo'] ?? null);
188+ }
189+ $replies = [];
190+ foreach (Timeline::replies($obj['id']) as $e) {
191+ if (!Timeline::canSee($this->me, $e)) continue;
192+ if ($this->me && Actor::isBlocked($this->me, $e['author'])) continue;
193+ $r = Timeline::object($e['object']);
194+ if ($r) $replies[] = ['entry' => $e, 'object' => $r];
195+ }
196+
197+ $item = $this->present([['entry' => ['type' => 'note', 'actor' => Fedi::id($obj['attributedTo']), 'object' => $obj['id']], 'object' => $obj]])[0];
198+ // 返信の初期値:相手と、メンションされた人
199+ $mention = '';
200+ if ($this->me) {
201+ $handles = [];
202+ $authorDoc = Remote::cachedActor($item['author']['url']);
203+ if ($authorDoc && $item['author']['url'] !== Fedi::actorUrl($this->me)) $handles[] = Remote::handle($authorDoc);
204+ foreach ($obj['tag'] ?? [] as $t) {
205+ if (is_array($t) && ($t['type'] ?? '') === 'Mention' && ($t['href'] ?? '') !== Fedi::actorUrl($this->me) && is_string($t['name'] ?? null)) {
206+ $n = str_contains(ltrim($t['name'], '@'), '@') ? $t['name'] : $t['name'].'@'.Fedi::domainOf((string)$t['href']);
207+ $handles[] = '@'.ltrim($n, '@');
208+ }
209+ }
210+ $handles = array_unique(array_map(fn($h) => preg_replace('/@'.preg_quote(Fedi::host(), '/').'$/', '', $h), $handles));
211+ $mention = $handles ? implode(' ', $handles).' ' : '';
212+ }
213+
214+ $this->render('microblog/note', Html::text((string)($obj['summary'] ?? '') ?: (string)($obj['content'] ?? ''), 30), [
215+ 'ancestors' => $this->present($ancestors),
216+ 'item' => $item,
217+ 'replies' => $this->present($replies),
218+ 'replyTo' => $obj['id'],
219+ 'replyText' => $mention,
220+ 'replyVis' => $item['visKey'] === 'direct' ? 'direct' : ($item['visKey'] === 'followers' ? 'followers' : 'public'),
221+ ]);
222+ }
223+
224+ /** 自分の投稿の編集(GET /microblog/edit?id=) */
225+ public function edit(array $params): void {
226+ $this->requireMe();
227+ $note = Notes::get((string)($_GET['id'] ?? $_POST['id'] ?? ''));
228+ if (!$note || $note['author'] !== $this->me) $this->notFound();
229+ $error = '';
230+ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
231+ if (!$this->csrfOk()) {
232+ $error = 'セッションの有効期限が切れました。もう一度送信して下さい。';
233+ $note['source'] = (string)($_POST['content'] ?? $note['source']);
234+ } else {
235+ $res = Social::edit($this->me, $note['id'], (string)($_POST['content'] ?? ''), (string)($_POST['cw'] ?? ''));
236+ if ($res->isSuccess) $this->redirect('/microblog/note?id='.rawurlencode(Notes::url($note)));
237+ $error = $res->message;
238+ $note['source'] = (string)($_POST['content'] ?? '');
239+ $note['summary'] = (string)($_POST['cw'] ?? '');
240+ }
241+ }
242+ $this->render('microblog/edit', '投稿の編集', ['note' => $note, 'error' => $error]);
243+ }
244+
245+ //////// 投稿と操作
246+
247+ /** 投稿する(POST /microblog/post、multipart/form-data) */
248+ public function post(array $params): void {
249+ $this->requireMe();
250+ $text = (string)($_POST['content'] ?? '');
251+ $back = safe_local_path($_POST['back'] ?? '', '/microblog');
252+ $error = '';
253+
254+ if (!$this->csrfOk()) {
255+ $error = 'セッションの有効期限が切れました。もう一度送信して下さい。';
256+ } else {
257+ // 添付
258+ $media = [];
259+ $files = $_FILES['media'] ?? null;
260+ if (is_array($files) && is_array($files['name'] ?? null)) {
261+ foreach ($files['name'] as $i => $name) {
262+ if (($files['error'][$i] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) continue;
263+ if (count($media) >= Fedi::MAX_ATTACHMENTS) break;
264+ $one = ['name' => $name, 'tmp_name' => $files['tmp_name'][$i], 'size' => $files['size'][$i], 'error' => $files['error'][$i]];
265+ $res = Media::save($one, 'media/'.$this->me);
266+ if (!$res->isSuccess) {
267+ $error = $res->message;
268+ break;
269+ }
270+ $media[] = $res->data;
271+ }
272+ }
273+ if ($error === '') {
274+ $res = Social::post($this->me, $text, [
275+ 'cw' => (string)($_POST['cw'] ?? ''),
276+ 'visibility' => (string)($_POST['visibility'] ?? 'public'),
277+ 'inReplyTo' => (string)($_POST['inReplyTo'] ?? ''),
278+ 'media' => $media,
279+ 'alts' => array_map('strval', (array)($_POST['alt'] ?? [])),
280+ ]);
281+ if ($res->isSuccess) $this->redirect($back);
282+ $error = $res->message;
283+ }
284+ foreach ($media ?? [] as $m) Media::remove($m['path']);
285+ }
286+
287+ // 失敗:入力した内容を残して表示する
288+ [$items, $next] = Timeline::query('home', $this->me);
289+ $this->render('microblog/timeline', 'ホーム', [
290+ 'scope' => 'home',
291+ 'heading' => 'ホーム',
292+ 'items' => $this->present($items),
293+ 'next' => null,
294+ 'error' => $error,
295+ 'draft' => $text,
296+ 'draftCw' => (string)($_POST['cw'] ?? ''),
297+ 'replyTo' => (string)($_POST['inReplyTo'] ?? '') ?: null,
298+ ]);
299+ }
300+
301+ /**
302+ * いいね・ブースト・削除・フォロー・ブロック等(POST /microblog/action)
303+ * do:操作、target:投稿又はアクターのURL、back:戻るページ
304+ */
305+ public function action(array $params): void {
306+ $this->requireMe();
307+ $back = safe_local_path($_POST['back'] ?? '', '/microblog');
308+ if (!$this->csrfOk()) $this->message('セッションの有効期限が切れました。前のページに戻って、もう一度お試し下さい。', $back);
309+
310+ $do = (string)($_POST['do'] ?? '');
311+ $target = trim((string)($_POST['target'] ?? ''));
312+ $me = $this->me;
313+ $res = match ($do) {
314+ 'like' => Social::like($me, $target),
315+ 'unlike' => Social::unlike($me, $target),
316+ 'boost' => Social::boost($me, $target),
317+ 'unboost' => Social::unboost($me, $target),
318+ 'delete' => ($ids = Fedi::localNoteId($target)) ? Social::delete($me, $ids[1]) : \Result::Error('投稿が見つかりません。'),
319+ 'follow' => Social::follow($me, $target),
320+ 'unfollow' => Social::unfollow($me, $target),
321+ 'accept' => Social::answerRequest($me, $target, true),
322+ 'reject' => Social::answerRequest($me, $target, false),
323+ 'removefollower' => Social::removeFollower($me, $target),
324+ 'block' => Social::block($me, $target),
325+ 'unblock' => Social::unblock($me, $target),
326+ 'blockdomain' => Social::blockDomain($me, $target, true),
327+ 'unblockdomain' => Social::blockDomain($me, $target, false),
328+ 'report' => Social::report($me, $target, array_values(array_filter([(string)($_POST['object'] ?? '')])),
329+ (string)($_POST['comment'] ?? ''), isset($_POST['forward'])),
330+ default => \Result::Error('不明な操作です。'),
331+ };
332+ // 削除した投稿のページには戻れない
333+ if ($do === 'delete' && str_starts_with($back, '/microblog/note')) $back = '/microblog';
334+ if (!$res->isSuccess) $this->message((string)$res->message, $back);
335+ $this->redirect($back);
336+ }
337+
338+ //////// 検索
339+
340+ /**
341+ * 検索(/microblog/search?q=)
342+ * 「@user@host」・URL:アカウントか投稿を探し、見つかればそのページへ
343+ * 「#タグ」:タグのページへ
344+ * それ以外:ローカルと受け取った投稿の本文を検索する
345+ */
346+ public function search(array $params): void {
347+ $q = trim((string)($_GET['q'] ?? ''));
348+ $items = [];
349+ $error = '';
350+ if ($q !== '') {
351+ if (preg_match('/^#([\p{L}\p{N}_]+)$/u', $q, $m)) $this->redirect('/microblog/tag/'.rawurlencode(mb_strtolower($m[1])));
352+
353+ $isLocalHandle = (bool)preg_match('/^@[A-Za-z0-9]+$/', $q);
354+ if ($isLocalHandle || preg_match('/^@?[A-Za-z0-9_.-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/', $q)) {
355+ // 他のサーバーへの問い合わせはログインしたユーザーだけ
356+ $url = $this->me !== null || $isLocalHandle ? Remote::resolve($q) : null;
357+ if ($url !== null) $this->redirect($this->actorPage($url));
358+ $error = $this->me === null ? '他のサーバーのアカウントを探すにはログインして下さい。' : 'アカウントが見つかりません。';
359+ } else if (preg_match('#^https://#', $q)) {
360+ if ($this->me === null) {
361+ $error = '他のサーバーを検索するにはログインして下さい。';
362+ } else if (($doc = Remote::actor($q)) !== null) {
363+ $this->redirect($this->actorPage($doc['id']));
364+ } else if (($obj = Remote::object($q)) !== null && in_array($obj['type'] ?? '', ['Note', 'Article', 'Question', 'Page'], true)) {
365+ // 見つけた投稿は保存し、いいね・ブースト・返信が出来る様にする
366+ if (!Fedi::isLocal($obj['id'])) {
367+ Remote::store($obj);
368+ Timeline::add($obj);
369+ Remote::actor((string)Fedi::id($obj['attributedTo'] ?? null));
370+ }
371+ $this->redirect('/microblog/note?id='.rawurlencode($obj['id']));
372+ } else {
373+ $error = '見つかりません。';
374+ }
375+ } else {
376+ $items = Timeline::search($q, $this->me);
377+ if (empty($items)) $error = '見つかりません。';
378+ }
379+ }
380+ $this->render('microblog/search', '検索', ['q' => $q, 'items' => $this->present($items), 'error' => $error]);
381+ }
382+
383+ //////// 通知・ブロック・管理
384+
385+ public function notifications(array $params): void {
386+ $this->requireMe();
387+ $list = [];
388+ foreach (Actor::notifications($this->me) as $n) {
389+ $a = $this->who($n['actor']);
390+ $obj = $n['object'] ? Timeline::object($n['object']) : null;
391+ $list[] = [
392+ 'type' => $n['type'],
393+ 'label' => ['follow' => 'があなたをフォローしました', 'follow_request' => 'がフォローをリクエストしました',
394+ 'mention' => 'があなたをメンションしました', 'reply' => 'が返信しました',
395+ 'like' => 'がいいねしました', 'boost' => 'がブーストしました'][$n['type']] ?? $n['type'],
396+ 'who' => $a,
397+ 'object' => $n['object'],
398+ 'page' => $n['object'] ? '/microblog/note?id='.rawurlencode($n['object']) : null,
399+ 'excerpt' => $obj ? Html::text((string)($obj['content'] ?? ''), 80) : '',
400+ 'at' => date('Y-m-d H:i', $n['at']),
401+ 'read' => $n['read'],
402+ ];
403+ }
404+ $requests = [];
405+ foreach (Actor::requests($this->me) as $actor => $r) $requests[] = ['who' => $this->who($actor), 'actor' => $actor];
406+ Actor::markRead($this->me);
407+ $this->render('microblog/notifications', '通知', ['list' => $list, 'requests' => $requests]);
408+ }
409+
410+ public function blocks(array $params): void {
411+ $this->requireMe();
412+ $b = Actor::blocks($this->me);
413+ $this->render('microblog/blocks', 'ブロック', [
414+ 'actors' => array_map(fn($a) => ['actor' => $a, 'who' => $this->who($a)], $b['actors']),
415+ 'domains' => $b['domains'],
416+ 'followers' => array_map(fn($a) => ['actor' => $a, 'who' => $this->who($a)], array_keys(Actor::followers($this->me))),
417+ ]);
418+ }
419+
420+ /** 管理(サイト全体のドメインのブロック、通報)。管理者だけ */
421+ public function admin(array $params): void {
422+ if (!$this->user || !($this->user->role & Roles::ADMIN)) $this->notFound();
423+ $error = '';
424+ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
425+ if (!$this->csrfOk()) {
426+ $error = 'セッションの有効期限が切れました。もう一度お試し下さい。';
427+ } else {
428+ $do = (string)($_POST['do'] ?? '');
429+ $res = match ($do) {
430+ 'blockinstance' => Social::blockInstance((string)($_POST['domain'] ?? ''), true),
431+ 'unblockinstance' => Social::blockInstance((string)($_POST['domain'] ?? ''), false),
432+ 'resolve' => (function() { Social::resolveReport((string)($_POST['id'] ?? '')); return \Result::Success(); })(),
433+ default => \Result::Error('不明な操作です。'),
434+ };
435+ if ($res->isSuccess) $this->redirect('/microblog/admin');
436+ $error = (string)$res->message;
437+ }
438+ }
439+ $reports = [];
440+ foreach (Social::reports() as $r) {
441+ $reports[] = $r + ['fromWho' => $this->who($r['from']), 'actorWho' => $this->who($r['actor']), 'at' => date('Y-m-d H:i', $r['at']),
442+ 'objectPages' => array_map(fn($o) => '/microblog/note?id='.rawurlencode($o), $r['objects'])];
443+ }
444+ $this->render('microblog/admin', 'Fediverseの管理', [
445+ 'domains' => Actor::blockedDomains(),
446+ 'reports' => $reports,
447+ 'queue' => Delivery::pending(),
448+ 'error' => $error,
449+ ]);
450+ }
451+
452+ //////// 表示用
453+
454+ /**
455+ * タイムラインの項目をテンプレート用の配列にする
456+ *
457+ * @param array $items ['entry' => 行, 'object' => Note]
458+ * @return array
459+ */
460+ private function present(array $items): array {
461+ $out = [];
462+ $likes = $this->me ? Fedi::read(Fedi::userPath($this->me, 'likes.json')) : [];
463+ $boosts = $this->me ? Fedi::read(Fedi::userPath($this->me, 'boosts.json')) : [];
464+ foreach ($items as ['entry' => $e, 'object' => $o]) {
465+ $id = (string)$o['id'];
466+ $authorUrl = (string)Fedi::id($o['attributedTo'] ?? null);
467+ $to = Fedi::ids($o['to'] ?? []);
468+ $cc = Fedi::ids($o['cc'] ?? []);
469+ $vis = Fedi::visibility($to, $cc, Fedi::isLocal($authorUrl) ? $authorUrl.'/followers' : null);
470+ $local = Fedi::localNoteId($id);
471+ $note = $local ? Notes::get($local[1]) : null;
472+
473+ $attachments = [];
474+ foreach ((array)($o['attachment'] ?? []) as $a) {
475+ if (!is_array($a)) continue;
476+ $url = self::safeUrl(is_string($a['url'] ?? null) ? $a['url'] : (string)($a['url'][0]['href'] ?? ''));
477+ if ($url === '') continue;
478+ $mt = (string)($a['mediaType'] ?? '');
479+ $kind = str_starts_with($mt, 'video/') ? 'video' : (str_starts_with($mt, 'audio/') ? 'audio'
480+ : (str_starts_with($mt, 'image/') || ($a['type'] ?? '') === 'Image' ? 'image' : 'link'));
481+ $attachments[] = ['url' => $url, 'alt' => (string)($a['name'] ?? ''), 'kind' => $kind];
482+ }
483+
484+ $out[] = [
485+ 'id' => $id,
486+ 'page' => '/microblog/note?id='.rawurlencode($id),
487+ 'original' => self::safeUrl(is_string($o['url'] ?? null) ? $o['url'] : $id),
488+ 'author' => $this->who($authorUrl),
489+ 'booster' => ($e['type'] ?? '') === 'boost' ? $this->who($e['actor']) : null,
490+ 'content' => Html::sanitize((string)($o['content'] ?? '')),
491+ 'summary' => (string)($o['summary'] ?? ''),
492+ 'sensitive' => (bool)($o['sensitive'] ?? false) || ($o['summary'] ?? '') !== '',
493+ 'attachments' => $attachments,
494+ 'published' => date('Y-m-d H:i', strtotime((string)($o['published'] ?? '')) ?: time()),
495+ 'edited' => !empty($o['updated']),
496+ 'visKey' => $vis,
497+ 'vis' => Social::VISIBILITIES[$vis],
498+ 'reply' => ($r = Fedi::id($o['inReplyTo'] ?? null)) ? '/microblog/note?id='.rawurlencode($r) : null,
499+ 'likes' => $note ? count($note['likes']) : null,
500+ 'boosts' => $note ? count($note['boosts']) : null,
501+ 'replies' => $note ? count($note['replies']) : null,
502+ 'liked' => isset($likes[$id]),
503+ 'boosted' => isset($boosts[$id]),
504+ 'canBoost' => in_array($vis, ['public', 'unlisted'], true),
505+ 'mine' => $this->me !== null && $authorUrl === Fedi::actorUrl($this->me),
506+ 'editUrl' => $note ? '/microblog/edit?id='.$note['id'] : null,
507+ ];
508+ }
509+ return $out;
510+ }
511+
512+ /**
513+ * アクターの表示用の情報(キャッシュだけ使う。取得しない)
514+ */
515+ private function who(string $url): array {
516+ $local = Fedi::localUsername($url);
517+ if ($local !== null) {
518+ $u = Fedi::localUser($local);
519+ return ['url' => $url, 'name' => $u ? ($u->displayname ?: $u->username) : $local, 'handle' => "@{$local}",
520+ 'avatar' => $u ? avatar_url($u) : '/static/user/noicon.png', 'page' => "/microblog/user/{$local}"];
521+ }
522+ $doc = Remote::cachedActor($url);
523+ $handle = $doc ? Remote::handle($doc) : '@'.basename((string)parse_url($url, PHP_URL_PATH)).'@'.Fedi::domainOf($url);
524+ $icon = $doc ? ($doc['icon']['url'] ?? (is_string($doc['icon'] ?? null) ? $doc['icon'] : '')) : '';
525+ return [
526+ 'url' => $url,
527+ 'name' => $doc ? (string)(($doc['name'] ?? '') ?: ($doc['preferredUsername'] ?? $handle)) : $handle,
528+ 'handle' => $handle,
529+ 'avatar' => self::safeUrl(is_string($icon) ? $icon : '') ?: '/static/user/noicon.png',
530+ 'page' => '/microblog/remote?actor='.rawurlencode($url),
531+ ];
532+ }
533+
534+ /** ログイン中のユーザーとアクターの関係 */
535+ private function relation(string $url): array {
536+ if ($this->me === null) return ['self' => false, 'can' => false];
537+ $f = Actor::following($this->me)[$url] ?? null;
538+ return [
539+ 'self' => $url === Fedi::actorUrl($this->me),
540+ 'can' => true,
541+ 'following' => ($f['state'] ?? '') === 'accepted',
542+ 'pending' => ($f['state'] ?? '') === 'pending',
543+ 'followsYou' => Actor::isFollowedBy($this->me, $url),
544+ 'blocked' => in_array($url, Actor::blocks($this->me)['actors'], true),
545+ 'domain' => Fedi::domainOf($url),
546+ 'remote' => !Fedi::isLocal($url),
547+ ];
548+ }
549+
550+ /**
551+ * 見る事が出来るオブジェクト(ローカル又は保存しているもの。$fetchなら取得もする)
552+ */
553+ private function visibleObject(string $id, bool $fetch = false): ?array {
554+ if ($id === '') return null;
555+ $obj = Timeline::object($id);
556+ if (!$obj && $fetch && !Fedi::isLocal($id) && Remote::isSafeUrl($id)) {
557+ $obj = Remote::object($id);
558+ if ($obj && in_array(Fedi::visibility(Fedi::ids($obj['to'] ?? []), Fedi::ids($obj['cc'] ?? [])), ['public', 'unlisted'], true)) {
559+ Remote::store($obj);
560+ Timeline::add($obj);
561+ } else {
562+ $obj = null;
563+ }
564+ }
565+ if (!$obj) return null;
566+ $author = (string)Fedi::id($obj['attributedTo'] ?? null);
567+ $e = ['vis' => Fedi::visibility(Fedi::ids($obj['to'] ?? []), Fedi::ids($obj['cc'] ?? []), Fedi::isLocal($author) ? $author.'/followers' : null),
568+ 'author' => $author, 'aud' => array_merge(Fedi::ids($obj['to'] ?? []), Fedi::ids($obj['cc'] ?? []))];
569+ return Timeline::canSee($this->me, $e) ? $obj : null;
570+ }
571+
572+ /**
573+ * リモートのアクターの最近の投稿(送信ボックスの最初のページ、公開のNoteだけ)
574+ */
575+ private function fetchOutbox(array $doc): array {
576+ $outbox = is_string($doc['outbox'] ?? null) ? Remote::fetch($doc['outbox']) : null;
577+ if (!$outbox) return [];
578+ $page = $outbox['orderedItems'] ?? null;
579+ if ($page === null) {
580+ $first = $outbox['first'] ?? null;
581+ $page = is_array($first) ? ($first['orderedItems'] ?? []) : (is_string($first) ? (Remote::fetch($first)['orderedItems'] ?? []) : []);
582+ }
583+ $out = [];
584+ foreach (array_slice((array)$page, 0, 20) as $act) {
585+ if (!is_array($act) || ($act['type'] ?? '') !== 'Create' || !is_array($act['object'] ?? null)) continue;
586+ $obj = $act['object'];
587+ $id = Fedi::id($obj['id'] ?? null);
588+ if ($id === null || Fedi::domainOf($id) !== Fedi::domainOf((string)$doc['id'])) continue;
589+ if (Fedi::id($obj['attributedTo'] ?? null) !== $doc['id']) continue;
590+ if (Fedi::visibility(Fedi::ids($obj['to'] ?? []), Fedi::ids($obj['cc'] ?? [])) !== 'public') continue;
591+ // いいね・ブースト・返信が出来る様に保存する(タイムラインには載せない)
592+ Remote::store($obj);
593+ $out[] = $obj;
594+ }
595+ return $out;
596+ }
597+
598+ private function actorPage(string $url): string {
599+ $local = Fedi::localUsername($url);
600+ return $local !== null ? "/microblog/user/{$local}" : '/microblog/remote?actor='.rawurlencode($url);
601+ }
602+
603+ private static function safeUrl(string $url): string {
604+ return preg_match('#^https?://[^\s"<>]+$#', $url) ? $url : '';
605+ }
606+
607+ private function before(): ?float {
608+ return isset($_GET['before']) && is_numeric($_GET['before']) ? (float)$_GET['before'] : null;
609+ }
610+
611+ private function csrfOk(): bool {
612+ return is_string($_POST['csrf_token'] ?? null) && verify_csrf_token($_POST['csrf_token']);
613+ }
614+
615+ private function requireMe(): void {
616+ if ($this->me !== null) return;
617+ if ($this->user === null) $this->redirect('/login');
618+ $this->message('Fediverseの機能を使う権限がありません。', '/microblog/local');
619+ }
620+
621+ private function redirect(string $to): never {
622+ header('Location: '.$to);
623+ exit;
624+ }
625+
626+ private function notFound(): never {
627+ header('Location: /404');
628+ exit;
629+ }
630+
631+ private function message(string $msg, string $back): never {
632+ $this->render('microblog/message', 'マイクロブログ', ['msg' => $msg, 'back' => $back]);
633+ exit;
634+ }
635+
636+ private function render(string $view, string $title, array $vars): void {
637+ $tmpl = new Template('/');
638+ $tmpl->assign('pagetit', $title);
639+ $tmpl->assign('curPage', 'microblog');
640+ $tmpl->assign('menu', $this->getMenu());
641+ $tmpl->assign('description', SITEINFO['description']);
642+ $tmpl->assign('user', $this->user);
643+ $tmpl->assign('me', $this->me);
644+ $tmpl->assign('unread', $this->me ? Actor::unreadCount($this->me) : 0);
645+ $tmpl->assign('isAdmin', $this->user && ($this->user->role & Roles::ADMIN));
646+ $tmpl->assign('here', safe_local_path($_SERVER['REQUEST_URI'] ?? '', '/microblog'));
647+ $tmpl->assign('visibilities', Social::VISIBILITIES);
648+ $tmpl->assign('maxLength', Fedi::MAX_LENGTH);
649+ $tmpl->assign('maxMedia', Fedi::MAX_ATTACHMENTS);
650+ // クッキーはテンプレートの出力の前に設定する
651+ $tmpl->assign('csrf', make_csrf_token());
652+ foreach (['error' => '', 'draft' => '', 'draftCw' => '', 'replyTo' => null, 'replyText' => '', 'replyVis' => 'public', 'scope' => ''] as $k => $v) {
653+ $tmpl->assign($k, $vars[$k] ?? $v);
654+ }
655+ foreach ($vars as $k => $v) $tmpl->assign($k, $v);
656+ $tmpl->addCss('microblog');
657+ $tmpl->render($view);
658+ }
659+}
@@ -84,6 +84,16 @@
8484 ],
8585 ];
8686
87+ if (ACTIVITYPUB_MULTIUSER) {
88+ $menu[] = [
89+ 'class' => 'menu-item',
90+ 'href' => '/microblog',
91+ 'page' => 'microblog',
92+ 'text' => 'マイクロブログ',
93+ 'show' => true,
94+ ];
95+ }
96+
8797 if (OPENPROVIDER_ENABLED) {
8898 $menu[] = [
8999 'class' => 'menu-item',
@@ -40,6 +40,9 @@
4040 use Site\Controller\Mods;
4141 use Std\Lib\Auth;
4242 use Std\Lib\Template;
43+use Std\Lib\Fedi\Actor;
44+use Std\Lib\Fedi\Social;
45+use Roles;
4346
4447 class User {
4548 use Mods;
@@ -246,6 +249,79 @@
246249 $tmpl->render('verify');
247250 }
248251
252+ /**
253+ * プロフィールの編集(/profile/{name}/edit)。本人と管理者だけ
254+ *
255+ * @param array $params パラメータ配列
256+ * @return void
257+ */
258+ public function editProfile(array $params): void {
259+ if (!AUTH_ENABLED) return;
260+ $name = (string)($params['name'] ?? '');
261+ $auth = new Auth;
262+ $user = $auth->getLoggedInUser();
263+ if (!$user) {
264+ header('Location: /login');
265+ exit();
266+ }
267+ $edit = $auth->getEditableUser($name);
268+ if (!$edit) {
269+ header('Location: /404');
270+ exit();
271+ }
272+
273+ $error = '';
274+ $message = '';
275+ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
276+ if (!is_string($_POST['csrf_token'] ?? null) || !\verify_csrf_token($_POST['csrf_token'])) {
277+ $error = 'セッションの有効期限が切れました。もう一度保存して下さい。';
278+ } else {
279+ $in = [
280+ 'displayname' => (string)($_POST['displayname'] ?? ''),
281+ 'gender' => (string)($_POST['gender'] ?? '-1'),
282+ 'namecolor' => isset($_POST['usecolor']) ? (string)($_POST['namecolor'] ?? '') : '',
283+ 'bio' => (string)($_POST['bio'] ?? ''),
284+ 'removeAvatar' => isset($_POST['removeAvatar']),
285+ ];
286+ if ($edit->isSelf) {
287+ foreach (['currentPassword', 'newPassword', 'newPasswordVerify', 'email'] as $k) $in[$k] = (string)($_POST[$k] ?? '');
288+ }
289+ if ($edit->isAdmin) $in['roles'] = array_map('intval', (array)($_POST['roles'] ?? []));
290+ $res = $auth->updateProfile($name, $in, $_FILES['avatar'] ?? null);
291+ if ($res->isSuccess) {
292+ // Fediverseの設定とプロフィールの配信
293+ if (ACTIVITYPUB_MULTIUSER) {
294+ $fresh = $res->data['user'];
295+ if (can_federate($fresh)) {
296+ Actor::saveSettings($name, ['locked' => isset($_POST['locked'])]);
297+ Social::sendProfileUpdate($name);
298+ }
299+ }
300+ $message = (string)$res->message;
301+ $edit = $auth->getEditableUser($name);
302+ } else {
303+ $error = (string)$res->message;
304+ }
305+ }
306+ }
307+
308+ $tmpl = new Template('/');
309+ $tmpl->assign('pagetit', 'プロフィールの編集');
310+ $tmpl->assign('curPage', 'auth');
311+ $tmpl->assign('custCss', false);
312+ $tmpl->assign('menu', $this->getMenu());
313+ $tmpl->assign('description', '');
314+ $tmpl->assign('user', $user);
315+ $tmpl->assign('e', $edit);
316+ $tmpl->assign('error', $error);
317+ $tmpl->assign('message', $message);
318+ $tmpl->assign('roles', Roles::LABELS);
319+ $tmpl->assign('fediEnabled', ACTIVITYPUB_MULTIUSER);
320+ $tmpl->assign('locked', ACTIVITYPUB_MULTIUSER && $edit->fedi ? (bool)Actor::settings($name)['locked'] : false);
321+ $tmpl->assign('csrf', make_csrf_token());
322+ $tmpl->render('editprofile');
323+ }
324+
249325 public function profile(array $params): void {
250326 if (!AUTH_ENABLED) return;
251327 $name = '';
@@ -276,6 +352,7 @@
276352 $tmpl->assign('description', $description);
277353 $tmpl->assign('user', $user);
278354 $tmpl->assign('u', $u);
355+ $tmpl->assign('name', $name);
279356
280357 $tmpl->render('profile');
281358 } catch (\Exception $e) {
@@ -461,27 +461,42 @@
461461 * @return bool 署名が正しく、署名者がactorと一致すればtrue
462462 */
463463 public function verifyInboxSignature(string $body, array $activity): bool {
464- if (!ACTIVITYPUB_ENABLED) return false;
464+ $actor = $activity['actor'] ?? null;
465+ if (is_array($actor)) $actor = $actor['id'] ?? null;
466+ if (!is_string($actor)) return false;
467+ return $this->signer($body) === $actor;
468+ }
465469
470+ /**
471+ * リクエストのHTTP署名を検証し、署名したアクターを返す
472+ * POSTはDigestも確認する。GET(フォロワー限定の投稿の取得等)はDigest無しで良い
473+ *
474+ * @param string $body リクエストの本文(GETは空)
475+ * @return string|null 鍵の持ち主のアクターのURL、検証出来なければnull
476+ */
477+ public function signer(string $body = ''): ?string {
478+ if (!ACTIVITYPUB_ENABLED) return null;
479+ $isPost = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST';
480+
466481 // Signature: keyId="…",algorithm="…",headers="…",signature="…"
467482 $sigHeader = $_SERVER['HTTP_SIGNATURE'] ?? '';
468- if ($sigHeader === '') return false;
483+ if ($sigHeader === '') return null;
469484 preg_match_all('/(\w+)="([^"]*)"/', $sigHeader, $m, PREG_SET_ORDER);
470485 $sig = [];
471486 foreach ($m as $kv) $sig[$kv[1]] = $kv[2];
472- if (empty($sig['keyId']) || empty($sig['signature'])) return false;
487+ if (empty($sig['keyId']) || empty($sig['signature'])) return null;
473488 if (isset($sig['algorithm'])
474- && !in_array(strtolower($sig['algorithm']), ['rsa-sha256', 'hs2019'], true)) return false;
489+ && !in_array(strtolower($sig['algorithm']), ['rsa-sha256', 'hs2019'], true)) return null;
475490
476491 // 最低限これらのヘッダーが署名されている事
477492 $signed = explode(' ', strtolower(trim($sig['headers'] ?? 'date')));
478- foreach (['(request-target)', 'host', 'date', 'digest'] as $req) {
479- if (!in_array($req, $signed, true)) return false;
493+ foreach ($isPost ? ['(request-target)', 'host', 'date', 'digest'] : ['(request-target)', 'host', 'date'] as $req) {
494+ if (!in_array($req, $signed, true)) return null;
480495 }
481496
482497 // 再送攻撃対策:Dateは前後5分以内
483498 $date = strtotime($_SERVER['HTTP_DATE'] ?? '');
484- if (!$date || abs(time() - $date) > 300) return false;
499+ if (!$date || abs(time() - $date) > 300) return null;
485500
486501 // Digestが本文と一致する事 / Digest must match the body
487502 $expected = base64_encode(hash('sha256', $body, true));
@@ -490,7 +505,7 @@
490505 $d = trim($d);
491506 if (stripos($d, 'SHA-256=') === 0 && hash_equals($expected, substr($d, 8))) $digestOk = true;
492507 }
493- if (!$digestOk) return false;
508+ if ($isPost && !$digestOk) return null;
494509
495510 // 署名対象の文字列を組み立てる
496511 $lines = [];
@@ -504,29 +519,29 @@
504519 'content-length' => 'CONTENT_LENGTH',
505520 default => 'HTTP_'.strtoupper(str_replace('-', '_', $h)),
506521 };
507- if (!isset($_SERVER[$key])) return false;
522+ if (!isset($_SERVER[$key])) return null;
508523 $lines[] = "{$h}: {$_SERVER[$key]}";
509524 }
510525
511- // 公開鍵を取得し、鍵の持ち主がアクティビティのactorと一致する事を確認する
512- $actor = $activity['actor'] ?? null;
513- if (is_array($actor)) $actor = $actor['id'] ?? null;
514- if (!is_string($actor)) return false;
526+ // 公開鍵を取得する(鍵の持ち主が署名したアクター)
515527
516528 $doc = $this->fetchRemoteJson(strtok($sig['keyId'], '#'));
517- if (!$doc) return false;
529+ if (!$doc) return null;
518530 $keys = $doc['publicKey'] ?? $doc;
519531 if (array_is_list($keys)) {
520532 $keys = array_values(array_filter($keys, fn($k) => is_array($k) && ($k['id'] ?? '') === $sig['keyId']))[0] ?? [];
521533 }
522- if (($keys['id'] ?? '') !== $sig['keyId']) return false;
523- if (($keys['owner'] ?? $doc['id'] ?? null) !== $actor) return false;
534+ if (($keys['id'] ?? '') !== $sig['keyId']) return null;
535+ $owner = $keys['owner'] ?? $doc['id'] ?? null;
536+ if (!is_string($owner)) return null;
537+ // 鍵の持ち主と鍵のURLは同じサーバーである事
538+ if (strtolower((string)parse_url($owner, PHP_URL_HOST)) !== strtolower((string)parse_url($sig['keyId'], PHP_URL_HOST))) return null;
524539
525540 $pub = openssl_pkey_get_public($keys['publicKeyPem'] ?? '');
526541 $raw = base64_decode($sig['signature'], true);
527- if (!$pub || $raw === false) return false;
542+ if (!$pub || $raw === false) return null;
528543
529- return openssl_verify(implode("\n", $lines), $raw, $pub, OPENSSL_ALGO_SHA256) === 1;
544+ return openssl_verify(implode("\n", $lines), $raw, $pub, OPENSSL_ALGO_SHA256) === 1 ? $owner : null;
530545 }
531546
532547 // 機能性メソッド
@@ -545,7 +560,7 @@
545560 * @param mixed $url 確認するURL
546561 * @return bool 安全ならtrue
547562 */
548- private function isSafeRemoteUrl(mixed $url): bool {
563+ public function isSafeRemoteUrl(mixed $url): bool {
549564 if (!is_string($url) || strlen($url) > 2048) return false;
550565 if (preg_match('/[\x00-\x20\x7f]/', $url)) return false;
551566
@@ -580,7 +595,7 @@
580595 * @param string $url 取得するURL
581596 * @return array|null デコード済みのJSON、失敗した場合はnull
582597 */
583- protected function fetchRemoteJson(string $url): ?array {
598+ public function fetchRemoteJson(string $url): ?array {
584599 if (!$this->isSafeRemoteUrl($url)) {
585600 logger(\LogType::ActivityPub, "安全ではないURLを拒否: {$url}");
586601 return null;
@@ -660,9 +675,10 @@
660675 * @param string $body 送信する本文(GETは空)
661676 * @param \OpenSSLAsymmetricKey $priv 秘密鍵
662677 * @param string $method post又はget
678+ * @param string|null $keyId 鍵のID(既定はブログのアクターの鍵。マルチユーザーではユーザーの鍵)
663679 * @return array|null 「名前: 値」のヘッダー、署名に失敗した場合はnull
664680 */
665- protected function signRequest(string $url, string $body, \OpenSSLAsymmetricKey $priv, string $method = 'post'): ?array {
681+ public function signRequest(string $url, string $body, \OpenSSLAsymmetricKey $priv, string $method = 'post', ?string $keyId = null): ?array {
666682 $p = parse_url($url);
667683 if (!$p || empty($p['host'])) return null;
668684 $method = strtolower($method);
@@ -688,7 +704,8 @@
688704 return null;
689705 }
690706
691- $headers['Signature'] = "keyId=\"{$this->domain}/ap/actor#main-key\","
707+ $keyId ??= "{$this->domain}/ap/actor#main-key";
708+ $headers['Signature'] = "keyId=\"{$keyId}\","
692709 .'algorithm="rsa-sha256",'
693710 .'headers="'.implode(' ', $signed).'",'
694711 .'signature="'.base64_encode($signature).'"';
@@ -88,6 +88,8 @@
8888 $isStaff = $myself && ($myself->role & (Roles::STAFF | Roles::ADMIN));
8989 if (!$myself || ($myself->id != $user->id && !$isStaff)) $user->email = '(秘密)';
9090 $user->canEdit = $myself && ($myself->id == $user->id || ($myself->role & Roles::ADMIN));
91+ $user->fediverse = can_federate($user) && defined('FEDIINFO')
92+ ? '@'.$user->username.'@'.parse_url(\Std\Lib\Fedi\Fedi::base(), PHP_URL_HOST) : '';
9193 $user->bioHtml = nl2br(htmlspecialchars((string)($user->bio ?? ''), ENT_QUOTES, 'UTF-8'), false);
9294 $user->avatar = avatar_url($user);
9395 $user->name = namecolor($user);
@@ -432,6 +434,7 @@
432434 $oldAvatar = (string)($user->avatar ?? '');
433435 $newAvatar = null;
434436 if ($avatar && ($avatar['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE) {
437+ $res = \Std\Lib\Fedi\Media::save($avatar, 'user/'.$user->username, 'avatar-', $this->checkUploads);
435438 if (!$res->isSuccess) $errors[] = 'エラー:'.$res->message;
436439 else $newAvatar = $res->data['name'];
437440 } else if (!empty($in['removeAvatar'])) {
@@ -496,6 +499,7 @@
496499 }
497500
498501 if (!empty($errors)) {
502+ if ($newAvatar !== null) \Std\Lib\Fedi\Media::remove('/public/static/user/'.$user->username.'/'.$newAvatar);
499503 return Result::Error(implode('<br />', array_map(fn($e) => htmlspecialchars($e, ENT_QUOTES, 'UTF-8'), $errors)));
500504 }
501505
@@ -539,6 +543,7 @@
539543 $user->isSelf = $me->id == $user->id;
540544 $user->isAdmin = (bool)($me->role & Roles::ADMIN);
541545 $user->colorHex = preg_match('/#[0-9a-fA-F]{6}/', (string)($user->namecolor ?? ''), $m) ? $m[0] : '';
546+ $user->fedi = can_federate($user);
542547 $user->avatarUrl = avatar_url($user);
543548 return $user;
544549 }
@@ -890,6 +895,10 @@
890895
891896 $res = $this->isUserExist($username);
892897 if ($res->isSuccess) return Result::Error($res->message);
898+ // ブログのアクターと同じ名前は使えない(WebFingerで区別出来ない)
899+ if (defined('FEDIINFO') && strcasecmp($username, (string)(FEDIINFO['actor'] ?? '')) === 0) {
900+ return Result::Error("ユーザー「{$username}」は既に存在します。");
901+ }
893902
894903 $accepted = 'A-Za-z0-9';
895904 if (preg_match("/[^{$accepted}]/", $username)) {
@@ -0,0 +1,268 @@
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+/**
41+ * ローカルのユーザーのアクター(鍵、プロフィール、WebFinger、コレクション)
42+ */
43+final class Actor {
44+ /**
45+ * ユーザーの秘密鍵(無ければ作る)
46+ *
47+ * @return \OpenSSLAsymmetricKey|null 作れない場合はnull(OpenSSLの設定が無い等)
48+ */
49+ public static function privateKey(string $username): ?\OpenSSLAsymmetricKey {
50+ $file = Fedi::userPath($username, 'private.pem');
51+ if (!is_file($file)) {
52+ $lock = fopen($file.'.lock', 'c');
53+ if ($lock) flock($lock, LOCK_EX);
54+ try {
55+ if (!is_file($file)) {
56+ $key = @openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]);
57+ if ($key === false || !openssl_pkey_export($key, $pem)) {
58+ logger(\LogType::ActivityPub, "「{$username}」の鍵を作れませんでした:".openssl_error_string());
59+ return null;
60+ }
61+ file_put_contents($file, $pem);
62+ @chmod($file, 0600);
63+ file_put_contents(Fedi::userPath($username, 'public.pem'), openssl_pkey_get_details($key)['key']);
64+ }
65+ } finally {
66+ if ($lock) {
67+ flock($lock, LOCK_UN);
68+ fclose($lock);
69+ }
70+ }
71+ }
72+ $key = openssl_pkey_get_private((string)file_get_contents($file));
73+ return $key === false ? null : $key;
74+ }
75+
76+ public static function publicKeyPem(string $username): ?string {
77+ $file = Fedi::userPath($username, 'public.pem');
78+ if (!is_file($file) && self::privateKey($username) === null) return null;
79+ return is_file($file) ? (string)file_get_contents($file) : null;
80+ }
81+
82+ public static function keyId(string $username): string {
83+ return Fedi::actorUrl($username).'#main-key';
84+ }
85+
86+ /** ユーザーの設定(locked:フォローを承認制にする) */
87+ public static function settings(string $username): array {
88+ return Fedi::read(Fedi::userPath($username, 'settings.json'), []) + ['locked' => false];
89+ }
90+
91+ public static function saveSettings(string $username, array $settings): void {
92+ Fedi::update(Fedi::userPath($username, 'settings.json'), fn($s) => array_merge($s, $settings));
93+ }
94+
95+ /**
96+ * アクターのJSON-LD
97+ *
98+ * @param \stdClass $user ローカルのユーザー(Fedi::localActor())
99+ * @return array
100+ */
101+ public static function document(\stdClass $user): array {
102+ $url = Fedi::actorUrl($user->username);
103+ $avatar = avatar_url($user);
104+ $doc = [
105+ '@context' => Fedi::CONTEXT,
106+ 'id' => $url,
107+ 'type' => 'Person',
108+ 'preferredUsername' => $user->username,
109+ 'name' => ($user->displayname ?? '') !== '' ? $user->displayname : $user->username,
110+ 'summary' => ($user->bio ?? '') !== '' ? Html::render($user->bio) : '',
111+ 'url' => Fedi::base().'/microblog/user/'.$user->username,
112+ 'inbox' => "{$url}/inbox",
113+ 'outbox' => "{$url}/outbox",
114+ 'followers' => "{$url}/followers",
115+ 'following' => "{$url}/following",
116+ 'endpoints' => ['sharedInbox' => Fedi::sharedInbox()],
117+ 'manuallyApprovesFollowers' => (bool)self::settings($user->username)['locked'],
118+ 'discoverable' => true,
119+ 'published' => Fedi::now((int)($user->regDate ?? time())),
120+ 'icon' => [
121+ 'type' => 'Image',
122+ 'mediaType' => self::mediaTypeOf($avatar),
123+ 'url' => Fedi::base().$avatar,
124+ ],
125+ ];
126+ $pem = self::publicKeyPem($user->username);
127+ if ($pem !== null) $doc['publicKey'] = ['id' => self::keyId($user->username), 'owner' => $url, 'publicKeyPem' => $pem];
128+ return $doc;
129+ }
130+
131+ /**
132+ * WebFingerの応答
133+ *
134+ * @param string $resource acct:ユーザー@ホスト又はアクターのURL
135+ * @return array|null 見つからなければnull
136+ */
137+ public static function webfinger(string $resource): ?array {
138+ $username = null;
139+ if (preg_match('/^(?:acct:)?@?([A-Za-z0-9]+)@(.+)$/', $resource, $m) && strtolower($m[2]) === strtolower(Fedi::host())) {
140+ $username = $m[1];
141+ } else {
142+ $username = Fedi::localUsername($resource);
143+ }
144+ if ($username === null || Fedi::localActor($username) === null) return null;
145+
146+ return [
147+ 'subject' => "acct:{$username}@".Fedi::host(),
148+ 'aliases' => [Fedi::actorUrl($username), Fedi::base().'/microblog/user/'.$username],
149+ 'links' => [
150+ ['rel' => 'self', 'type' => 'application/activity+json', 'href' => Fedi::actorUrl($username)],
151+ ['rel' => 'http://webfinger.net/rel/profile-page', 'type' => 'text/html', 'href' => Fedi::base().'/microblog/user/'.$username],
152+ ],
153+ ];
154+ }
155+
156+ //////// フォロワーとフォロー
157+
158+ /** フォロワー(アクターのURL => [inbox, sharedInbox, since]) */
159+ public static function followers(string $username): array {
160+ return Fedi::read(Fedi::userPath($username, 'followers.json'));
161+ }
162+
163+ /** フォロー(アクターのURL => [state(pending/accepted), id, since]) */
164+ public static function following(string $username): array {
165+ return Fedi::read(Fedi::userPath($username, 'following.json'));
166+ }
167+
168+ /** 承認待ちのフォローリクエスト(アクターのURL => [activity, at]) */
169+ public static function requests(string $username): array {
170+ return Fedi::read(Fedi::userPath($username, 'requests.json'));
171+ }
172+
173+ public static function isFollowing(string $username, string $actor): bool {
174+ return (self::following($username)[$actor]['state'] ?? '') === 'accepted';
175+ }
176+
177+ public static function isFollowedBy(string $username, string $actor): bool {
178+ return isset(self::followers($username)[$actor]);
179+ }
180+
181+ /**
182+ * フォロワー・フォローのOrderedCollection(一覧は公開しない。数だけ)
183+ */
184+ public static function collection(string $username, string $which): array {
185+ $list = $which === 'followers' ? self::followers($username)
186+ : array_filter(self::following($username), fn($f) => ($f['state'] ?? '') === 'accepted');
187+ return [
188+ '@context' => 'https://www.w3.org/ns/activitystreams',
189+ 'id' => Fedi::actorUrl($username)."/{$which}",
190+ 'type' => 'OrderedCollection',
191+ 'totalItems' => count($list),
192+ ];
193+ }
194+
195+ //////// ブロック
196+
197+ /** ブロック(actors:アクターのURL、domains:ドメイン) */
198+ public static function blocks(string $username): array {
199+ return Fedi::read(Fedi::userPath($username, 'blocks.json')) + ['actors' => [], 'domains' => []];
200+ }
201+
202+ /**
203+ * このユーザー(又はサイト全体)がブロックしているか
204+ */
205+ public static function isBlocked(string $username, string $actor): bool {
206+ if (self::isDomainBlocked($actor)) return true;
207+ $b = self::blocks($username);
208+ return in_array($actor, $b['actors'], true) || in_array(Fedi::domainOf($actor), $b['domains'], true);
209+ }
210+
211+ /** サイト全体でブロックしているドメイン(管理者) */
212+ public static function blockedDomains(): array {
213+ return Fedi::read(Fedi::path('blocked_domains.json'));
214+ }
215+
216+ public static function isDomainBlocked(string $url): bool {
217+ $d = Fedi::domainOf($url);
218+ if ($d === '') return false;
219+ foreach (self::blockedDomains() as $b) {
220+ if ($d === $b || str_ends_with($d, '.'.$b)) return true;
221+ }
222+ return false;
223+ }
224+
225+ //////// 通知
226+
227+ /**
228+ * 通知を追加する(新しい順、200件まで)
229+ *
230+ * @param string $username ユーザー
231+ * @param string $type follow, follow_request, mention, reply, like, boost
232+ * @param string $actor 誰から
233+ * @param string|null $object 対象の投稿
234+ */
235+ public static function notify(string $username, string $type, string $actor, ?string $object = null): void {
236+ Fedi::update(Fedi::userPath($username, 'notifications.json'), function(array $list) use ($type, $actor, $object): array {
237+ // 同じ通知を重複させない
238+ foreach ($list as $n) {
239+ if ($n['type'] === $type && $n['actor'] === $actor && ($n['object'] ?? null) === $object && $n['at'] > time() - 3600) return $list;
240+ }
241+ array_unshift($list, ['type' => $type, 'actor' => $actor, 'object' => $object, 'at' => time(), 'read' => false]);
242+ return array_slice($list, 0, 200);
243+ });
244+ }
245+
246+ public static function notifications(string $username): array {
247+ return Fedi::read(Fedi::userPath($username, 'notifications.json'));
248+ }
249+
250+ public static function unreadCount(string $username): int {
251+ return count(array_filter(self::notifications($username), fn($n) => !$n['read']));
252+ }
253+
254+ public static function markRead(string $username): void {
255+ Fedi::update(Fedi::userPath($username, 'notifications.json'), fn($l) => array_map(fn($n) => ['read' => true] + $n, $l));
256+ }
257+
258+ //////
259+
260+ private static function mediaTypeOf(string $path): string {
261+ return match (strtolower(pathinfo($path, PATHINFO_EXTENSION))) {
262+ 'png' => 'image/png',
263+ 'gif' => 'image/gif',
264+ 'webp' => 'image/webp',
265+ default => 'image/jpeg',
266+ };
267+ }
268+}
@@ -0,0 +1,200 @@
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\Activitypub;
41+use Std\Lib\Curl;
42+
43+/**
44+ * 配信の待ち行列(data/ap/queue/)
45+ *
46+ * 送るアクティビティは一旦ファイルにして、cronのapdeliverが毎分送る(Webのリクエストを待たせない為)。
47+ * 失敗したら間隔を空けて再試行し、MAX_TRIES回で諦める。
48+ * 宛先がこのサイトなら、HTTPを使わずに直接受付ボックスの処理を呼ぶ。
49+ */
50+class Delivery {
51+ public const MAX_TRIES = 8;
52+ /** 再試行までの秒数(回数毎) */
53+ private const BACKOFF = [60, 300, 1800, 7200, 21600, 43200, 86400, 86400];
54+
55+ /**
56+ * アクティビティを送る(ローカルの宛先はすぐに処理し、リモートは待ち行列に入れる)
57+ *
58+ * @param string $from 送るユーザー
59+ * @param array $activity アクティビティ
60+ * @param array $inboxes 受付ボックスのURL
61+ * @return void
62+ */
63+ public static function enqueue(string $from, array $activity, array $inboxes): void {
64+ $inboxes = array_values(array_unique(array_filter($inboxes, 'is_string')));
65+ $local = false;
66+ foreach ($inboxes as $inbox) {
67+ if (Fedi::isLocal($inbox)) {
68+ $local = true;
69+ continue;
70+ }
71+ if (Actor::isDomainBlocked($inbox)) continue;
72+ $job = ['from' => $from, 'inbox' => $inbox, 'activity' => $activity, 'tries' => 0, 'next' => time()];
73+ Fedi::write(Fedi::path('queue/'.Fedi::newId().'.json'), $job);
74+ }
75+ // ローカルの宛先は1回だけ処理する(共有の受付ボックスなので、受け取る全員に届く)
76+ if ($local) Inbox::handle($activity, true);
77+ }
78+
79+ /**
80+ * アクティビティの宛先の受付ボックス(to・cc・フォロワー)
81+ *
82+ * @param string $from 送るユーザー
83+ * @param array $activity アクティビティ
84+ * @param array $extra 他に送るアクター(返信先の投稿者等)
85+ * @return array 受付ボックス(sharedInboxがあればそちら、重複なし)
86+ */
87+ public static function audience(string $from, array $activity, array $extra = []): array {
88+ $obj = is_array($activity['object'] ?? null) ? $activity['object'] : [];
89+ $targets = array_merge(
90+ Fedi::ids($activity['to'] ?? []), Fedi::ids($activity['cc'] ?? []),
91+ Fedi::ids($obj['to'] ?? []), Fedi::ids($obj['cc'] ?? []), $extra
92+ );
93+ $followersUrl = Fedi::actorUrl($from).'/followers';
94+ $inboxes = [];
95+ foreach (array_unique($targets) as $t) {
96+ if ($t === Fedi::PUBLIC || $t === Fedi::actorUrl($from)) continue;
97+ if ($t === $followersUrl) {
98+ foreach (Actor::followers($from) as $actor => $f) {
99+ $inbox = $f['sharedInbox'] ?? $f['inbox'] ?? null;
100+ if ($inbox) $inboxes[] = $inbox;
101+ }
102+ continue;
103+ }
104+ if (str_ends_with($t, '/followers')) continue; // 他人のフォロワーには送れない
105+ $doc = Remote::actor($t);
106+ if (!$doc) continue;
107+ $inbox = Remote::inbox($doc);
108+ if ($inbox) $inboxes[] = $inbox;
109+ }
110+ return array_values(array_unique($inboxes));
111+ }
112+
113+ /**
114+ * 送る時刻になったアクティビティを送る(cronのapdeliver)
115+ *
116+ * @param int $budget この秒数を超えたら次の実行に回す
117+ * @return \Result data:sent, failed, dropped
118+ */
119+ public static function run(int $budget = 50): \Result {
120+ if (!Fedi::enabled()) return \Result::Error('マルチユーザーのFediverseは無効です。');
121+ $lock = fopen(Fedi::path('queue.lock'), 'c');
122+ if (!$lock || !flock($lock, LOCK_EX | LOCK_NB)) return \Result::Success('前の配信がまだ終わっていません。');
123+
124+ $start = time();
125+ $sent = $failed = $dropped = 0;
126+ try {
127+ $files = glob(Fedi::path('queue/').'*.json') ?: [];
128+ sort($files);
129+ foreach ($files as $file) {
130+ if (time() - $start > $budget) break;
131+ $job = Fedi::read($file, null);
132+ if (!$job) {
133+ @unlink($file);
134+ continue;
135+ }
136+ if (($job['next'] ?? 0) > time()) continue;
137+
138+ if (static::send($job['from'], $job['inbox'], $job['activity'])) {
139+ @unlink($file);
140+ $sent++;
141+ continue;
142+ }
143+ $job['tries']++;
144+ if ($job['tries'] >= self::MAX_TRIES) {
145+ logger(\LogType::ActivityPub, "配信を諦めました:{$job['activity']['type']} → {$job['inbox']}");
146+ @unlink($file);
147+ $dropped++;
148+ continue;
149+ }
150+ $job['next'] = time() + self::BACKOFF[$job['tries'] - 1];
151+ Fedi::write($file, $job);
152+ $failed++;
153+ }
154+ } finally {
155+ flock($lock, LOCK_UN);
156+ fclose($lock);
157+ }
158+
159+ $data = ['sent' => $sent, 'failed' => $failed, 'dropped' => $dropped];
160+ return \Result::Success("配信:成功{$sent}件、失敗{$failed}件、諦めた{$dropped}件", $data);
161+ }
162+
163+ /** 待ち行列の件数 */
164+ public static function pending(): int {
165+ return count(glob(Fedi::path('queue/').'*.json') ?: []);
166+ }
167+
168+ /**
169+ * 署名して送る
170+ *
171+ * @param string $from 送るユーザー(空ならブログのアクター)
172+ * @param string $inbox 受付ボックス
173+ * @param array $activity アクティビティ
174+ * @return bool 2xxならtrue
175+ */
176+ protected static function send(string $from, string $inbox, array $activity): bool {
177+ if (!Remote::isSafeUrl($inbox)) return false;
178+ $key = Actor::privateKey($from);
179+ if ($key === null) return false;
180+
181+ $body = json_encode($activity, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
182+ $headers = (new Activitypub())->signRequest($inbox, $body, $key, 'post', Actor::keyId($from));
183+ if ($headers === null) return false;
184+
185+ $curl = new Curl($inbox);
186+ $curl->setMethod('POST')
187+ ->setPostRaw($body)
188+ ->setHeaders($headers)
189+ ->setFollowRedirects(false)
190+ ->setTimeout(15)
191+ ->setCaInfo('/etc/ssl/cert.pem');
192+ $res = $curl->execute();
193+ $code = $curl->getResponseCode();
194+ $ok = $res->isSuccess && $code >= 200 && $code < 300;
195+ // 410(もう無い)は再試行しても無駄
196+ if ($code === 410) return true;
197+ if (!$ok) logger(\LogType::ActivityPub, "{$activity['type']}を「{$inbox}」に送れません:HTTP {$code} {$curl->getError()}");
198+ return $ok;
199+ }
200+}
@@ -0,0 +1,296 @@
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 Roles;
41+
42+/**
43+ * マルチユーザーのActivityPub(Fediverse)の共通の設定と保存
44+ *
45+ * FEDIINFOの「isSingleUser」をfalseにすると有効になる(ACTIVITYPUB_MULTIUSER)。
46+ * ブログのアクター(/ap/actor)は今まで通りブログの投稿を配信し、
47+ * Fediverseのロールを持つユーザーはそれぞれ自分のアクター(/ap/users/{名前})になる。
48+ *
49+ * データは全部data/ap/の下のJSONファイル:
50+ * users/{名前}/ 鍵、フォロワー、フォロー、通知等
51+ * notes/{ID}.json ローカルの投稿
52+ * remote/objects/ 受け取ったリモートの投稿
53+ * remote/actors/ リモートのアクターのキャッシュ
54+ * queue/ 配信待ちのアクティビティ(cronのapdeliverが送る)
55+ * timeline.jsonl タイムラインの索引
56+ */
57+final class Fedi {
58+ public const PUBLIC = 'https://www.w3.org/ns/activitystreams#Public';
59+ public const CONTEXT = [
60+ 'https://www.w3.org/ns/activitystreams',
61+ 'https://w3id.org/security/v1',
62+ [
63+ 'Hashtag' => 'as:Hashtag',
64+ 'sensitive' => 'as:sensitive',
65+ 'manuallyApprovesFollowers' => 'as:manuallyApprovesFollowers',
66+ 'toot' => 'http://joinmastodon.org/ns#',
67+ 'discoverable' => 'toot:discoverable',
68+ ],
69+ ];
70+
71+ /** 投稿の最大文字数 */
72+ public const MAX_LENGTH = 5000;
73+ /** 1つの投稿の最大の添付数 */
74+ public const MAX_ATTACHMENTS = 4;
75+
76+ /** @var string|null 保存先(テスト用。既定はdata/ap) */
77+ public static ?string $dataDir = null;
78+ /** @var string|null ユーザーデータ(テスト用。既定はdata/user) */
79+ public static ?string $userDir = null;
80+ /** @var string|null サイトのURL(テスト用。既定はFEDIINFOのdomain又はHTTP_HOST) */
81+ public static ?string $baseUrl = null;
82+
83+ /**
84+ * マルチユーザーモードか
85+ */
86+ public static function enabled(): bool {
87+ return ACTIVITYPUB_MULTIUSER;
88+ }
89+
90+ /**
91+ * サイトのURL(末尾の「/」なし)
92+ */
93+ public static function base(): string {
94+ if (self::$baseUrl !== null) return self::$baseUrl;
95+ $domain = rtrim((string)(FEDIINFO['domain'] ?? ''), '/');
96+ if ($domain !== '') return $domain;
97+ $https = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';
98+ return ($https ? 'https' : 'http').'://'.($_SERVER['HTTP_HOST'] ?? '127.0.0.1:8000');
99+ }
100+
101+ public static function host(): string {
102+ return (string)parse_url(self::base(), PHP_URL_HOST);
103+ }
104+
105+ public static function actorUrl(string $username): string {
106+ return self::base().'/ap/users/'.$username;
107+ }
108+
109+ public static function noteUrl(string $username, string $id): string {
110+ return self::actorUrl($username).'/notes/'.$id;
111+ }
112+
113+ /** ブログのアクター */
114+ public static function blogActorUrl(): string {
115+ return self::base().'/ap/actor';
116+ }
117+
118+ public static function sharedInbox(): string {
119+ return self::base().'/ap/inbox';
120+ }
121+
122+ /** このサイトのURLか */
123+ public static function isLocal(string $url): bool {
124+ $base = self::base();
125+ return $url === $base || str_starts_with($url, $base.'/');
126+ }
127+
128+ /**
129+ * ローカルのアクターのURLからユーザー名を取り出す
130+ *
131+ * @return string|null ローカルのユーザーでなければnull
132+ */
133+ public static function localUsername(string $actorUrl): ?string {
134+ $prefix = self::base().'/ap/users/';
135+ if (!str_starts_with($actorUrl, $prefix)) return null;
136+ $name = substr($actorUrl, strlen($prefix));
137+ return preg_match('/^[A-Za-z0-9]+$/', $name) ? $name : null;
138+ }
139+
140+ /**
141+ * ローカルの投稿のURLからIDを取り出す
142+ *
143+ * @return array|null [ユーザー名, ID]
144+ */
145+ public static function localNoteId(string $url): ?array {
146+ if (!preg_match('#^'.preg_quote(self::base().'/ap/users/', '#').'([A-Za-z0-9]+)/notes/([0-9a-f]{20})$#', $url, $m)) return null;
147+ return [$m[1], $m[2]];
148+ }
149+
150+ /** URLのホスト名(小文字) */
151+ public static function domainOf(string $url): string {
152+ return strtolower((string)parse_url($url, PHP_URL_HOST));
153+ }
154+
155+ //////// ユーザー
156+
157+ /**
158+ * ローカルのユーザーのデータ(パスワード等を除く)
159+ *
160+ * @param string $username ユーザー名
161+ * @return \stdClass|null
162+ */
163+ public static function localUser(string $username): ?\stdClass {
164+ if (!preg_match('/^[A-Za-z0-9]+$/', $username)) return null;
165+ $dir = self::$userDir ?? ROOT.'/data/user/';
166+ foreach (glob($dir.'*.'.$username.'.json') ?: [] as $f) {
167+ if (!preg_match('/^\d+\.'.$username.'\.json$/', basename($f))) continue;
168+ $u = json_decode((string)file_get_contents($f));
169+ if (!$u instanceof \stdClass || ($u->username ?? '') !== $username) continue;
170+ unset($u->password, $u->tokens, $u->verifyToken);
171+ return $u;
172+ }
173+ return null;
174+ }
175+
176+ /**
177+ * Fediverseのアクターとして使えるローカルのユーザー(ロールがあり、メール確認済み)
178+ */
179+ public static function localActor(string $username): ?\stdClass {
180+ $u = self::localUser($username);
181+ if (!$u || !can_federate($u) || (($u->verified ?? true) === false)) return null;
182+ return $u;
183+ }
184+
185+ //////// ファイル
186+
187+ /**
188+ * data/ap/の下のパス(ディレクトリは作る)
189+ */
190+ public static function path(string $rel): string {
191+ $base = rtrim(self::$dataDir ?? ROOT.'/data/ap', '/');
192+ $path = $base.'/'.ltrim($rel, '/');
193+ $dir = dirname($path);
194+ if (!is_dir($dir)) @mkdir($dir, 0700, true);
195+ return $path;
196+ }
197+
198+ public static function userPath(string $username, string $file): string {
199+ return self::path("users/{$username}/{$file}");
200+ }
201+
202+ /**
203+ * JSONファイルを読む
204+ */
205+ public static function read(string $path, mixed $default = []): mixed {
206+ if (!is_file($path)) return $default;
207+ $data = json_decode((string)file_get_contents($path), true);
208+ return $data ?? $default;
209+ }
210+
211+ /**
212+ * JSONファイルを書く(一時ファイルから名前を変えるので、途中で読まれても壊れない)
213+ */
214+ public static function write(string $path, mixed $data): bool {
215+ $tmp = $path.'.'.bin2hex(random_bytes(4)).'.tmp';
216+ $json = json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
217+ if ($json === false || file_put_contents($tmp, $json) === false) return false;
218+ if (!rename($tmp, $path)) {
219+ @unlink($tmp);
220+ return false;
221+ }
222+ return true;
223+ }
224+
225+ /**
226+ * JSONファイルをロックして読み書きする
227+ *
228+ * @param string $path ファイル
229+ * @param callable $fn データを受け取り、新しいデータを返す
230+ * @param mixed $default ファイルが無い場合のデータ
231+ * @return mixed 新しいデータ
232+ */
233+ public static function update(string $path, callable $fn, mixed $default = []): mixed {
234+ $lock = fopen($path.'.lock', 'c');
235+ if ($lock) flock($lock, LOCK_EX);
236+ try {
237+ $new = $fn(self::read($path, $default));
238+ self::write($path, $new);
239+ return $new;
240+ } finally {
241+ if ($lock) {
242+ flock($lock, LOCK_UN);
243+ fclose($lock);
244+ }
245+ }
246+ }
247+
248+ /**
249+ * 時刻順に並ぶID(20文字の16進数)
250+ */
251+ public static function newId(): string {
252+ return sprintf('%012x', (int)(microtime(true) * 1000)).bin2hex(random_bytes(4));
253+ }
254+
255+ /** ISO 8601(UTC) */
256+ public static function now(?int $t = null): string {
257+ return gmdate('Y-m-d\TH:i:s\Z', $t ?? time());
258+ }
259+
260+ /**
261+ * 宛先からの公開範囲
262+ *
263+ * @param array $to to
264+ * @param array $cc cc
265+ * @param string|null $followers 投稿者のフォロワーのコレクション
266+ * @return string public, unlisted, followers, direct
267+ */
268+ public static function visibility(array $to, array $cc, ?string $followers = null): string {
269+ if (in_array(self::PUBLIC, $to, true) || in_array('as:Public', $to, true) || in_array('Public', $to, true)) return 'public';
270+ if (in_array(self::PUBLIC, $cc, true) || in_array('as:Public', $cc, true) || in_array('Public', $cc, true)) return 'unlisted';
271+ $all = array_merge($to, $cc);
272+ if ($followers !== null && in_array($followers, $all, true)) return 'followers';
273+ foreach ($all as $a) if (is_string($a) && str_ends_with($a, '/followers')) return 'followers';
274+ return 'direct';
275+ }
276+
277+ /**
278+ * IDの配列にする(文字列、オブジェクト、配列のどれでも)
279+ */
280+ public static function ids(mixed $v): array {
281+ if ($v === null) return [];
282+ if (is_string($v)) return [$v];
283+ if (is_array($v) && isset($v['id']) && is_string($v['id'])) return [$v['id']];
284+ if (is_array($v) && array_is_list($v)) {
285+ $out = [];
286+ foreach ($v as $x) $out = array_merge($out, self::ids($x));
287+ return $out;
288+ }
289+ return [];
290+ }
291+
292+ /** オブジェクトのID(文字列かオブジェクト) */
293+ public static function id(mixed $v): ?string {
294+ return self::ids($v)[0] ?? null;
295+ }
296+}
@@ -0,0 +1,215 @@
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+/**
41+ * 投稿の本文のHTML
42+ *
43+ * render():ローカルの投稿(プレーンテキスト)をHTMLにする(リンク、メンション、ハッシュタグ)
44+ * sanitize():リモートの投稿のHTMLを許可したタグだけにする(XSS対策)
45+ */
46+final class Html {
47+ /** タグ => 許可する属性 */
48+ private const ALLOWED = [
49+ 'p' => [], 'br' => [], 'a' => ['href', 'class', 'rel'], 'span' => ['class'],
50+ 'strong' => [], 'b' => [], 'em' => [], 'i' => [], 'u' => [], 's' => [], 'del' => [],
51+ 'code' => [], 'pre' => [], 'blockquote' => [], 'ul' => [], 'ol' => [], 'li' => [],
52+ ];
53+ /** 中身ごと消すタグ */
54+ private const DROP = ['script', 'style', 'iframe', 'object', 'embed', 'template', 'svg', 'math', 'form', 'textarea', 'select', 'button', 'noscript'];
55+ /** 許可するクラス(Mastodonのメンション等) */
56+ private const CLASSES = ['mention', 'hashtag', 'h-card', 'u-url', 'invisible', 'ellipsis'];
57+
58+ // 区切り文字なしの正規表現(組み合わせて使う)
59+ private const URL = '\bhttps?://[^\s<>"\' ]+';
60+ private const MENTION = '(?<![\w@/])@([A-Za-z0-9_]+)(?:@([A-Za-z0-9.-]+\.[A-Za-z]{2,}(?::\d+)?))?(?![@\w])';
61+ private const TAG = '(?<![\w#&/])#([\p{L}\p{N}_]{1,64})';
62+ private const URL_RE = '~'.self::URL.'~u';
63+ public const MENTION_RE = '~'.self::MENTION.'~u';
64+ public const TAG_RE = '~'.self::TAG.'~u';
65+
66+ /**
67+ * プレーンテキストからメンションを取り出す
68+ *
69+ * @return array [「@user」又は「@user@domain」 => [user, domain|null]]
70+ */
71+ public static function mentions(string $text): array {
72+ $text = preg_replace(self::URL_RE, ' ', $text);
73+ preg_match_all(self::MENTION_RE, $text, $m, PREG_SET_ORDER);
74+ $out = [];
75+ foreach ($m as $x) $out[$x[0]] = [$x[1], isset($x[2]) && $x[2] !== '' ? strtolower($x[2]) : null];
76+ return $out;
77+ }
78+
79+ /**
80+ * プレーンテキストからハッシュタグを取り出す
81+ *
82+ * @return array タグ(「#」なし、小文字、重複なし)
83+ */
84+ public static function hashtags(string $text): array {
85+ $text = preg_replace(self::URL_RE, ' ', $text);
86+ preg_match_all(self::TAG_RE, $text, $m);
87+ return array_values(array_unique(array_map(fn($t) => mb_strtolower($t), $m[1])));
88+ }
89+
90+ /**
91+ * プレーンテキストをHTMLにする
92+ *
93+ * @param string $text 本文
94+ * @param array $mentionUrls 「@user@domain」 => アクターのURL(解決出来たメンションだけリンクにする)
95+ * @return string HTML
96+ */
97+ public static function render(string $text, array $mentionUrls = []): string {
98+ $text = str_replace(["\r\n", "\r"], "\n", trim($text));
99+ $base = Fedi::base();
100+
101+ // URL・メンション・ハッシュタグを1回で置き換える(URLの中の「#」や「@」を変換しない様に)
102+ // グループ:1=URL、2=ユーザー、3=ドメイン、4=タグ
103+ $re = '~('.self::URL.')|'.self::MENTION.'|'.self::TAG.'~u';
104+ $out = '';
105+ $pos = 0;
106+ preg_match_all($re, $text, $ms, PREG_SET_ORDER | PREG_OFFSET_CAPTURE);
107+ foreach ($ms as $m) {
108+ [$whole, $at] = $m[0];
109+ $out .= self::esc(substr($text, $pos, $at - $pos));
110+ $pos = $at + strlen($whole);
111+
112+ if (($m[1][0] ?? '') !== '') {
113+ // 文末の句読点はURLに含めない
114+ $url = rtrim($whole, '.,!?)。、」』');
115+ $pos -= strlen($whole) - strlen($url);
116+ $shown = preg_replace('#^https?://#', '', $url);
117+ if (mb_strlen($shown) > 40) $shown = mb_substr($shown, 0, 40).'…';
118+ $out .= '<a href="'.self::esc($url).'" rel="nofollow noopener noreferrer">'.self::esc($shown).'</a>';
119+ } else if (($m[2][0] ?? '') !== '') {
120+ $key = $whole;
121+ $url = $mentionUrls[$key] ?? $mentionUrls[strtolower($key)] ?? null;
122+ if ($url === null) {
123+ $out .= self::esc($whole);
124+ continue;
125+ }
126+ $out .= '<span class="h-card"><a href="'.self::esc($url).'" class="u-url mention">@<span>'.self::esc($m[2][0]).'</span></a></span>';
127+ } else {
128+ $tag = $m[4][0];
129+ $out .= '<a href="'.self::esc($base.'/microblog/tag/'.rawurlencode(mb_strtolower($tag))).'" class="mention hashtag" rel="tag">#<span>'.self::esc($tag).'</span></a>';
130+ }
131+ }
132+ $out .= self::esc(substr($text, $pos));
133+
134+ // 空行で段落、改行で<br>
135+ $paras = preg_split('/\n{2,}/', $out);
136+ return implode('', array_map(fn($p) => '<p>'.str_replace("\n", '<br />', $p).'</p>', $paras));
137+ }
138+
139+ /**
140+ * リモートのHTMLを安全にする
141+ *
142+ * @param string $html HTML
143+ * @return string 許可したタグと属性だけのHTML
144+ */
145+ public static function sanitize(string $html): string {
146+ if ($html === '') return '';
147+ if (!class_exists(\DOMDocument::class)) {
148+ // DOMが無い環境:タグを全部外す
149+ return '<p>'.nl2br(self::esc(html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5, 'UTF-8')), false).'</p>';
150+ }
151+
152+ $doc = new \DOMDocument();
153+ $prev = libxml_use_internal_errors(true);
154+ $doc->loadHTML('<?xml encoding="utf-8"?><div id="root">'.$html.'</div>', LIBXML_NONET | LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
155+ libxml_clear_errors();
156+ libxml_use_internal_errors($prev);
157+
158+ $root = $doc->getElementById('root') ?? $doc->documentElement;
159+ if (!$root) return '';
160+ $out = '';
161+ foreach ($root->childNodes as $child) $out .= self::clean($child);
162+ return $out;
163+ }
164+
165+ /**
166+ * HTMLからタグを外した文(検索や通知用)
167+ */
168+ public static function text(string $html, int $max = 0): string {
169+ $html = preg_replace('#<br\s*/?>|</p>#i', "\n", $html);
170+ $t = trim(html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5, 'UTF-8'));
171+ if ($max > 0 && mb_strlen($t) > $max) $t = mb_substr($t, 0, $max).'…';
172+ return $t;
173+ }
174+
175+ public static function esc(string $s): string {
176+ return htmlspecialchars($s, ENT_QUOTES | ENT_HTML5, 'UTF-8');
177+ }
178+
179+ ///////
180+
181+ private static function clean(\DOMNode $node): string {
182+ if ($node instanceof \DOMText) return self::esc($node->nodeValue ?? '');
183+ if (!$node instanceof \DOMElement) return '';
184+
185+ $tag = strtolower($node->nodeName);
186+ if (in_array($tag, self::DROP, true)) return '';
187+
188+ $inner = '';
189+ foreach ($node->childNodes as $c) $inner .= self::clean($c);
190+
191+ // 許可しないタグは中身だけ残す(見出し等は段落にする)
192+ if (!isset(self::ALLOWED[$tag])) {
193+ return in_array($tag, ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'div'], true) ? "<p>{$inner}</p>" : $inner;
194+ }
195+ if ($tag === 'br') return '<br />';
196+
197+ $attrs = '';
198+ foreach (self::ALLOWED[$tag] as $name) {
199+ if (!$node->hasAttribute($name)) continue;
200+ $v = trim($node->getAttribute($name));
201+ if ($name === 'href') {
202+ if (!preg_match('#^https?://#i', $v)) continue;
203+ } else if ($name === 'class') {
204+ $v = implode(' ', array_intersect(preg_split('/\s+/', $v), self::CLASSES));
205+ if ($v === '') continue;
206+ } else if ($name === 'rel') {
207+ continue; // 下で付ける
208+ }
209+ $attrs .= ' '.$name.'="'.self::esc($v).'"';
210+ }
211+ if ($tag === 'a') $attrs .= ' rel="nofollow noopener noreferrer"';
212+
213+ return "<{$tag}{$attrs}>{$inner}</{$tag}>";
214+ }
215+}
@@ -0,0 +1,358 @@
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\Activitypub;
41+
42+/**
43+ * 受付ボックス(共有の/ap/inboxとユーザー毎の/ap/users/{名前}/inbox)に届いたアクティビティの処理
44+ *
45+ * 署名の検証は呼ぶ側(Fediverseコントローラー)で行う。
46+ * ローカルのユーザーからのアクティビティ(Delivery::enqueue)も、HTTPを使わずにここで処理する。
47+ */
48+final class Inbox {
49+ /**
50+ * アクティビティを処理する
51+ *
52+ * @param array $activity アクティビティ(署名検証済み)
53+ * @param bool $local このサイトのユーザーからか
54+ * @return array [HTTPステータス, レスポンスの本文]
55+ */
56+ public static function handle(array $activity, bool $local = false): array {
57+ $actor = Fedi::id($activity['actor'] ?? null);
58+ $type = (string)($activity['type'] ?? '');
59+ if ($actor === null) return [400, ['error' => 'actorがありません']];
60+ // ローカルのアクターを名乗るリモートのアクティビティは受け付けない
61+ if (!$local && Fedi::isLocal($actor)) return [403, ['error' => '不正なactor']];
62+ if (Actor::isDomainBlocked($actor)) return [403, ['error' => 'ブロックされています']];
63+
64+ try {
65+ return match ($type) {
66+ 'Follow' => self::follow($activity, $actor),
67+ 'Accept' => self::accept($activity, $actor, true),
68+ 'Reject' => self::accept($activity, $actor, false),
69+ 'Undo' => self::undo($activity, $actor),
70+ 'Create' => self::create($activity, $actor, $local),
71+ 'Update' => self::update($activity, $actor),
72+ 'Delete' => self::delete($activity, $actor),
73+ 'Like' => self::like($activity, $actor),
74+ 'Announce' => self::announce($activity, $actor, $local),
75+ 'Block' => self::block($activity, $actor),
76+ 'Flag' => self::flag($activity, $actor),
77+ default => [202, ['status' => '未対応なアクティビティタイプ: '.$type]],
78+ };
79+ } catch (\Throwable $e) {
80+ logger(\LogType::ActivityPub, "受付ボックスの処理に失敗({$type}):".$e->getMessage());
81+ return [500, ['error' => '処理に失敗']];
82+ }
83+ }
84+
85+ //////
86+
87+ private static function follow(array $a, string $actor): array {
88+ $target = Fedi::id($a['object'] ?? null);
89+ if ($target === Fedi::blogActorUrl()) return (new Activitypub())->handleInbox($a);
90+ $username = $target ? Fedi::localUsername($target) : null;
91+ if ($username === null || Fedi::localActor($username) === null) return [404, ['error' => 'アカウントが見つかりません']];
92+
93+ if (Actor::isBlocked($username, $actor)) {
94+ Social::rejectFollow($username, $a);
95+ return [202, ['status' => '拒否しました']];
96+ }
97+ if (Actor::isFollowedBy($username, $actor)) {
98+ // 既にフォロワー:もう一度Acceptを返す(相手が状態を失った場合)
99+ Social::acceptFollow($username, $a);
100+ return [202, ['status' => 'OK']];
101+ }
102+ if (Actor::settings($username)['locked']) {
103+ Fedi::update(Fedi::userPath($username, 'requests.json'), fn($l) => [$actor => ['activity' => $a, 'at' => time()]] + $l);
104+ Actor::notify($username, 'follow_request', $actor);
105+ return [202, ['status' => '承認待ち']];
106+ }
107+ Social::acceptFollow($username, $a);
108+ return [202, ['status' => 'OK']];
109+ }
110+
111+ /**
112+ * 自分が送ったFollowへのAccept・Reject
113+ * Rejectはフォロワーを外された場合(Followが自分の場合)もある
114+ */
115+ private static function accept(array $a, string $actor, bool $accepted): array {
116+ $obj = $a['object'] ?? null;
117+ $followId = Fedi::id($obj);
118+ $follower = is_array($obj) ? Fedi::id($obj['actor'] ?? null) : null;
119+
120+ foreach (Social::localUsernames() as $u) {
121+ $me = Fedi::actorUrl($u);
122+ $f = Actor::following($u)[$actor] ?? null;
123+ if ($f === null) continue;
124+ if ($follower !== null && $follower !== $me) continue;
125+ if ($follower === null && $followId !== $f['id']) continue;
126+ Fedi::update(Fedi::userPath($u, 'following.json'), function($l) use ($actor, $accepted) {
127+ if ($accepted) $l[$actor]['state'] = 'accepted';
128+ else unset($l[$actor]);
129+ return $l;
130+ });
131+ }
132+ return [202, ['status' => 'OK']];
133+ }
134+
135+ private static function undo(array $a, string $actor): array {
136+ $inner = $a['object'] ?? null;
137+ if (is_string($inner)) $inner = self::findOwnActivity($inner, $actor);
138+ if (!is_array($inner)) return [202, ['status' => '無視しました']];
139+ // 他人のアクティビティは取り消せない
140+ if (Fedi::id($inner['actor'] ?? null) !== $actor) return [202, ['status' => '無視しました']];
141+
142+ switch ($inner['type'] ?? '') {
143+ case 'Follow':
144+ $target = Fedi::id($inner['object'] ?? null);
145+ if ($target === Fedi::blogActorUrl()) return (new Activitypub())->handleInbox($a);
146+ $username = $target ? Fedi::localUsername($target) : null;
147+ if ($username !== null) {
148+ Fedi::update(Fedi::userPath($username, 'followers.json'), function($l) use ($actor) { unset($l[$actor]); return $l; });
149+ Fedi::update(Fedi::userPath($username, 'requests.json'), function($l) use ($actor) { unset($l[$actor]); return $l; });
150+ }
151+ break;
152+ case 'Like':
153+ self::modifyLocalNote(Fedi::id($inner['object'] ?? null), function($n) use ($actor) { unset($n['likes'][$actor]); return $n; });
154+ break;
155+ case 'Announce':
156+ $obj = Fedi::id($inner['object'] ?? null);
157+ self::modifyLocalNote($obj, function($n) use ($actor) { unset($n['boosts'][$actor]); return $n; });
158+ if ($obj) Timeline::remove($obj, $actor);
159+ break;
160+ }
161+ return [202, ['status' => 'OK']];
162+ }
163+
164+ private static function create(array $a, string $actor, bool $local): array {
165+ $obj = $a['object'] ?? null;
166+ if (is_string($obj)) $obj = Remote::object($obj);
167+ if (!is_array($obj) || !in_array($obj['type'] ?? '', ['Note', 'Article', 'Question', 'Page'], true)) return [202, ['status' => '無視しました']];
168+ if (Fedi::id($obj['attributedTo'] ?? null) !== $actor) return [403, ['error' => '作者が違います']];
169+ $id = Fedi::id($obj['id'] ?? null);
170+ if ($id === null || Fedi::domainOf($id) !== Fedi::domainOf($actor)) return [403, ['error' => '不正なID']];
171+
172+ $addressed = self::addressedLocalUsers($obj);
173+ $parent = Fedi::id($obj['inReplyTo'] ?? null);
174+ $parentIds = $parent ? Fedi::localNoteId($parent) : null;
175+
176+ if (!$local) {
177+ // 誰にも関係の無い投稿は保存しない(スパム対策)
178+ $followers = self::localFollowersOf($actor);
179+ if (empty($addressed) && empty($followers) && $parentIds === null) return [202, ['status' => '無視しました']];
180+ $obj['_receivedAt'] = time();
181+ Remote::store($obj);
182+ Timeline::add($obj);
183+ // 表示用にアクターをキャッシュする(1日1回まで取得)
184+ Remote::actor($actor);
185+ }
186+
187+ // 返信と通知
188+ if ($parentIds !== null) {
189+ Notes::modify($parentIds[1], function($n) use ($id) {
190+ if (!in_array($id, $n['replies'] ?? [], true)) $n['replies'][] = $id;
191+ return $n;
192+ });
193+ if ($parentIds[0] !== Fedi::localUsername($actor)) Actor::notify($parentIds[0], 'reply', $actor, $id);
194+ }
195+ foreach ($addressed as $u) {
196+ if ($parentIds !== null && $u === $parentIds[0]) continue;
197+ if (Fedi::localUsername($actor) === $u || Actor::isBlocked($u, $actor)) continue;
198+ Actor::notify($u, 'mention', $actor, $id);
199+ }
200+ return [202, ['status' => 'OK']];
201+ }
202+
203+ private static function update(array $a, string $actor): array {
204+ $obj = $a['object'] ?? null;
205+ if (!is_array($obj)) return [202, ['status' => '無視しました']];
206+ if (Remote::isActor($obj)) {
207+ if (($obj['id'] ?? null) === $actor) Remote::cacheActor($obj);
208+ return [202, ['status' => 'OK']];
209+ }
210+ $id = Fedi::id($obj['id'] ?? null);
211+ if ($id === null || Fedi::isLocal($id)) return [202, ['status' => 'OK']];
212+ $old = Remote::stored($id);
213+ if (!$old || Fedi::id($old['attributedTo'] ?? null) !== $actor || Fedi::id($obj['attributedTo'] ?? null) !== $actor) {
214+ return [202, ['status' => '無視しました']];
215+ }
216+ $obj['_receivedAt'] = $old['_receivedAt'] ?? time();
217+ Remote::store($obj);
218+ Timeline::refresh($obj);
219+ return [202, ['status' => 'OK']];
220+ }
221+
222+ private static function delete(array $a, string $actor): array {
223+ $id = Fedi::id($a['object'] ?? null);
224+ if ($id === null) return [202, ['status' => '無視しました']];
225+
226+ if ($id === $actor) {
227+ // アカウントの削除:全部から消す
228+ $blog = (new Activitypub())->handleInbox($a);
229+ foreach (Social::localUsernames() as $u) {
230+ foreach (['followers.json', 'following.json', 'requests.json'] as $f) {
231+ Fedi::update(Fedi::userPath($u, $f), function($l) use ($actor) { unset($l[$actor]); return $l; });
232+ }
233+ }
234+ foreach (Timeline::entries() as $e) {
235+ if ($e['author'] === $actor && !$e['local']) Remote::forget($e['object']);
236+ }
237+ Timeline::removeActor($actor);
238+ Remote::forgetActor($actor);
239+ return [202, ['status' => 'OK']];
240+ }
241+
242+ $stored = Remote::stored($id);
243+ if ($stored && Fedi::id($stored['attributedTo'] ?? null) === $actor) {
244+ Remote::forget($id);
245+ Timeline::remove($id);
246+ $parent = Fedi::id($stored['inReplyTo'] ?? null);
247+ self::modifyLocalNote($parent, function($n) use ($id) {
248+ $n['replies'] = array_values(array_diff($n['replies'] ?? [], [$id]));
249+ return $n;
250+ });
251+ }
252+ return [202, ['status' => 'OK']];
253+ }
254+
255+ private static function like(array $a, string $actor): array {
256+ $obj = Fedi::id($a['object'] ?? null);
257+ $ids = $obj ? Fedi::localNoteId($obj) : null;
258+ if ($ids === null) return [202, ['status' => '無視しました']];
259+ if (Actor::isBlocked($ids[0], $actor)) return [202, ['status' => '無視しました']];
260+ $id = Fedi::id($a['id'] ?? null) ?? '';
261+ self::modifyLocalNote($obj, function($n) use ($actor, $id) { $n['likes'][$actor] = $id; return $n; });
262+ if (Fedi::localUsername($actor) !== $ids[0]) Actor::notify($ids[0], 'like', $actor, $obj);
263+ return [202, ['status' => 'OK']];
264+ }
265+
266+ private static function announce(array $a, string $actor, bool $local): array {
267+ $objId = Fedi::id($a['object'] ?? null);
268+ if ($objId === null) return [202, ['status' => '無視しました']];
269+ $ids = Fedi::localNoteId($objId);
270+ $id = Fedi::id($a['id'] ?? null) ?? '';
271+
272+ if ($ids !== null && !Actor::isBlocked($ids[0], $actor)) {
273+ self::modifyLocalNote($objId, function($n) use ($actor, $id) { $n['boosts'][$actor] = $id; return $n; });
274+ if (Fedi::localUsername($actor) !== $ids[0]) Actor::notify($ids[0], 'boost', $actor, $objId);
275+ }
276+ // フォローしている人のブーストはタイムラインに載せる
277+ if (!$local && !empty(self::localFollowersOf($actor))) {
278+ // 埋め込まれたオブジェクトは偽装出来るので、IDから取得する
279+ $obj = Remote::object($objId);
280+ if ($obj && in_array(Fedi::visibility(Fedi::ids($obj['to'] ?? []), Fedi::ids($obj['cc'] ?? [])), ['public', 'unlisted'], true)) {
281+ if (!Fedi::isLocal($objId)) Remote::store($obj);
282+ Timeline::add($obj, $actor, strtotime((string)($a['published'] ?? '')) ?: time());
283+ }
284+ }
285+ return [202, ['status' => 'OK']];
286+ }
287+
288+ private static function block(array $a, string $actor): array {
289+ $target = Fedi::id($a['object'] ?? null);
290+ $username = $target ? Fedi::localUsername($target) : null;
291+ if ($username === null) return [202, ['status' => '無視しました']];
292+ foreach (['followers.json', 'following.json', 'requests.json'] as $f) {
293+ Fedi::update(Fedi::userPath($username, $f), function($l) use ($actor) { unset($l[$actor]); return $l; });
294+ }
295+ return [202, ['status' => 'OK']];
296+ }
297+
298+ private static function flag(array $a, string $actor): array {
299+ $objects = Fedi::ids($a['object'] ?? []);
300+ if (empty($objects)) return [202, ['status' => '無視しました']];
301+ Fedi::update(Fedi::path('reports.json'), function($l) use ($a, $actor, $objects) {
302+ array_unshift($l, ['id' => Fedi::newId(), 'from' => $actor, 'actor' => $objects[0], 'objects' => array_slice($objects, 1),
303+ 'content' => mb_substr(Html::text((string)($a['content'] ?? '')), 0, 1000), 'at' => time(),
304+ 'remote' => true, 'resolved' => false]);
305+ return array_slice($l, 0, 500);
306+ });
307+ return [202, ['status' => 'OK']];
308+ }
309+
310+ //////
311+
312+ /**
313+ * このアクターをフォローしているローカルのユーザー
314+ */
315+ public static function localFollowersOf(string $actor): array {
316+ $out = [];
317+ foreach (Social::localUsernames() as $u) {
318+ if (Actor::isFollowing($u, $actor)) $out[] = $u;
319+ }
320+ return $out;
321+ }
322+
323+ /**
324+ * 投稿の宛先(to・cc・メンション)に含まれるローカルのユーザー
325+ */
326+ private static function addressedLocalUsers(array $obj): array {
327+ $targets = array_merge(Fedi::ids($obj['to'] ?? []), Fedi::ids($obj['cc'] ?? []));
328+ foreach ($obj['tag'] ?? [] as $t) {
329+ if (is_array($t) && ($t['type'] ?? '') === 'Mention' && is_string($t['href'] ?? null)) $targets[] = $t['href'];
330+ }
331+ $out = [];
332+ foreach (array_unique($targets) as $t) {
333+ $u = Fedi::localUsername($t);
334+ if ($u !== null && Fedi::localActor($u) !== null) $out[] = $u;
335+ }
336+ return array_values(array_unique($out));
337+ }
338+
339+ private static function modifyLocalNote(?string $objectId, callable $fn): void {
340+ $ids = $objectId ? Fedi::localNoteId($objectId) : null;
341+ if ($ids) Notes::modify($ids[1], $fn);
342+ }
343+
344+ /**
345+ * UndoのobjectがIDだけの場合:このアクターの以前のアクティビティを探す(いいね・ブースト・フォロー)
346+ */
347+ private static function findOwnActivity(string $id, string $actor): ?array {
348+ foreach (Timeline::entries() as $e) {
349+ if (!$e['local'] || $e['type'] !== 'note') continue;
350+ $ids = Fedi::localNoteId($e['object']);
351+ $n = $ids ? Notes::get($ids[1]) : null;
352+ if (!$n) continue;
353+ if (($n['likes'][$actor] ?? null) === $id) return ['type' => 'Like', 'actor' => $actor, 'object' => $e['object']];
354+ if (($n['boosts'][$actor] ?? null) === $id) return ['type' => 'Announce', 'actor' => $actor, 'object' => $e['object']];
355+ }
356+ return null;
357+ }
358+}
@@ -0,0 +1,160 @@
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\Image;
41+
42+/**
43+ * アップロードされた画像(投稿の添付とアイコン)
44+ *
45+ * 種類はファイルの中身で判断し(PNG・JPEG・GIF)、位置情報等が入る部分(JPEGのAPPn、PNGのeXIf・テキスト)を取り除く。
46+ */
47+final class Media {
48+ public const MAX_BYTES = 4194304; // 4MiB
49+ private const TYPES = ['image/png' => 'png', 'image/jpeg' => 'jpg', 'image/gif' => 'gif'];
50+
51+ /**
52+ * $_FILESの1件を保存する
53+ *
54+ * @param array $file $_FILES['name']の1件(name, tmp_name, size, error)
55+ * @param string $dir 保存先(public/static/の下、例:「media/ユーザー」)
56+ * @param string $prefix ファイル名の接頭辞
57+ * @param bool $uploaded is_uploaded_fileで確認するか(テストではfalse)
58+ * @return \Result data:path(public/からのパス)、url、type、width、height
59+ */
60+ public static function save(array $file, string $dir, string $prefix = '', bool $uploaded = true): \Result {
61+ if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
62+ return \Result::Error(($file['error'] ?? 0) === UPLOAD_ERR_INI_SIZE || ($file['error'] ?? 0) === UPLOAD_ERR_FORM_SIZE
63+ ? 'ファイルが大き過ぎます。' : 'ファイルのアップロードに失敗しました。');
64+ }
65+ $tmp = (string)($file['tmp_name'] ?? '');
66+ if ($uploaded && !is_uploaded_file($tmp)) return \Result::Error('ファイルのアップロードに失敗しました。');
67+ $size = filesize($tmp);
68+ if ($size === false || $size === 0) return \Result::Error('ファイルが空です。');
69+ if ($size > self::MAX_BYTES) return \Result::Error('ファイルが大き過ぎます('.(self::MAX_BYTES >> 20).'MiBまで)。');
70+
71+ $data = (string)file_get_contents($tmp);
72+ $type = self::detect($data);
73+ if ($type === null) return \Result::Error('PNG・JPEG・GIFの画像だけアップロード出来ます。');
74+ $data = self::strip($data, $type);
75+
76+ $rel = '/public/static/'.trim($dir, '/').'/';
77+ $abs = ROOT.$rel;
78+ if (!is_dir($abs) && !@mkdir($abs, 0755, true) && !is_dir($abs)) return \Result::Error('保存先を作れません。');
79+ $name = $prefix.bin2hex(random_bytes(12)).'.'.self::TYPES[$type];
80+ if (file_put_contents($abs.$name, $data) === false) return \Result::Error('ファイルの保存に失敗しました。');
81+ @chmod($abs.$name, 0644);
82+
83+ // 大きさ(読めなくても保存は成功)
84+ $w = $h = 0;
85+ try {
86+ $img = new Image($rel.$name);
87+ $w = (int)($img->fullInfo->width ?? 0);
88+ $h = (int)($img->fullInfo->height ?? 0);
89+ } catch (\Throwable $e) {
90+ }
91+
92+ return \Result::Success('', [
93+ 'name' => $name,
94+ 'path' => $rel.$name,
95+ 'url' => Fedi::base().'/static/'.trim($dir, '/').'/'.$name,
96+ 'type' => $type,
97+ 'width' => $w,
98+ 'height' => $h,
99+ ]);
100+ }
101+
102+ /**
103+ * ファイルの中身から種類を判断する
104+ */
105+ public static function detect(string $data): ?string {
106+ if (str_starts_with($data, "\x89PNG\r\n\x1a\n")) return 'image/png';
107+ if (str_starts_with($data, "\xff\xd8\xff")) return 'image/jpeg';
108+ if (str_starts_with($data, 'GIF87a') || str_starts_with($data, 'GIF89a')) return 'image/gif';
109+ return null;
110+ }
111+
112+ /**
113+ * メタデータを取り除く
114+ *
115+ * JPEG:APP1〜APP15(Exif、XMP等)とCOMを消す(APP0のJFIFは残す)
116+ * PNG:eXIf、tEXt、zTXt、iTXt、tIMEを消す
117+ */
118+ public static function strip(string $data, string $type): string {
119+ if ($type === 'image/jpeg') {
120+ $out = substr($data, 0, 2);
121+ $pos = 2;
122+ $len = strlen($data);
123+ while ($pos + 4 <= $len && $data[$pos] === "\xff") {
124+ $marker = ord($data[$pos + 1]);
125+ // SOS(画像データの始まり)以降はそのまま
126+ if ($marker === 0xda) break;
127+ $seg = unpack('n', substr($data, $pos + 2, 2))[1];
128+ if ($seg < 2) break;
129+ $isMeta = ($marker >= 0xe1 && $marker <= 0xef) || $marker === 0xfe;
130+ if (!$isMeta) $out .= substr($data, $pos, $seg + 2);
131+ $pos += $seg + 2;
132+ }
133+ return $out.substr($data, $pos);
134+ }
135+ if ($type === 'image/png') {
136+ $out = substr($data, 0, 8);
137+ $pos = 8;
138+ $len = strlen($data);
139+ while ($pos + 12 <= $len) {
140+ $clen = unpack('N', substr($data, $pos, 4))[1];
141+ $ctype = substr($data, $pos + 4, 4);
142+ $total = $clen + 12;
143+ if ($pos + $total > $len) break;
144+ if (!in_array($ctype, ['eXIf', 'tEXt', 'zTXt', 'iTXt', 'tIME'], true)) $out .= substr($data, $pos, $total);
145+ $pos += $total;
146+ }
147+ return $out;
148+ }
149+ return $data;
150+ }
151+
152+ /**
153+ * 保存した画像を消す(public/からのパス)
154+ */
155+ public static function remove(string $path): void {
156+ $abs = realpath(ROOT.$path);
157+ $root = realpath(ROOT.'/public/static');
158+ if ($abs && $root && str_starts_with($abs, $root.DIRECTORY_SEPARATOR) && is_file($abs)) @unlink($abs);
159+ }
160+}
@@ -0,0 +1,130 @@
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+/**
41+ * ローカルの投稿の保存(data/ap/notes/{ID}.json)
42+ */
43+final class Notes {
44+ public static function path(string $id): string {
45+ return Fedi::path("notes/{$id}.json");
46+ }
47+
48+ public static function get(string $id): ?array {
49+ if (!preg_match('/^[0-9a-f]{20}$/', $id)) return null;
50+ return Fedi::read(self::path($id), null);
51+ }
52+
53+ public static function save(array $note): bool {
54+ return Fedi::write(self::path($note['id']), $note);
55+ }
56+
57+ /**
58+ * 投稿をロックして変更する
59+ *
60+ * @return array|null 変更後の投稿、無ければnull
61+ */
62+ public static function modify(string $id, callable $fn): ?array {
63+ if (self::get($id) === null) return null;
64+ return Fedi::update(self::path($id), fn($n) => $n ? $fn($n) : $n, null);
65+ }
66+
67+ public static function delete(string $id): void {
68+ @unlink(self::path($id));
69+ }
70+
71+ /** 投稿のURL(オブジェクトのID) */
72+ public static function url(array $note): string {
73+ return Fedi::noteUrl($note['author'], $note['id']);
74+ }
75+
76+ /** 投稿のページ(HTML) */
77+ public static function htmlUrl(string $objectId): string {
78+ return Fedi::base().'/microblog/note?id='.rawurlencode($objectId);
79+ }
80+
81+ /**
82+ * ActivityPubのNote
83+ */
84+ public static function toObject(array $note): array {
85+ $url = self::url($note);
86+ $obj = [
87+ '@context' => Fedi::CONTEXT,
88+ 'id' => $url,
89+ 'type' => 'Note',
90+ 'attributedTo' => Fedi::actorUrl($note['author']),
91+ 'content' => $note['content'],
92+ 'source' => ['content' => $note['source'], 'mediaType' => 'text/plain'],
93+ 'to' => $note['to'],
94+ 'cc' => $note['cc'],
95+ 'published' => $note['published'],
96+ 'url' => self::htmlUrl($url),
97+ 'sensitive' => (bool)$note['sensitive'],
98+ 'tag' => $note['tags'],
99+ // 「path」は削除用の内部の項目なので外す
100+ 'attachment' => array_map(fn($a) => array_diff_key($a, ['path' => 1]), $note['attachments']),
101+ 'replies' => [
102+ 'id' => "{$url}/replies",
103+ 'type' => 'Collection',
104+ 'totalItems' => count($note['replies'] ?? []),
105+ ],
106+ 'likes' => ['id' => "{$url}/likes", 'type' => 'Collection', 'totalItems' => count($note['likes'] ?? [])],
107+ 'shares' => ['id' => "{$url}/shares", 'type' => 'Collection', 'totalItems' => count($note['boosts'] ?? [])],
108+ ];
109+ if (($note['summary'] ?? '') !== '') $obj['summary'] = $note['summary'];
110+ if (!empty($note['inReplyTo'])) $obj['inReplyTo'] = $note['inReplyTo'];
111+ if (!empty($note['updated'])) $obj['updated'] = $note['updated'];
112+ return $obj;
113+ }
114+
115+ /** Createアクティビティ */
116+ public static function toCreate(array $note): array {
117+ $obj = self::toObject($note);
118+ unset($obj['@context']);
119+ return [
120+ '@context' => Fedi::CONTEXT,
121+ 'id' => self::url($note).'/activity',
122+ 'type' => 'Create',
123+ 'actor' => Fedi::actorUrl($note['author']),
124+ 'published' => $note['published'],
125+ 'to' => $note['to'],
126+ 'cc' => $note['cc'],
127+ 'object' => $obj,
128+ ];
129+ }
130+}
@@ -0,0 +1,230 @@
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\Activitypub;
41+
42+/**
43+ * リモートのアクターとオブジェクトの取得とキャッシュ
44+ *
45+ * 取得はブログのアクターの鍵で署名する(Authorized fetchのサーバーにも対応)。
46+ * このサイトのURLはHTTPで取りに行かず、保存しているデータから返す。
47+ */
48+final class Remote {
49+ /** アクターのキャッシュの有効期限 */
50+ public const ACTOR_TTL = 86400;
51+
52+ private static ?Activitypub $ap = null;
53+
54+ private static function ap(): Activitypub {
55+ return self::$ap ??= new Activitypub();
56+ }
57+
58+ /** リモートに接続しても安全なURLか(https、公開アドレス等) */
59+ public static function isSafeUrl(mixed $url): bool {
60+ return is_string($url) && self::ap()->isSafeRemoteUrl($url);
61+ }
62+
63+ /**
64+ * URLを取得する(キャッシュなし)
65+ */
66+ public static function fetch(string $url): ?array {
67+ if (Actor::isDomainBlocked($url)) return null;
68+ return self::ap()->fetchRemoteJson($url);
69+ }
70+
71+ /**
72+ * アクターを取得する(1日キャッシュ)
73+ *
74+ * @param string $url アクターのURL
75+ * @param bool $refresh キャッシュを使わない
76+ * @return array|null
77+ */
78+ public static function actor(string $url, bool $refresh = false): ?array {
79+ if (Fedi::isLocal($url)) {
80+ $name = Fedi::localUsername($url);
81+ if ($name === null) return $url === Fedi::blogActorUrl() ? json_decode(self::ap()->getActor(), true) : null;
82+ $u = Fedi::localActor($name);
83+ return $u ? Actor::document($u) : null;
84+ }
85+
86+ $file = Fedi::path('remote/actors/'.sha1($url).'.json');
87+ $cached = Fedi::read($file, null);
88+ if (!$refresh && $cached && ($cached['fetchedAt'] ?? 0) > time() - self::ACTOR_TTL) return $cached['doc'];
89+
90+ $doc = self::fetch($url);
91+ if (!$doc || !self::isActor($doc) || ($doc['id'] ?? '') !== $url || !self::isSafeUrl($doc['inbox'] ?? null)) {
92+ return $cached['doc'] ?? null;
93+ }
94+ Fedi::write($file, ['doc' => $doc, 'fetchedAt' => time()]);
95+ return $doc;
96+ }
97+
98+ /**
99+ * キャッシュしているアクター(取得しない。表示用)
100+ */
101+ public static function cachedActor(string $url): ?array {
102+ if (Fedi::isLocal($url)) return self::actor($url);
103+ return Fedi::read(Fedi::path('remote/actors/'.sha1($url).'.json'), null)['doc'] ?? null;
104+ }
105+
106+ /**
107+ * 受け取ったアクター(Update{Person}等)でキャッシュを更新する
108+ */
109+ public static function cacheActor(array $doc): void {
110+ $id = $doc['id'] ?? null;
111+ if (!is_string($id) || Fedi::isLocal($id) || !self::isActor($doc)) return;
112+ Fedi::write(Fedi::path('remote/actors/'.sha1($id).'.json'), ['doc' => $doc, 'fetchedAt' => time()]);
113+ }
114+
115+ public static function forgetActor(string $url): void {
116+ @unlink(Fedi::path('remote/actors/'.sha1($url).'.json'));
117+ }
118+
119+ public static function isActor(array $doc): bool {
120+ return in_array($doc['type'] ?? '', ['Person', 'Service', 'Application', 'Group', 'Organization'], true);
121+ }
122+
123+ /**
124+ * 「@user@host」をWebFingerでアクターのURLにする
125+ *
126+ * @return string|null
127+ */
128+ public static function webfinger(string $user, string $host): ?string {
129+ if (!preg_match('/^[A-Za-z0-9_.-]+$/', $user) || !preg_match('/^[A-Za-z0-9.-]+$/', $host)) return null;
130+ if (strtolower($host) === strtolower(Fedi::host())) {
131+ return Fedi::localActor($user) ? Fedi::actorUrl($user) : null;
132+ }
133+
134+ // 同じ人を何度も探さない様に、結果を1日キャッシュする
135+ $file = Fedi::path('remote/webfinger/'.sha1(strtolower("{$user}@{$host}")).'.json');
136+ $cached = Fedi::read($file, null);
137+ if ($cached && ($cached['at'] ?? 0) > time() - self::ACTOR_TTL) return $cached['url'];
138+
139+ $jrd = self::fetch("https://{$host}/.well-known/webfinger?resource=".rawurlencode("acct:{$user}@{$host}"));
140+ $url = null;
141+ foreach ($jrd['links'] ?? [] as $l) {
142+ if (($l['rel'] ?? '') === 'self' && str_contains((string)($l['type'] ?? ''), 'json') && self::isSafeUrl($l['href'] ?? null)) {
143+ $url = $l['href'];
144+ break;
145+ }
146+ }
147+ Fedi::write($file, ['url' => $url, 'at' => time()]);
148+ return $url;
149+ }
150+
151+ /**
152+ * 「@user@host」「user@host」「@localuser」又はURLからアクターのURL
153+ */
154+ public static function resolve(string $q): ?string {
155+ $q = trim($q);
156+ if (preg_match('#^https?://#', $q)) {
157+ $doc = self::actor($q);
158+ return $doc ? $doc['id'] : null;
159+ }
160+ if (preg_match('/^@?([A-Za-z0-9_.-]+)@([A-Za-z0-9.-]+\.[A-Za-z]{2,}(?::\d+)?)$/', $q, $m)) return self::webfinger($m[1], $m[2]);
161+ if (preg_match('/^@([A-Za-z0-9]+)$/', $q, $m)) return Fedi::localActor($m[1]) ? Fedi::actorUrl($m[1]) : null;
162+ return null;
163+ }
164+
165+ /**
166+ * オブジェクト(投稿)を取得する。保存しているものがあればそれを使う
167+ *
168+ * @param string $url オブジェクトのID
169+ * @return array|null
170+ */
171+ public static function object(string $url): ?array {
172+ if (Fedi::isLocal($url)) {
173+ $ids = Fedi::localNoteId($url);
174+ $note = $ids ? Notes::get($ids[1]) : null;
175+ return $note && $note['author'] === $ids[0] ? Notes::toObject($note) : null;
176+ }
177+ $stored = self::stored($url);
178+ if ($stored) return $stored;
179+
180+ $obj = self::fetch($url);
181+ // 他のサーバーのオブジェクトを名乗っていないか
182+ if (!$obj || !is_string($obj['id'] ?? null) || Fedi::domainOf($obj['id']) !== Fedi::domainOf($url)) return null;
183+ $author = Fedi::id($obj['attributedTo'] ?? null);
184+ if ($author === null || Fedi::domainOf($author) !== Fedi::domainOf($obj['id'])) return null;
185+ return $obj;
186+ }
187+
188+ //////// 受け取ったオブジェクトの保存
189+
190+ public static function storedPath(string $id): string {
191+ return Fedi::path('remote/objects/'.sha1($id).'.json');
192+ }
193+
194+ /** 保存しているリモートのオブジェクト */
195+ public static function stored(string $id): ?array {
196+ return Fedi::read(self::storedPath($id), null);
197+ }
198+
199+ /**
200+ * リモートのオブジェクトを保存する(本文は表示する時に安全にする)
201+ */
202+ public static function store(array $obj): void {
203+ $id = $obj['id'] ?? null;
204+ if (!is_string($id) || Fedi::isLocal($id)) return;
205+ $path = self::storedPath($id);
206+ Fedi::write($path, $obj);
207+ }
208+
209+ public static function forget(string $id): void {
210+ @unlink(self::storedPath($id));
211+ }
212+
213+ /**
214+ * アクターの受付ボックス(sharedInboxがあればそちら)
215+ */
216+ public static function inbox(array $actor, bool $shared = true): ?string {
217+ // このサイトの受付ボックスはこのサイトのアクターだけ(リモートのアクターが名乗っても使わない)
218+ $local = Fedi::isLocal((string)($actor['id'] ?? ''));
219+ $ok = fn($u) => is_string($u) && ($local ? Fedi::isLocal($u) : self::isSafeUrl($u));
220+ $inbox = $shared ? ($actor['endpoints']['sharedInbox'] ?? null) : null;
221+ if (!$ok($inbox)) $inbox = $actor['inbox'] ?? null;
222+ return $ok($inbox) ? $inbox : null;
223+ }
224+
225+ /** 「@user@host」 */
226+ public static function handle(array $actor): string {
227+ $user = (string)($actor['preferredUsername'] ?? 'unknown');
228+ return '@'.$user.'@'.Fedi::domainOf((string)($actor['id'] ?? ''));
229+ }
230+}
@@ -0,0 +1,551 @@
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 Result;
41+
42+/**
43+ * ローカルのユーザーの操作(投稿、編集、削除、いいね、ブースト、フォロー、ブロック、通報)
44+ *
45+ * 全部のメソッドはユーザーがFediverseを使える事を確認してから呼ぶ事(コントローラーで確認する)。
46+ */
47+final class Social {
48+ public const VISIBILITIES = ['public' => '公開', 'unlisted' => '未収載', 'followers' => 'フォロワー限定', 'direct' => 'ダイレクト'];
49+
50+ //////// 投稿
51+
52+ /**
53+ * 投稿する
54+ *
55+ * @param string $username 投稿するユーザー
56+ * @param string $text 本文(プレーンテキスト)
57+ * @param array $opt cw(注意書き)、visibility、inReplyTo(返信先のID)、media(Media::saveの結果のdata)、alts(代替テキスト)
58+ * @return Result data:note
59+ */
60+ public static function post(string $username, string $text, array $opt = []): Result {
61+ $text = trim(str_replace("\r\n", "\n", $text));
62+ $cw = trim((string)($opt['cw'] ?? ''));
63+ $media = $opt['media'] ?? [];
64+ if ($text === '' && empty($media)) return Result::Error('本文を入力して下さい。');
65+ if (mb_strlen($text) > Fedi::MAX_LENGTH) return Result::Error('本文は'.Fedi::MAX_LENGTH.'文字までです。');
66+ if (mb_strlen($cw) > 200) return Result::Error('注意書きは200文字までです。');
67+ if (count($media) > Fedi::MAX_ATTACHMENTS) return Result::Error('添付は'.Fedi::MAX_ATTACHMENTS.'枚までです。');
68+ $vis = (string)($opt['visibility'] ?? 'public');
69+ if (!isset(self::VISIBILITIES[$vis])) $vis = 'public';
70+
71+ // 返信先
72+ $parent = null;
73+ $inReplyTo = trim((string)($opt['inReplyTo'] ?? ''));
74+ if ($inReplyTo !== '') {
75+ $parent = Remote::object($inReplyTo);
76+ if (!$parent) return Result::Error('返信先の投稿が見つかりません。');
77+ }
78+
79+ [$mentionUrls, $tags] = self::resolveTags($text);
80+ $mentioned = array_values(array_unique(array_values($mentionUrls)));
81+ $parentAuthor = $parent ? Fedi::id($parent['attributedTo'] ?? null) : null;
82+ if ($parentAuthor && $parentAuthor !== Fedi::actorUrl($username)) $mentioned[] = $parentAuthor;
83+ $mentioned = array_values(array_unique($mentioned));
84+ [$to, $cc] = self::address($username, $vis, $mentioned);
85+
86+ $attachments = [];
87+ foreach (array_values($media) as $i => $m) {
88+ $a = ['type' => 'Image', 'mediaType' => $m['type'], 'url' => $m['url'], 'name' => mb_substr((string)($opt['alts'][$i] ?? ''), 0, 1500)];
89+ if (!empty($m['width'])) $a['width'] = $m['width'];
90+ if (!empty($m['height'])) $a['height'] = $m['height'];
91+ $a['path'] = $m['path'];
92+ $attachments[] = $a;
93+ }
94+
95+ $note = [
96+ 'id' => Fedi::newId(),
97+ 'author' => $username,
98+ 'source' => $text,
99+ 'content' => Html::render($text, $mentionUrls),
100+ 'summary' => $cw,
101+ 'sensitive' => $cw !== '',
102+ 'visibility' => $vis,
103+ 'to' => $to,
104+ 'cc' => $cc,
105+ 'inReplyTo' => $parent ? $parent['id'] : null,
106+ 'tags' => $tags,
107+ 'attachments' => $attachments,
108+ 'published' => Fedi::now(),
109+ 'likes' => [],
110+ 'boosts' => [],
111+ 'replies' => [],
112+ ];
113+ if (!Notes::save($note)) return Result::Error('投稿の保存に失敗しました。');
114+
115+ $obj = Notes::toObject($note);
116+ Timeline::add($obj, null, microtime(true));
117+ $create = Notes::toCreate($note);
118+ Delivery::enqueue($username, $create, Delivery::audience($username, $create));
119+ return Result::Success('投稿しました。', ['note' => $note]);
120+ }
121+
122+ /**
123+ * 自分の投稿を編集する(本文と注意書き。公開範囲は変えられない)
124+ */
125+ public static function edit(string $username, string $id, string $text, string $cw = ''): Result {
126+ $note = Notes::get($id);
127+ if (!$note || $note['author'] !== $username) return Result::Error('投稿が見つかりません。');
128+ $text = trim(str_replace("\r\n", "\n", $text));
129+ $cw = trim($cw);
130+ if ($text === '' && empty($note['attachments'])) return Result::Error('本文を入力して下さい。');
131+ if (mb_strlen($text) > Fedi::MAX_LENGTH) return Result::Error('本文は'.Fedi::MAX_LENGTH.'文字までです。');
132+ if (mb_strlen($cw) > 200) return Result::Error('注意書きは200文字までです。');
133+
134+ [$mentionUrls, $tags] = self::resolveTags($text);
135+ // 新しいメンションを宛先に追加する(外さない。既に届いているので)
136+ $new = array_diff(array_values($mentionUrls), $note['to'], $note['cc']);
137+ if ($note['visibility'] === 'direct') $note['to'] = array_values(array_merge($note['to'], $new));
138+ else $note['cc'] = array_values(array_merge($note['cc'], $new));
139+
140+ $note['source'] = $text;
141+ $note['content'] = Html::render($text, $mentionUrls);
142+ $note['summary'] = $cw;
143+ $note['sensitive'] = $cw !== '';
144+ $note['tags'] = $tags;
145+ $note['updated'] = Fedi::now();
146+ Notes::modify($id, fn($n) => array_merge($n, array_intersect_key($note, array_flip(['source', 'content', 'summary', 'sensitive', 'tags', 'updated', 'to', 'cc']))));
147+
148+ $obj = Notes::toObject(Notes::get($id));
149+ Timeline::refresh($obj);
150+ unset($obj['@context']);
151+ $update = [
152+ '@context' => Fedi::CONTEXT,
153+ 'id' => Notes::url($note).'#updates/'.time(),
154+ 'type' => 'Update',
155+ 'actor' => Fedi::actorUrl($username),
156+ 'to' => $note['to'],
157+ 'cc' => $note['cc'],
158+ 'object' => $obj,
159+ ];
160+ Delivery::enqueue($username, $update, Delivery::audience($username, $update));
161+ return Result::Success('編集しました。');
162+ }
163+
164+ /**
165+ * 自分の投稿を削除する
166+ */
167+ public static function delete(string $username, string $id): Result {
168+ $note = Notes::get($id);
169+ if (!$note || $note['author'] !== $username) return Result::Error('投稿が見つかりません。');
170+ $url = Notes::url($note);
171+
172+ $delete = [
173+ '@context' => Fedi::CONTEXT,
174+ 'id' => "{$url}#delete",
175+ 'type' => 'Delete',
176+ 'actor' => Fedi::actorUrl($username),
177+ 'to' => $note['to'],
178+ 'cc' => $note['cc'],
179+ 'object' => ['id' => $url, 'type' => 'Tombstone'],
180+ ];
181+ // 宛先は消す前に計算する(いいね・ブーストした人にも届ける)
182+ $inboxes = Delivery::audience($username, $delete, array_merge(array_keys($note['likes'] ?? []), array_keys($note['boosts'] ?? [])));
183+
184+ foreach ($note['attachments'] as $a) if (!empty($a['path'])) Media::remove($a['path']);
185+ Notes::delete($id);
186+ Timeline::remove($url);
187+ if (!empty($note['inReplyTo'])) self::forgetReply($note['inReplyTo'], $url);
188+
189+ Delivery::enqueue($username, $delete, $inboxes);
190+ return Result::Success('削除しました。');
191+ }
192+
193+ //////// いいね・ブースト
194+
195+ public static function like(string $username, string $objectId): Result {
196+ $obj = Remote::object($objectId);
197+ if (!$obj) return Result::Error('投稿が見つかりません。');
198+ $author = Fedi::id($obj['attributedTo'] ?? null);
199+ if ($author === null) return Result::Error('投稿者が分かりません。');
200+ $likes = Fedi::read(Fedi::userPath($username, 'likes.json'));
201+ if (isset($likes[$objectId])) return Result::Success();
202+
203+ $me = Fedi::actorUrl($username);
204+ $like = ['@context' => Fedi::CONTEXT, 'id' => "{$me}#likes/".Fedi::newId(), 'type' => 'Like', 'actor' => $me, 'object' => $objectId];
205+ Fedi::update(Fedi::userPath($username, 'likes.json'), fn($l) => [$objectId => $like['id']] + $l);
206+ Delivery::enqueue($username, $like, self::inboxesOf([$author]));
207+ return Result::Success('いいねしました。');
208+ }
209+
210+ public static function unlike(string $username, string $objectId): Result {
211+ $likes = Fedi::read(Fedi::userPath($username, 'likes.json'));
212+ if (!isset($likes[$objectId])) return Result::Success();
213+ $obj = Remote::object($objectId);
214+ $me = Fedi::actorUrl($username);
215+ $undo = ['@context' => Fedi::CONTEXT, 'id' => $likes[$objectId].'/undo', 'type' => 'Undo', 'actor' => $me,
216+ 'object' => ['id' => $likes[$objectId], 'type' => 'Like', 'actor' => $me, 'object' => $objectId]];
217+ Fedi::update(Fedi::userPath($username, 'likes.json'), function($l) use ($objectId) { unset($l[$objectId]); return $l; });
218+ $author = $obj ? Fedi::id($obj['attributedTo'] ?? null) : null;
219+ if ($author) Delivery::enqueue($username, $undo, self::inboxesOf([$author]));
220+ return Result::Success('いいねを取り消しました。');
221+ }
222+
223+ public static function boost(string $username, string $objectId): Result {
224+ $obj = Remote::object($objectId);
225+ if (!$obj) return Result::Error('投稿が見つかりません。');
226+ $vis = Fedi::visibility(Fedi::ids($obj['to'] ?? []), Fedi::ids($obj['cc'] ?? []));
227+ if (!in_array($vis, ['public', 'unlisted'], true)) return Result::Error('公開の投稿だけブースト出来ます。');
228+ $boosts = Fedi::read(Fedi::userPath($username, 'boosts.json'));
229+ if (isset($boosts[$objectId])) return Result::Success();
230+
231+ $me = Fedi::actorUrl($username);
232+ $author = Fedi::id($obj['attributedTo'] ?? null);
233+ $announce = [
234+ '@context' => Fedi::CONTEXT,
235+ 'id' => "{$me}#boosts/".Fedi::newId(),
236+ 'type' => 'Announce',
237+ 'actor' => $me,
238+ 'published' => Fedi::now(),
239+ 'to' => [Fedi::PUBLIC],
240+ 'cc' => array_values(array_filter([$me.'/followers', $author])),
241+ 'object' => $objectId,
242+ ];
243+ Fedi::update(Fedi::userPath($username, 'boosts.json'), fn($b) => [$objectId => $announce['id']] + $b);
244+ if (!Fedi::isLocal($objectId)) Remote::store($obj);
245+ Timeline::add($obj, $me);
246+ Delivery::enqueue($username, $announce, Delivery::audience($username, $announce));
247+ return Result::Success('ブーストしました。');
248+ }
249+
250+ public static function unboost(string $username, string $objectId): Result {
251+ $boosts = Fedi::read(Fedi::userPath($username, 'boosts.json'));
252+ if (!isset($boosts[$objectId])) return Result::Success();
253+ $me = Fedi::actorUrl($username);
254+ $obj = Remote::object($objectId);
255+ $author = $obj ? Fedi::id($obj['attributedTo'] ?? null) : null;
256+ $undo = [
257+ '@context' => Fedi::CONTEXT,
258+ 'id' => $boosts[$objectId].'/undo',
259+ 'type' => 'Undo',
260+ 'actor' => $me,
261+ 'to' => [Fedi::PUBLIC],
262+ 'cc' => array_values(array_filter([$me.'/followers', $author])),
263+ 'object' => ['id' => $boosts[$objectId], 'type' => 'Announce', 'actor' => $me, 'object' => $objectId],
264+ ];
265+ Fedi::update(Fedi::userPath($username, 'boosts.json'), function($b) use ($objectId) { unset($b[$objectId]); return $b; });
266+ Timeline::remove($objectId, $me);
267+ Delivery::enqueue($username, $undo, Delivery::audience($username, $undo));
268+ return Result::Success('ブーストを取り消しました。');
269+ }
270+
271+ public static function hasLiked(string $username, string $objectId): bool {
272+ return isset(Fedi::read(Fedi::userPath($username, 'likes.json'))[$objectId]);
273+ }
274+
275+ public static function hasBoosted(string $username, string $objectId): bool {
276+ return isset(Fedi::read(Fedi::userPath($username, 'boosts.json'))[$objectId]);
277+ }
278+
279+ //////// フォロー
280+
281+ public static function follow(string $username, string $target): Result {
282+ $me = Fedi::actorUrl($username);
283+ if ($target === $me) return Result::Error('自分はフォロー出来ません。');
284+ if ($target === Fedi::blogActorUrl()) return Result::Error('このサイトのブログはフォロー出来ません。ブログのフィードをご利用下さい。');
285+ if (Actor::isBlocked($username, $target)) return Result::Error('ブロックしているアカウントはフォロー出来ません。');
286+ $doc = Remote::actor($target);
287+ if (!$doc) return Result::Error('アカウントが見つかりません。');
288+ $state = Actor::following($username)[$target]['state'] ?? null;
289+ if ($state !== null) return Result::Success($state === 'pending' ? '承認を待っています。' : 'フォローしています。');
290+
291+ $follow = ['@context' => Fedi::CONTEXT, 'id' => "{$me}#follows/".Fedi::newId(), 'type' => 'Follow', 'actor' => $me, 'object' => $target];
292+ Fedi::update(Fedi::userPath($username, 'following.json'), fn($f) => $f + [$target => ['state' => 'pending', 'id' => $follow['id'], 'since' => time()]]);
293+ $inbox = Remote::inbox($doc, false);
294+ if ($inbox) Delivery::enqueue($username, $follow, [$inbox]);
295+ $state = Actor::following($username)[$target]['state'] ?? 'pending';
296+ return Result::Success($state === 'accepted' ? 'フォローしました。' : 'フォローをリクエストしました。');
297+ }
298+
299+ public static function unfollow(string $username, string $target): Result {
300+ $f = Actor::following($username)[$target] ?? null;
301+ if ($f === null) return Result::Success();
302+ $me = Fedi::actorUrl($username);
303+ $undo = ['@context' => Fedi::CONTEXT, 'id' => $f['id'].'/undo', 'type' => 'Undo', 'actor' => $me,
304+ 'object' => ['id' => $f['id'], 'type' => 'Follow', 'actor' => $me, 'object' => $target]];
305+ Fedi::update(Fedi::userPath($username, 'following.json'), function($l) use ($target) { unset($l[$target]); return $l; });
306+ $doc = Remote::actor($target);
307+ $inbox = $doc ? Remote::inbox($doc, false) : null;
308+ if ($inbox) Delivery::enqueue($username, $undo, [$inbox]);
309+ return Result::Success('フォローを解除しました。');
310+ }
311+
312+ /**
313+ * フォローリクエストを承認・拒否する
314+ */
315+ public static function answerRequest(string $username, string $actor, bool $accept): Result {
316+ $req = Actor::requests($username)[$actor] ?? null;
317+ if ($req === null) return Result::Error('リクエストが見つかりません。');
318+ Fedi::update(Fedi::userPath($username, 'requests.json'), function($l) use ($actor) { unset($l[$actor]); return $l; });
319+ if ($accept) {
320+ self::acceptFollow($username, $req['activity']);
321+ return Result::Success('承認しました。');
322+ }
323+ self::rejectFollow($username, $req['activity']);
324+ return Result::Success('拒否しました。');
325+ }
326+
327+ /**
328+ * フォローを受け入れる(Inboxと承認から使う)
329+ */
330+ public static function acceptFollow(string $username, array $follow): void {
331+ $actor = Fedi::id($follow['actor'] ?? null);
332+ $doc = $actor ? Remote::actor($actor) : null;
333+ if (!$doc) return;
334+ Fedi::update(Fedi::userPath($username, 'followers.json'), fn($l) => [$actor => [
335+ 'inbox' => Remote::inbox($doc, false),
336+ 'sharedInbox' => Remote::inbox($doc, true),
337+ 'since' => time(),
338+ ]] + $l);
339+ $me = Fedi::actorUrl($username);
340+ $accept = ['@context' => Fedi::CONTEXT, 'id' => "{$me}#accepts/".Fedi::newId(), 'type' => 'Accept', 'actor' => $me, 'object' => $follow];
341+ $inbox = Remote::inbox($doc, false);
342+ if ($inbox) Delivery::enqueue($username, $accept, [$inbox]);
343+ Actor::notify($username, 'follow', $actor);
344+ }
345+
346+ public static function rejectFollow(string $username, array $follow): void {
347+ $actor = Fedi::id($follow['actor'] ?? null);
348+ $doc = $actor ? Remote::actor($actor) : null;
349+ $me = Fedi::actorUrl($username);
350+ $reject = ['@context' => Fedi::CONTEXT, 'id' => "{$me}#rejects/".Fedi::newId(), 'type' => 'Reject', 'actor' => $me, 'object' => $follow];
351+ $inbox = $doc ? Remote::inbox($doc, false) : null;
352+ if ($inbox) Delivery::enqueue($username, $reject, [$inbox]);
353+ }
354+
355+ /**
356+ * フォロワーを外す(相手のフォローを拒否する)
357+ */
358+ public static function removeFollower(string $username, string $actor): Result {
359+ if (!Actor::isFollowedBy($username, $actor)) return Result::Success();
360+ Fedi::update(Fedi::userPath($username, 'followers.json'), function($l) use ($actor) { unset($l[$actor]); return $l; });
361+ self::rejectFollow($username, ['type' => 'Follow', 'actor' => $actor, 'object' => Fedi::actorUrl($username)]);
362+ return Result::Success('フォロワーから外しました。');
363+ }
364+
365+ //////// ブロック
366+
367+ public static function block(string $username, string $target): Result {
368+ if ($target === Fedi::actorUrl($username)) return Result::Error('自分はブロック出来ません。');
369+ Fedi::update(Fedi::userPath($username, 'blocks.json'), function($b) use ($target) {
370+ $b += ['actors' => [], 'domains' => []];
371+ if (!in_array($target, $b['actors'], true)) $b['actors'][] = $target;
372+ return $b;
373+ });
374+ if (Actor::isFollowing($username, $target) || isset(Actor::following($username)[$target])) self::unfollow($username, $target);
375+ if (Actor::isFollowedBy($username, $target)) {
376+ Fedi::update(Fedi::userPath($username, 'followers.json'), function($l) use ($target) { unset($l[$target]); return $l; });
377+ }
378+ $me = Fedi::actorUrl($username);
379+ $block = ['@context' => Fedi::CONTEXT, 'id' => "{$me}#blocks/".sha1($target), 'type' => 'Block', 'actor' => $me, 'object' => $target];
380+ Delivery::enqueue($username, $block, self::inboxesOf([$target], false));
381+ return Result::Success('ブロックしました。');
382+ }
383+
384+ public static function unblock(string $username, string $target): Result {
385+ Fedi::update(Fedi::userPath($username, 'blocks.json'), function($b) use ($target) {
386+ $b += ['actors' => [], 'domains' => []];
387+ $b['actors'] = array_values(array_diff($b['actors'], [$target]));
388+ return $b;
389+ });
390+ $me = Fedi::actorUrl($username);
391+ $id = "{$me}#blocks/".sha1($target);
392+ $undo = ['@context' => Fedi::CONTEXT, 'id' => "{$id}/undo", 'type' => 'Undo', 'actor' => $me,
393+ 'object' => ['id' => $id, 'type' => 'Block', 'actor' => $me, 'object' => $target]];
394+ Delivery::enqueue($username, $undo, self::inboxesOf([$target], false));
395+ return Result::Success('ブロックを解除しました。');
396+ }
397+
398+ /**
399+ * ドメインごとブロックする(このユーザーだけ。サイト全体は管理者のblockInstance)
400+ */
401+ public static function blockDomain(string $username, string $domain, bool $block = true): Result {
402+ $domain = strtolower(trim($domain));
403+ if (!preg_match('/^[a-z0-9.-]+\.[a-z]{2,}$/', $domain)) return Result::Error('ドメインが不正です。');
404+ if ($domain === strtolower(Fedi::host())) return Result::Error('このサイトはブロック出来ません。');
405+ Fedi::update(Fedi::userPath($username, 'blocks.json'), function($b) use ($domain, $block) {
406+ $b += ['actors' => [], 'domains' => []];
407+ $b['domains'] = array_values(array_diff($b['domains'], [$domain]));
408+ if ($block) $b['domains'][] = $domain;
409+ return $b;
410+ });
411+ if ($block) {
412+ // そのドメインのフォロワーを外す
413+ Fedi::update(Fedi::userPath($username, 'followers.json'), fn($l) => array_filter($l, fn($k) => Fedi::domainOf($k) !== $domain, ARRAY_FILTER_USE_KEY));
414+ }
415+ return Result::Success($block ? "{$domain}をブロックしました。" : "{$domain}のブロックを解除しました。");
416+ }
417+
418+ //////// 通報・管理
419+
420+ /**
421+ * 通報する(このサイトの管理者に記録し、リモートなら相手のサーバーにもFlagを送る)
422+ */
423+ public static function report(string $username, string $target, array $objects, string $comment, bool $forward = true): Result {
424+ $comment = mb_substr(trim($comment), 0, 1000);
425+ Fedi::update(Fedi::path('reports.json'), function($l) use ($username, $target, $objects, $comment) {
426+ array_unshift($l, ['id' => Fedi::newId(), 'from' => Fedi::actorUrl($username), 'actor' => $target, 'objects' => array_values($objects),
427+ 'content' => $comment, 'at' => time(), 'remote' => false, 'resolved' => false]);
428+ return array_slice($l, 0, 500);
429+ });
430+ if ($forward && !Fedi::isLocal($target)) {
431+ $me = Fedi::actorUrl($username);
432+ $flag = ['@context' => Fedi::CONTEXT, 'id' => "{$me}#flags/".Fedi::newId(), 'type' => 'Flag', 'actor' => $me,
433+ 'object' => array_values(array_merge([$target], $objects)), 'content' => $comment];
434+ Delivery::enqueue($username, $flag, self::inboxesOf([$target]));
435+ }
436+ return Result::Success('通報しました。');
437+ }
438+
439+ /**
440+ * サイト全体でドメインをブロックする(管理者)
441+ */
442+ public static function blockInstance(string $domain, bool $block = true): Result {
443+ $domain = strtolower(trim($domain));
444+ if (!preg_match('/^[a-z0-9.-]+\.[a-z]{2,}$/', $domain)) return Result::Error('ドメインが不正です。');
445+ if ($domain === strtolower(Fedi::host())) return Result::Error('このサイトはブロック出来ません。');
446+ Fedi::update(Fedi::path('blocked_domains.json'), function($l) use ($domain, $block) {
447+ $l = array_values(array_diff($l, [$domain]));
448+ if ($block) $l[] = $domain;
449+ sort($l);
450+ return $l;
451+ });
452+ if ($block) {
453+ // 全員のフォロワーから外し、タイムラインからも消す
454+ foreach (self::localUsernames() as $u) {
455+ Fedi::update(Fedi::userPath($u, 'followers.json'), fn($l) => array_filter($l, fn($k) => !Actor::isDomainBlocked($k), ARRAY_FILTER_USE_KEY));
456+ }
457+ foreach (Timeline::entries() as $e) {
458+ if (!$e['local'] && Actor::isDomainBlocked($e['author'])) Remote::forget($e['object']);
459+ }
460+ }
461+ return Result::Success($block ? "{$domain}をブロックしました。" : "{$domain}のブロックを解除しました。");
462+ }
463+
464+ public static function reports(): array {
465+ return Fedi::read(Fedi::path('reports.json'));
466+ }
467+
468+ public static function resolveReport(string $id): void {
469+ Fedi::update(Fedi::path('reports.json'), fn($l) => array_map(fn($r) => $r['id'] === $id ? ['resolved' => true] + $r : $r, $l));
470+ }
471+
472+ /**
473+ * プロフィールの更新をフォロワーに送る(プロフィールを編集した後)
474+ */
475+ public static function sendProfileUpdate(string $username): void {
476+ $u = Fedi::localActor($username);
477+ if (!$u || empty(Actor::followers($username))) return;
478+ $doc = Actor::document($u);
479+ unset($doc['@context']);
480+ $me = Fedi::actorUrl($username);
481+ $update = ['@context' => Fedi::CONTEXT, 'id' => "{$me}#updates/".time(), 'type' => 'Update', 'actor' => $me,
482+ 'to' => [Fedi::PUBLIC], 'cc' => ["{$me}/followers"], 'object' => $doc];
483+ Delivery::enqueue($username, $update, Delivery::audience($username, $update));
484+ }
485+
486+ /**
487+ * Fediverseのデータがあるローカルのユーザー
488+ */
489+ public static function localUsernames(): array {
490+ $out = [];
491+ foreach (glob(Fedi::path('users/').'*', GLOB_ONLYDIR) ?: [] as $d) $out[] = basename($d);
492+ return $out;
493+ }
494+
495+ //////
496+
497+ /**
498+ * メンションとハッシュタグを解決する
499+ *
500+ * @return array [「@user@domain」 => アクターのURL, tag(Mention・Hashtag)]
501+ */
502+ private static function resolveTags(string $text): array {
503+ $urls = [];
504+ $tags = [];
505+ foreach (Html::mentions($text) as $key => [$user, $domain]) {
506+ $url = $domain === null || $domain === strtolower(Fedi::host())
507+ ? (Fedi::localActor($user) ? Fedi::actorUrl($user) : null)
508+ : Remote::webfinger($user, $domain);
509+ if ($url === null) continue;
510+ $urls[$key] = $url;
511+ $tags[] = ['type' => 'Mention', 'href' => $url, 'name' => $domain === null ? "@{$user}@".Fedi::host() : $key];
512+ }
513+ foreach (Html::hashtags($text) as $tag) {
514+ $tags[] = ['type' => 'Hashtag', 'href' => Fedi::base().'/microblog/tag/'.rawurlencode($tag), 'name' => "#{$tag}"];
515+ }
516+ return [$urls, $tags];
517+ }
518+
519+ /**
520+ * 公開範囲からto・cc
521+ */
522+ private static function address(string $username, string $vis, array $mentioned): array {
523+ $followers = Fedi::actorUrl($username).'/followers';
524+ return match ($vis) {
525+ 'public' => [[Fedi::PUBLIC], array_values(array_merge([$followers], $mentioned))],
526+ 'unlisted' => [[$followers], array_values(array_merge([Fedi::PUBLIC], $mentioned))],
527+ 'followers' => [[$followers], $mentioned],
528+ 'direct' => [$mentioned, []],
529+ };
530+ }
531+
532+ /** アクターの受付ボックス */
533+ private static function inboxesOf(array $actors, bool $shared = true): array {
534+ $out = [];
535+ foreach ($actors as $a) {
536+ $doc = Remote::actor($a);
537+ $inbox = $doc ? Remote::inbox($doc, $shared) : null;
538+ if ($inbox) $out[] = $inbox;
539+ }
540+ return $out;
541+ }
542+
543+ /** 削除した返信を返信先の投稿から外す */
544+ private static function forgetReply(string $parentId, string $replyId): void {
545+ $ids = Fedi::localNoteId($parentId);
546+ if ($ids) Notes::modify($ids[1], function($n) use ($replyId) {
547+ $n['replies'] = array_values(array_diff($n['replies'] ?? [], [$replyId]));
548+ return $n;
549+ });
550+ }
551+}
@@ -0,0 +1,338 @@
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+/**
41+ * タイムライン(data/ap/timeline.jsonl に1行1件の索引)
42+ *
43+ * 各行:t(時刻)、type(note/boost)、object(投稿のID)、actor(投稿者又はブーストした人)、
44+ * author(投稿者)、vis(公開範囲)、aud(to+cc)、local(ローカルの投稿か)、reply(返信先)、tags
45+ */
46+final class Timeline {
47+ /** 1ページの件数 */
48+ public const PAGE = 20;
49+
50+ private static function file(): string {
51+ return Fedi::path('timeline.jsonl');
52+ }
53+
54+ /**
55+ * 全部の行(古い順)
56+ */
57+ public static function entries(): array {
58+ $file = self::file();
59+ if (!is_file($file)) return [];
60+ $out = [];
61+ foreach (file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) {
62+ $e = json_decode($line, true);
63+ if (is_array($e)) $out[] = $e;
64+ }
65+ return $out;
66+ }
67+
68+ /**
69+ * 投稿・ブーストを追加する
70+ *
71+ * @param array $obj Note(ローカルならtoObject()の結果)
72+ * @param string|null $booster ブーストした人(ブーストの場合)
73+ * @param int|float|null $t 時刻
74+ */
75+ public static function add(array $obj, ?string $booster = null, int|float|null $t = null): void {
76+ $id = Fedi::id($obj['id'] ?? null);
77+ $author = Fedi::id($obj['attributedTo'] ?? null);
78+ if ($id === null || $author === null) return;
79+ $to = Fedi::ids($obj['to'] ?? []);
80+ $cc = Fedi::ids($obj['cc'] ?? []);
81+ $tags = [];
82+ foreach ($obj['tag'] ?? [] as $tag) {
83+ if (is_array($tag) && ($tag['type'] ?? '') === 'Hashtag') $tags[] = mb_strtolower(ltrim((string)($tag['name'] ?? ''), '#'));
84+ }
85+ $e = [
86+ 't' => $t ?? (strtotime((string)($obj['published'] ?? '')) ?: time()),
87+ 'type' => $booster ? 'boost' : 'note',
88+ 'object' => $id,
89+ 'actor' => $booster ?? $author,
90+ 'author' => $author,
91+ 'vis' => Fedi::visibility($to, $cc, Fedi::id(self::followersOf($author))),
92+ 'aud' => array_values(array_unique(array_merge($to, $cc))),
93+ 'local' => Fedi::isLocal($id),
94+ 'reply' => Fedi::id($obj['inReplyTo'] ?? null),
95+ 'tags' => $tags,
96+ ];
97+ if ($booster) $e['t'] = $t ?? time();
98+
99+ self::rewrite(function(array $list) use ($e): array {
100+ // 同じ投稿(又は同じ人の同じブースト)は1回だけ
101+ foreach ($list as $x) {
102+ if ($x['type'] === $e['type'] && $x['object'] === $e['object'] && $x['actor'] === $e['actor']) return $list;
103+ }
104+ $list[] = $e;
105+ return $list;
106+ });
107+ }
108+
109+ /**
110+ * 編集された投稿のタグと宛先を更新する
111+ */
112+ public static function refresh(array $obj): void {
113+ $id = Fedi::id($obj['id'] ?? null);
114+ if ($id === null) return;
115+ $tags = [];
116+ foreach ($obj['tag'] ?? [] as $tag) {
117+ if (is_array($tag) && ($tag['type'] ?? '') === 'Hashtag') $tags[] = mb_strtolower(ltrim((string)($tag['name'] ?? ''), '#'));
118+ }
119+ $aud = array_values(array_unique(array_merge(Fedi::ids($obj['to'] ?? []), Fedi::ids($obj['cc'] ?? []))));
120+ self::rewrite(fn(array $list) => array_map(fn($x) => $x['object'] === $id ? ['tags' => $tags, 'aud' => $aud] + $x : $x, $list));
121+ }
122+
123+ /**
124+ * 投稿(とそのブースト)を消す
125+ */
126+ public static function remove(string $objectId, ?string $booster = null): void {
127+ self::rewrite(fn(array $list) => array_values(array_filter($list, function($x) use ($objectId, $booster) {
128+ if ($x['object'] !== $objectId) return true;
129+ return $booster !== null && !($x['type'] === 'boost' && $x['actor'] === $booster);
130+ })));
131+ }
132+
133+ /**
134+ * アクターの投稿を全部消す(アカウントの削除・ブロック)
135+ */
136+ public static function removeActor(string $actor): void {
137+ self::rewrite(fn(array $list) => array_values(array_filter($list, fn($x) => $x['actor'] !== $actor && $x['author'] !== $actor)));
138+ }
139+
140+ /**
141+ * 古い行を消す(cronのapcleanup)。ローカルの投稿は消さない
142+ *
143+ * @param int $days この日数より古いリモートの行を消す
144+ * @return array 消したリモートのオブジェクトのID
145+ */
146+ public static function prune(int $days): array {
147+ $limit = time() - $days * 86400;
148+ $removed = [];
149+ self::rewrite(function(array $list) use ($limit, &$removed): array {
150+ $keep = [];
151+ foreach ($list as $x) {
152+ if (!$x['local'] && $x['t'] < $limit) $removed[] = $x['object'];
153+ else $keep[] = $x;
154+ }
155+ return $keep;
156+ });
157+ return array_values(array_unique($removed));
158+ }
159+
160+ /**
161+ * 古いリモートのデータを消す(cronのapcleanup)
162+ *
163+ * @param int $days この日数より古いリモートの投稿とキャッシュを消す
164+ * @return \Result
165+ */
166+ public static function cleanup(int $days = 90): \Result {
167+ $removed = self::prune($days);
168+ // 他の行(ブースト等)から使われていなければ、保存したオブジェクトも消す
169+ $used = [];
170+ foreach (self::entries() as $e) $used[$e['object']] = true;
171+ $n = 0;
172+ foreach ($removed as $id) {
173+ if (!isset($used[$id])) {
174+ Remote::forget($id);
175+ $n++;
176+ }
177+ }
178+ // タイムラインに無い古いオブジェクト(検索やプロフィールで取得した物)
179+ $limit = time() - $days * 86400;
180+ foreach (glob(Fedi::path('remote/objects/').'*.json') ?: [] as $f) {
181+ if (filemtime($f) < $limit) {
182+ $obj = Fedi::read($f, null);
183+ if (!$obj || !isset($used[$obj['id'] ?? ''])) {
184+ @unlink($f);
185+ $n++;
186+ }
187+ }
188+ }
189+ // 古いキャッシュ
190+ foreach (['remote/actors/', 'remote/webfinger/'] as $dir) {
191+ foreach (glob(Fedi::path($dir).'*.json') ?: [] as $f) if (filemtime($f) < $limit) @unlink($f);
192+ }
193+ return \Result::Success("古い投稿{$n}件を削除しました。", ['removed' => $n]);
194+ }
195+
196+ /**
197+ * タイムラインを読む
198+ *
199+ * @param string $scope home, local, federated, user, actor, tag
200+ * @param string|null $viewer 見ているローカルのユーザー(未ログインならnull)
201+ * @param array $opt user/actor/tag:対象、before:この時刻より前、limit
202+ * @return array [項目の配列, 次のページのbefore(無ければnull)]
203+ * 項目:['entry' => 行, 'object' => Note]
204+ */
205+ public static function query(string $scope, ?string $viewer, array $opt = []): array {
206+ $before = (int)($opt['before'] ?? PHP_INT_MAX);
207+ $limit = (int)($opt['limit'] ?? self::PAGE);
208+ $me = $viewer !== null ? Fedi::actorUrl($viewer) : null;
209+ $target = $opt['user'] ?? $opt['actor'] ?? null;
210+ if (isset($opt['user'])) $target = Fedi::actorUrl($opt['user']);
211+
212+ $entries = self::entries();
213+ usort($entries, fn($a, $b) => $b['t'] <=> $a['t']);
214+
215+ $out = [];
216+ $next = null;
217+ $seen = [];
218+ foreach ($entries as $e) {
219+ if ($e['t'] >= $before) continue;
220+ $ok = match ($scope) {
221+ 'home' => $me !== null && ($e['actor'] === $me || $e['author'] === $me || in_array($me, $e['aud'], true)
222+ || Actor::isFollowing($viewer, $e['actor'])),
223+ 'local' => $e['local'] && $e['type'] === 'note' && $e['vis'] === 'public',
224+ 'federated' => $e['type'] === 'note' && $e['vis'] === 'public',
225+ 'user', 'actor' => $e['actor'] === $target && in_array($e['vis'], ['public', 'unlisted', 'followers', 'direct'], true),
226+ 'tag' => $e['type'] === 'note' && in_array(mb_strtolower((string)($opt['tag'] ?? '')), $e['tags'] ?? [], true)
227+ && in_array($e['vis'], ['public', 'unlisted'], true),
228+ default => false,
229+ };
230+ if (!$ok || !self::canSee($viewer, $e)) continue;
231+ if ($viewer !== null && (Actor::isBlocked($viewer, $e['actor']) || Actor::isBlocked($viewer, $e['author']))) continue;
232+ if (Actor::isDomainBlocked($e['author'])) continue;
233+
234+ // 同じ投稿は1回だけ(ブーストと元の投稿等)
235+ if (isset($seen[$e['object']])) continue;
236+ $obj = self::object($e['object']);
237+ if ($obj === null) continue;
238+ $seen[$e['object']] = true;
239+
240+ if (count($out) >= $limit) {
241+ $next = $out[count($out) - 1]['entry']['t'];
242+ break;
243+ }
244+ $out[] = ['entry' => $e, 'object' => $obj];
245+ }
246+ return [$out, $next];
247+ }
248+
249+ /**
250+ * 見ているユーザーがこの投稿を見られるか
251+ */
252+ public static function canSee(?string $viewer, array $e): bool {
253+ if (in_array($e['vis'], ['public', 'unlisted'], true)) return true;
254+ if ($viewer === null) return false;
255+ $me = Fedi::actorUrl($viewer);
256+ if ($e['author'] === $me || in_array($me, $e['aud'], true)) return true;
257+ if ($e['vis'] !== 'followers') return false;
258+ // フォロワー限定:作者をフォローしている事
259+ $author = $e['author'];
260+ $local = Fedi::localUsername($author);
261+ return $local !== null ? Actor::isFollowedBy($local, $me) : Actor::isFollowing($viewer, $author);
262+ }
263+
264+ /**
265+ * 投稿のオブジェクト(ローカル又は保存しているリモート)
266+ */
267+ public static function object(string $id): ?array {
268+ if (Fedi::isLocal($id)) {
269+ $ids = Fedi::localNoteId($id);
270+ $note = $ids ? Notes::get($ids[1]) : null;
271+ return $note ? Notes::toObject($note) : null;
272+ }
273+ return Remote::stored($id);
274+ }
275+
276+ /**
277+ * ある投稿への返信(保存しているもの)
278+ */
279+ public static function replies(string $id): array {
280+ $out = [];
281+ foreach (self::entries() as $e) {
282+ if ($e['type'] === 'note' && $e['reply'] === $id) $out[] = $e;
283+ }
284+ usort($out, fn($a, $b) => $a['t'] <=> $b['t']);
285+ return $out;
286+ }
287+
288+ /**
289+ * 文字で検索する(ローカルと保存しているリモートの、公開・未収載の投稿)
290+ */
291+ public static function search(string $q, ?string $viewer, int $limit = 40): array {
292+ $q = mb_strtolower(trim($q));
293+ if ($q === '') return [];
294+ $entries = self::entries();
295+ usort($entries, fn($a, $b) => $b['t'] <=> $a['t']);
296+ $out = [];
297+ foreach ($entries as $e) {
298+ if ($e['type'] !== 'note' || !self::canSee($viewer, $e)) continue;
299+ if ($viewer !== null && Actor::isBlocked($viewer, $e['author'])) continue;
300+ $obj = self::object($e['object']);
301+ if (!$obj) continue;
302+ $text = mb_strtolower(Html::text((string)($obj['content'] ?? '')).' '.($obj['summary'] ?? ''));
303+ if (!str_contains($text, $q)) continue;
304+ $out[] = ['entry' => $e, 'object' => $obj];
305+ if (count($out) >= $limit) break;
306+ }
307+ return $out;
308+ }
309+
310+ ///////
311+
312+ private static function rewrite(callable $fn): void {
313+ $file = self::file();
314+ $lock = fopen($file.'.lock', 'c');
315+ if ($lock) flock($lock, LOCK_EX);
316+ try {
317+ $list = $fn(self::entries());
318+ $tmp = $file.'.'.bin2hex(random_bytes(4)).'.tmp';
319+ $data = '';
320+ foreach ($list as $e) $data .= json_encode($e, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)."\n";
321+ file_put_contents($tmp, $data);
322+ rename($tmp, $file);
323+ } finally {
324+ if ($lock) {
325+ flock($lock, LOCK_UN);
326+ fclose($lock);
327+ }
328+ }
329+ }
330+
331+ /** 投稿者のフォロワーのコレクション(分かれば) */
332+ private static function followersOf(string $author): ?string {
333+ if (Fedi::isLocal($author)) return $author.'/followers';
334+ $file = Fedi::path('remote/actors/'.sha1($author).'.json');
335+ $cached = Fedi::read($file, null);
336+ return $cached['doc']['followers'] ?? null;
337+ }
338+}
@@ -61,7 +61,7 @@
6161 }
6262 protected function followersFile(): string { return $this->file; }
6363 protected function getInboxFromActor(string $actor): ?string { return $this->remote[$actor]['inbox'] ?? null; }
64- protected function fetchRemoteJson(string $url): ?array { return $this->remote[$url] ?? null; }
64+ public function fetchRemoteJson(string $url): ?array { return $this->remote[$url] ?? null; }
6565 protected function sendActivity(string $inboxUrl, array $activity): bool {
6666 $this->sent[] = [$inboxUrl, $activity];
6767 return true;
@@ -0,0 +1,479 @@
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\Test;
39+
40+require_once __DIR__.'/../../../autoload.php';
41+
42+use Std\Lib\Tester;
43+use Std\Lib\Activitypub;
44+use Std\Lib\Fedi\{Fedi, Actor, Social, Delivery, Inbox, Timeline, Remote, Notes, Html, Media};
45+
46+$test = new Tester([
47+ 'colorOutput' => true,
48+ 'verboseOutput' => true
49+]);
50+
51+// 2つのサーバー(A・B)を1つのプロセスで動かす。公開IPのURLなので名前解決は要らない
52+$tmp = sys_get_temp_dir().'/lbfedi_'.bin2hex(random_bytes(6));
53+$inst = ['A' => 'https://1.1.1.1', 'B' => 'https://8.8.8.8'];
54+$use = function(string $n) use ($tmp, $inst): void {
55+ Fedi::$baseUrl = $inst[$n];
56+ Fedi::$dataDir = "{$tmp}/{$n}/ap";
57+ Fedi::$userDir = "{$tmp}/{$n}/user/";
58+ if (!is_dir(Fedi::$userDir)) mkdir(Fedi::$userDir, 0700, true);
59+};
60+$mkuser = function(string $n, string $name, int $id, int $role = 1) use ($use): void {
61+ $use($n);
62+ file_put_contents(Fedi::$userDir."{$id}.{$name}.json", json_encode([
63+ 'id' => $id, 'username' => $name, 'displayname' => ucfirst($name), 'bio' => "I am {$name}", 'avatar' => '',
64+ 'email' => "{$name}@example.com", 'regDate' => time(), 'gender' => -1, 'role' => $role, 'namecolor' => '',
65+ ]));
66+};
67+// 相手のサーバーのアクターをキャッシュに入れる(取得しなくても良い様に)
68+$seed = function(string $from, string $to, string $name) use ($use): void {
69+ $use($to);
70+ $doc = Actor::document(Fedi::localActor($name));
71+ $use($from);
72+ Remote::cacheActor($doc);
73+};
74+// 待ち行列を相手のサーバーの受付ボックスに届ける
75+$pump = function() use ($use, $inst): array {
76+ $log = [];
77+ for ($round = 0; $round < 5; $round++) {
78+ $any = false;
79+ foreach (array_keys($inst) as $n) {
80+ $use($n);
81+ foreach (glob(Fedi::path('queue/').'*.json') ?: [] as $f) {
82+ $job = json_decode(file_get_contents($f), true);
83+ unlink($f);
84+ $any = true;
85+ $dest = array_search('https://'.parse_url($job['inbox'], PHP_URL_HOST), $inst, true);
86+ $use($dest);
87+ [$code] = Inbox::handle($job['activity']);
88+ $log[] = "{$job['activity']['type']}:{$code}";
89+ $use($n);
90+ }
91+ }
92+ if (!$any) break;
93+ }
94+ return $log;
95+};
96+$alice = 'https://1.1.1.1/ap/users/alice';
97+$carol = 'https://1.1.1.1/ap/users/carol';
98+$bob = 'https://8.8.8.8/ap/users/bob';
99+
100+$test->describe('Fediverseの本文', function($test): void {
101+ if (!ACTIVITYPUB_ENABLED) {
102+ $test->skipAll('ACTIVITYPUB_ENABLEDが無効');
103+ }
104+ $test->it('メンションとハッシュタグを取り出すはず', function($test): void {
105+ $t = "Hi @bob@b.example and @alice! #タグ #php https://x.example/#notatag @not@url";
106+ $test->assertEquals(['@bob@b.example' => ['bob', 'b.example'], '@alice' => ['alice', null]], Html::mentions($t));
107+ $test->assertEquals(['タグ', 'php'], Html::hashtags($t));
108+ });
109+
110+ $test->it('プレーンテキストをHTMLにするはず', function($test): void {
111+ Fedi::$baseUrl = 'https://1.1.1.1';
112+ $html = Html::render("Hi @bob@b.example <b>\nhttps://x.example/a?b=1, #tag\n\nnext", ['@bob@b.example' => 'https://8.8.8.8/ap/users/bob']);
113+ $test->assertStringContains('<span class="h-card"><a href="https://8.8.8.8/ap/users/bob" class="u-url mention">@<span>bob</span></a></span>', $html);
114+ $test->assertStringContains('&lt;b&gt;<br />', $html);
115+ $test->assertStringContains('<a href="https://x.example/a?b=1" rel="nofollow noopener noreferrer">x.example/a?b=1</a>,', $html);
116+ $test->assertStringContains('<a href="https://1.1.1.1/microblog/tag/tag" class="mention hashtag" rel="tag">#<span>tag</span></a>', $html);
117+ $test->assertStringContains('</p><p>next</p>', $html);
118+ // 解決出来なかったメンションはリンクにしない
119+ $test->assertEquals('<p>@nobody@x.example</p>', Html::render('@nobody@x.example'));
120+ });
121+
122+ $test->it('リモートのHTMLを安全にするはず', function($test): void {
123+ $out = Html::sanitize('<p onclick="x">Hi <a href="javascript:alert(1)">x</a> <a href="https://e.x/" class="mention evil" target="_blank">@u</a>'
124+ .'<script>alert(1)</script><img src=x onerror=alert(1)><style>p{}</style><iframe src="https://e.x"></iframe><b>日本語</b></p>');
125+ $test->assertFalse(str_contains($out, '<script'));
126+ $test->assertFalse(str_contains($out, 'onerror'));
127+ $test->assertFalse(str_contains($out, 'onclick'));
128+ $test->assertFalse(str_contains($out, 'javascript:'));
129+ $test->assertFalse(str_contains($out, '<img'));
130+ $test->assertFalse(str_contains($out, '<iframe'));
131+ $test->assertFalse(str_contains($out, 'target='));
132+ $test->assertStringContains('<a href="https://e.x/" class="mention" rel="nofollow noopener noreferrer">@u</a>', $out);
133+ $test->assertStringContains('<b>日本語</b>', $out);
134+ $test->assertEquals("a\nb", Html::text('<p>a<br>b</p>'));
135+ });
136+
137+ $test->it('公開範囲を判断するはず', function($test): void {
138+ $f = 'https://x.example/users/a/followers';
139+ $test->assertEquals('public', Fedi::visibility([Fedi::PUBLIC], [$f]));
140+ $test->assertEquals('unlisted', Fedi::visibility([$f], [Fedi::PUBLIC]));
141+ $test->assertEquals('followers', Fedi::visibility([$f], []));
142+ $test->assertEquals('direct', Fedi::visibility(['https://x.example/users/b'], []));
143+ });
144+});
145+
146+$test->describe('アップロードした画像', function($test): void {
147+ $test->it('中身で種類を判断するはず', function($test): void {
148+ $test->assertEquals('image/png', Media::detect("\x89PNG\r\n\x1a\nxxxx"));
149+ $test->assertEquals('image/jpeg', Media::detect("\xff\xd8\xff\xe0xxxx"));
150+ $test->assertEquals('image/gif', Media::detect('GIF89axxxx'));
151+ $test->assertNull(Media::detect('<?php echo 1;'));
152+ });
153+
154+ $test->it('JPEGとPNGのメタデータを取り除くはず', function($test): void {
155+ $exif = "\xff\xe1".pack('n', 12).'Exif'."\0\0GPS!";
156+ $jfif = "\xff\xe0".pack('n', 6).'JFIF';
157+ $com = "\xff\xfe".pack('n', 6).'note';
158+ $jpeg = "\xff\xd8".$jfif.$exif.$com."\xff\xda".pack('n', 2)."DATA\xff\xd9";
159+ $out = Media::strip($jpeg, 'image/jpeg');
160+ $test->assertFalse(str_contains($out, 'GPS!'));
161+ $test->assertFalse(str_contains($out, 'note'));
162+ $test->assertStringContains('JFIF', $out);
163+ $test->assertTrue(str_ends_with($out, "DATA\xff\xd9"));
164+
165+ $chunk = fn($t, $d) => pack('N', strlen($d)).$t.$d.pack('N', crc32($t.$d));
166+ $png = "\x89PNG\r\n\x1a\n".$chunk('IHDR', pack('NNCCCCC', 1, 1, 8, 2, 0, 0, 0)).$chunk('tEXt', "Comment\0secret")
167+ .$chunk('eXIf', 'MM').$chunk('IDAT', 'x').$chunk('IEND', '');
168+ $out = Media::strip($png, 'image/png');
169+ $test->assertFalse(str_contains($out, 'secret'));
170+ $test->assertFalse(str_contains($out, 'eXIf'));
171+ $test->assertStringContains('IDAT', $out);
172+ $test->assertStringContains('IEND', $out);
173+ });
174+});
175+
176+$test->describe('マルチユーザーのFediverse(2つのサーバー)', function($test) use ($use, $mkuser, $seed, $pump, $tmp, $alice, $bob, $carol): void {
177+ if (!ACTIVITYPUB_MULTIUSER) {
178+ $test->skipAll('マルチユーザーモードが無効(FEDIINFOのisSingleUserがtrue、又はAUTH_ENABLEDが無効)');
179+ }
180+ if (ACTIVITYPUB_MULTIUSER) {
181+ $mkuser('A', 'alice', 1);
182+ $mkuser('A', 'carol', 2);
183+ $mkuser('A', 'dave', 3, 0); // BANされた
184+ $mkuser('B', 'bob', 1);
185+ $seed('A', 'B', 'bob');
186+ $seed('B', 'A', 'alice');
187+ $seed('B', 'A', 'carol');
188+ $use('A');
189+ Fedi::write(Fedi::path('remote/webfinger/'.sha1('bob@b.example').'.json'), ['url' => $bob, 'at' => time()]);
190+ }
191+
192+ $test->it('アクターとWebFingerを返すはず', function($test) use ($use, $alice): void {
193+ $use('A');
194+ $doc = Actor::document(Fedi::localActor('alice'));
195+ $test->assertEquals($alice, $doc['id']);
196+ $test->assertEquals("{$alice}/inbox", $doc['inbox']);
197+ $test->assertEquals('https://1.1.1.1/ap/inbox', $doc['endpoints']['sharedInbox']);
198+ $test->assertStringContains('I am alice', $doc['summary']);
199+ $test->assertEquals('https://1.1.1.1/static/user/noicon.png', $doc['icon']['url']);
200+ $test->assertEquals($alice, Actor::webfinger('acct:alice@1.1.1.1')['links'][0]['href']);
201+ $test->assertNull(Actor::webfinger('acct:alice@other.example'));
202+ $test->assertNull(Actor::webfinger('acct:nobody@1.1.1.1'));
203+ // BANされたユーザーはアクターにならない
204+ $test->assertNull(Fedi::localActor('dave'));
205+ });
206+
207+ $test->it('リモートのユーザーをフォローして承認されるはず', function($test) use ($use, $pump, $alice, $bob): void {
208+ $use('A');
209+ $test->assertTrue(Social::follow('alice', $bob)->isSuccess);
210+ $test->assertEquals('pending', Actor::following('alice')[$bob]['state']);
211+ $test->assertEquals(['Follow:202', 'Accept:202'], $pump());
212+ $use('A');
213+ $test->assertTrue(Actor::isFollowing('alice', $bob));
214+ $use('B');
215+ $test->assertTrue(Actor::isFollowedBy('bob', $alice));
216+ $test->assertEquals('follow', Actor::notifications('bob')[0]['type']);
217+ });
218+
219+ $test->it('フォローしている人の投稿がホームに届くはず', function($test) use ($use, $pump): void {
220+ $use('B');
221+ $test->assertTrue(Social::post('bob', 'Hello #fedi')->isSuccess);
222+ $test->assertEquals(['Create:202'], $pump());
223+ $use('A');
224+ [$home] = Timeline::query('home', 'alice');
225+ $test->assertCount(1, $home);
226+ $test->assertStringContains('Hello', $home[0]['object']['content']);
227+ $test->assertCount(1, Timeline::query('federated', null)[0]);
228+ $test->assertCount(0, Timeline::query('local', null)[0]);
229+ $test->assertCount(1, Timeline::query('tag', null, ['tag' => 'fedi'])[0]);
230+ });
231+
232+ $test->it('いいね・ブースト・返信が相手に届くはず', function($test) use ($use, $pump): void {
233+ $use('A');
234+ $id = Timeline::query('home', 'alice')[0][0]['object']['id'];
235+ $test->assertTrue(Social::like('alice', $id)->isSuccess);
236+ $test->assertTrue(Social::boost('alice', $id)->isSuccess);
237+ $test->assertTrue(Social::post('alice', '@bob@b.example いいね!', ['inReplyTo' => $id])->isSuccess);
238+ $pump();
239+ $use('B');
240+ $note = Notes::get(Fedi::localNoteId($id)[1]);
241+ $test->assertCount(1, $note['likes']);
242+ $test->assertCount(1, $note['boosts']);
243+ $test->assertCount(1, $note['replies']);
244+ $types = array_column(Actor::notifications('bob'), 'type');
245+ foreach (['like', 'boost', 'reply'] as $t) $test->assertTrue(in_array($t, $types, true), "通知「{$t}」が無い");
246+ // 返信は返信先のスレッドに出る
247+ $test->assertCount(1, Timeline::replies($id));
248+ });
249+
250+ $test->it('取り消しも届くはず', function($test) use ($use, $pump): void {
251+ $use('A');
252+ $id = Timeline::query('home', 'alice', ['limit' => 50])[0][count(Timeline::query('home', 'alice', ['limit' => 50])[0]) - 1]['object']['id'];
253+ $test->assertTrue(Social::unlike('alice', $id)->isSuccess);
254+ $test->assertTrue(Social::unboost('alice', $id)->isSuccess);
255+ $pump();
256+ $use('B');
257+ $note = Notes::get(Fedi::localNoteId($id)[1]);
258+ $test->assertCount(0, $note['likes']);
259+ $test->assertCount(0, $note['boosts']);
260+ });
261+
262+ $test->it('承認制のアカウントはリクエストを承認するまでフォローされないはず', function($test) use ($use, $pump, $alice, $carol): void {
263+ $use('A');
264+ Actor::saveSettings('alice', ['locked' => true]);
265+ $test->assertTrue(Social::follow('carol', $alice)->isSuccess);
266+ $test->assertEquals('pending', Actor::following('carol')[$alice]['state']);
267+ $test->assertTrue(isset(Actor::requests('alice')[$carol]));
268+ $test->assertTrue(Social::answerRequest('alice', $carol, true)->isSuccess);
269+ $test->assertTrue(Actor::isFollowing('carol', $alice));
270+ $test->assertTrue(Actor::isFollowedBy('alice', $carol));
271+ Actor::saveSettings('alice', ['locked' => false]);
272+ });
273+
274+ $test->it('フォロワー限定とダイレクトは関係ある人だけ見えるはず', function($test) use ($use, $pump): void {
275+ $use('A');
276+ $test->assertTrue(Social::post('alice', 'followers only', ['visibility' => 'followers'])->isSuccess);
277+ $test->assertTrue(Social::post('alice', '@bob@b.example secret', ['visibility' => 'direct'])->isSuccess);
278+ $pump();
279+ $use('A');
280+ $texts = fn($items) => array_map(fn($i) => Html::text($i['object']['content']), $items);
281+ $carolHome = $texts(Timeline::query('home', 'carol')[0]);
282+ $test->assertTrue(in_array('followers only', $carolHome, true), 'フォロワーに見えない');
283+ $test->assertFalse(in_array('@bob secret', $carolHome, true), '関係無い人にダイレクトが見える');
284+ $test->assertFalse(in_array('followers only', $texts(Timeline::query('local', null)[0]), true));
285+ $test->assertFalse(in_array('followers only', $texts(Timeline::query('user', null, ['user' => 'alice'])[0]), true));
286+
287+ $use('B');
288+ $bobHome = $texts(Timeline::query('home', 'bob')[0]);
289+ $test->assertTrue(in_array('@bob secret', $bobHome, true), 'ダイレクトが届かない');
290+ // bobはaliceのフォロワーではない
291+ $test->assertFalse(in_array('followers only', $bobHome, true));
292+ $test->assertTrue(in_array('mention', array_column(Actor::notifications('bob'), 'type'), true));
293+ });
294+
295+ $test->it('編集と削除が届くはず', function($test) use ($use, $pump): void {
296+ $use('B');
297+ $own = Timeline::query('user', 'bob', ['user' => 'bob'])[0][0]['object']['id'];
298+ $id = Fedi::localNoteId($own)[1];
299+ $test->assertTrue(Social::edit('bob', $id, 'Edited #changed')->isSuccess);
300+ $pump();
301+ $use('A');
302+ $test->assertEquals('Edited #changed', Html::text(Remote::stored($own)['content']));
303+ $test->assertCount(1, Timeline::query('tag', null, ['tag' => 'changed'])[0]);
304+ $test->assertCount(0, Timeline::query('tag', null, ['tag' => 'fedi'])[0]);
305+
306+ $use('B');
307+ $test->assertTrue(Social::delete('bob', $id)->isSuccess);
308+ $pump();
309+ $use('A');
310+ $test->assertNull(Remote::stored($own));
311+ $test->assertFalse(in_array($own, array_map(fn($i) => $i['object']['id'], Timeline::query('federated', null)[0]), true));
312+ });
313+
314+ $test->it('他人の投稿は編集・削除出来ないはず', function($test) use ($use): void {
315+ $use('A');
316+ $mine = Timeline::query('user', 'alice', ['user' => 'alice'])[0][0]['object']['id'];
317+ $id = Fedi::localNoteId($mine)[1];
318+ $test->assertFalse(Social::edit('carol', $id, 'x')->isSuccess);
319+ $test->assertFalse(Social::delete('carol', $id)->isSuccess);
320+ });
321+
322+ $test->it('偽装したアクティビティは受け付けないはず', function($test) use ($use, $alice, $bob): void {
323+ $use('A');
324+ // ローカルのアクターを名乗るリモートのアクティビティ
325+ $test->assertEquals(403, Inbox::handle(['type' => 'Like', 'actor' => $alice, 'object' => 'x'])[0]);
326+ // 他人の投稿として作られたCreate
327+ $test->assertEquals(403, Inbox::handle(['type' => 'Create', 'actor' => $bob,
328+ 'object' => ['type' => 'Note', 'id' => 'https://8.8.8.8/n/1', 'attributedTo' => 'https://8.8.8.8/ap/users/eve', 'to' => [Fedi::PUBLIC]]])[0]);
329+ // 違うサーバーのIDのNote
330+ $test->assertEquals(403, Inbox::handle(['type' => 'Create', 'actor' => $bob,
331+ 'object' => ['type' => 'Note', 'id' => 'https://9.9.9.9/n/1', 'attributedTo' => $bob, 'to' => [Fedi::PUBLIC]]])[0]);
332+ // 他人のフォローの取り消し
333+ $test->assertEquals(202, Inbox::handle(['type' => 'Undo', 'actor' => $bob,
334+ 'object' => ['type' => 'Follow', 'actor' => 'https://8.8.8.8/ap/users/eve', 'object' => $alice]])[0]);
335+ // 誰にも関係の無い投稿は保存しない
336+ Inbox::handle(['type' => 'Create', 'actor' => 'https://8.8.8.8/ap/users/stranger',
337+ 'object' => ['type' => 'Note', 'id' => 'https://8.8.8.8/n/spam', 'attributedTo' => 'https://8.8.8.8/ap/users/stranger', 'to' => [Fedi::PUBLIC], 'content' => 'spam']]);
338+ $test->assertNull(Remote::stored('https://8.8.8.8/n/spam'));
339+ });
340+
341+ $test->it('ブロックするとフォロー関係が無くなり、投稿が見えなくなるはず', function($test) use ($use, $pump, $alice, $bob): void {
342+ $use('B');
343+ $test->assertTrue(Social::block('bob', $alice)->isSuccess);
344+ $pump();
345+ $use('A');
346+ $test->assertFalse(Actor::isFollowing('alice', $bob));
347+ $use('B');
348+ $test->assertFalse(Actor::isFollowedBy('bob', $alice));
349+ $test->assertTrue(Actor::isBlocked('bob', $alice));
350+ // ブロックしている人からのフォローは拒否する
351+ $use('A');
352+ Social::follow('alice', $bob);
353+ $pump();
354+ $use('A');
355+ $test->assertFalse(isset(Actor::following('alice')[$bob]));
356+ $use('B');
357+ $test->assertTrue(Social::unblock('bob', $alice)->isSuccess);
358+ $pump();
359+ });
360+
361+ $test->it('サイト全体でドメインをブロック出来るはず', function($test) use ($use, $bob): void {
362+ $use('A');
363+ $test->assertFalse(Social::blockInstance('1.1.1.1')->isSuccess);
364+ $test->assertTrue(Social::blockInstance('evil.example')->isSuccess);
365+ $test->assertTrue(Actor::isDomainBlocked('https://sub.evil.example/u/x'));
366+ $test->assertFalse(Actor::isDomainBlocked('https://notevil.example/u/x'));
367+ $test->assertEquals(403, Inbox::handle(['type' => 'Follow', 'actor' => 'https://evil.example/u/x', 'object' => 'x'])[0]);
368+ // 待ち行列にも入れない
369+ Delivery::enqueue('alice', ['type' => 'Note', 'id' => 'x'], ['https://evil.example/inbox']);
370+ $test->assertEquals(0, Delivery::pending());
371+ $test->assertTrue(Social::blockInstance('evil.example', false)->isSuccess);
372+ $test->assertFalse(Actor::isDomainBlocked('https://evil.example/u/x'));
373+ });
374+
375+ $test->it('通報を記録し、相手のサーバーにも送るはず', function($test) use ($use, $pump, $bob): void {
376+ $use('A');
377+ $test->assertTrue(Social::report('alice', $bob, [], '迷惑です')->isSuccess);
378+ $test->assertEquals('迷惑です', Social::reports()[0]['content']);
379+ $pump();
380+ $use('B');
381+ $test->assertTrue((bool)Social::reports()[0]['remote']);
382+ $test->assertEquals('迷惑です', Social::reports()[0]['content']);
383+ });
384+
385+ $test->it('アカウントの削除で全部から消えるはず', function($test) use ($use, $pump, $bob): void {
386+ $use('A');
387+ Social::follow('alice', $bob);
388+ $pump();
389+ $use('A');
390+ $test->assertTrue(Actor::isFollowing('alice', $bob));
391+ Inbox::handle(['type' => 'Delete', 'actor' => $bob, 'object' => $bob]);
392+ $test->assertFalse(isset(Actor::following('alice')[$bob]));
393+ $test->assertNull(Remote::cachedActor($bob));
394+ });
395+
396+ $test->it('待ち行列は失敗したら間隔を空けて再試行するはず', function($test) use ($use): void {
397+ $use('A');
398+ foreach (glob(Fedi::path('queue/').'*.json') ?: [] as $f) unlink($f);
399+ $failing = new class extends Delivery {
400+ public static int $calls = 0;
401+ public static bool $ok = false;
402+ protected static function send(string $from, string $inbox, array $activity): bool {
403+ self::$calls++;
404+ return self::$ok;
405+ }
406+ };
407+ $failing::enqueue('alice', ['type' => 'Like', 'id' => 'x'], ['https://9.9.9.9/inbox']);
408+ $res = $failing::run();
409+ $test->assertEquals(['sent' => 0, 'failed' => 1, 'dropped' => 0], $res->data);
410+ // 次の時刻までは送らない
411+ $failing::run();
412+ $test->assertEquals(1, $failing::$calls);
413+ $job = glob(Fedi::path('queue/').'*.json')[0];
414+ $data = Fedi::read($job);
415+ $data['next'] = time() - 1;
416+ Fedi::write($job, $data);
417+ $failing::$ok = true;
418+ $test->assertEquals(['sent' => 1, 'failed' => 0, 'dropped' => 0], $failing::run()->data);
419+ $test->assertEquals(0, Delivery::pending());
420+ });
421+
422+ $test->it('古いリモートの投稿を消すはず', function($test) use ($use): void {
423+ $use('A');
424+ $old = ['id' => 'https://8.8.8.8/n/old', 'type' => 'Note', 'attributedTo' => 'https://8.8.8.8/ap/users/x',
425+ 'to' => [Fedi::PUBLIC], 'content' => 'old', 'published' => Fedi::now(time() - 200 * 86400)];
426+ Remote::store($old);
427+ Timeline::add($old);
428+ $res = Timeline::cleanup(90);
429+ $test->assertTrue($res->data['removed'] >= 1);
430+ $test->assertNull(Remote::stored($old['id']));
431+ // ローカルの投稿は消さない
432+ $test->assertTrue(count(Timeline::query('user', 'alice', ['user' => 'alice'])[0]) > 0);
433+ });
434+
435+ $test->it('GETの署名を検証出来るはず', function($test) use ($use, $alice): void {
436+ $use('A');
437+ $key = Actor::privateKey('alice');
438+ if ($key === null) {
439+ $test->assertTrue(true);
440+ return; // OpenSSLの設定が無い環境
441+ }
442+ $ap = new class extends Activitypub {
443+ public array $docs = [];
444+ public function fetchRemoteJson(string $url): ?array { return $this->docs[$url] ?? null; }
445+ };
446+ $ap->docs[$alice] = Actor::document(Fedi::localActor('alice'));
447+ $headers = [];
448+ foreach ($ap->signRequest('https://8.8.8.8/ap/users/bob/notes/x', '', $key, 'get', Actor::keyId('alice')) as $line) {
449+ [$k, $v] = explode(': ', $line, 2);
450+ $headers[$k] = $v;
451+ }
452+ $server = $_SERVER;
453+ try {
454+ $_SERVER['REQUEST_METHOD'] = 'GET';
455+ $_SERVER['REQUEST_URI'] = '/ap/users/bob/notes/x';
456+ $_SERVER['HTTP_HOST'] = $headers['Host'];
457+ $_SERVER['HTTP_DATE'] = $headers['Date'];
458+ $_SERVER['HTTP_SIGNATURE'] = $headers['Signature'];
459+ $test->assertEquals($alice, $ap->signer());
460+ $_SERVER['REQUEST_URI'] = '/ap/users/bob/notes/y';
461+ $test->assertNull($ap->signer());
462+ } finally {
463+ $_SERVER = $server;
464+ }
465+ });
466+});
467+
468+// 後片付け
469+Fedi::$baseUrl = Fedi::$dataDir = Fedi::$userDir = null;
470+$rm = function(string $dir) use (&$rm): void {
471+ foreach (scandir($dir) ?: [] as $f) {
472+ if ($f === '.' || $f === '..') continue;
473+ is_dir("{$dir}/{$f}") ? $rm("{$dir}/{$f}") : @unlink("{$dir}/{$f}");
474+ }
475+ @rmdir($dir);
476+};
477+if (is_dir($tmp)) $rm($tmp);
478+
479+$test->printSummary();
変更 util.php
@@ -37,6 +37,7 @@
3737 */
3838 if (!CURL_ENABLED) define('ACTIVITYPUB_ENABLED', false);
3939 foreach (['MAILER_ENABLED', 'OPENPROVIDER_ENABLED', 'CRON_ENABLED', 'AUTH_VERIFY_EMAIL'] as $c) if (!defined($c)) define($c, false);
40+define('ACTIVITYPUB_MULTIUSER', ACTIVITYPUB_ENABLED && AUTH_ENABLED && defined('FEDIINFO') && !(FEDIINFO['isSingleUser'] ?? true));
4041
4142 enum LogType {
4243 case ActivityPub;
@@ -79,6 +80,26 @@
7980 public const int FSUBSCRIBER = 1 << 7; // 128 (NINTENDNDA + PLAYSTATIONNDA + SUBSCRIBER)
8081 public const int STAFF = 1 << 8; // 256
8182 public const int ADMIN = 1 << 9; // 512
83+ public const int FEDIVERSE = 1 << 10; // 1024(マルチユーザーのFediverseを使える)
84+
85+ // 支援者(以前の「role >= SUBSCRIBER」と同じ範囲。ビットを足しても壊れない様に&で確認する)
86+ public const int PAID = self::SUBSCRIBER | self::NSUBSCRIBER | self::PSUBSCRIBER | self::FSUBSCRIBER
87+ | self::STAFF | self::ADMIN;
88+
89+ // プロフィールの編集画面で管理者が付け外し出来るロール
90+ public const array LABELS = [
91+ self::MEMBER => 'メンバー',
92+ self::NINTENDONDA => '任天堂NDA',
93+ self::PLAYSTATIONNDA => 'PlayStation NDA',
94+ self::FULLNDA => '全NDA',
95+ self::SUBSCRIBER => '支援者',
96+ self::NSUBSCRIBER => '支援者(任天堂NDA)',
97+ self::PSUBSCRIBER => '支援者(PlayStation NDA)',
98+ self::FSUBSCRIBER => '支援者(全NDA)',
99+ self::STAFF => 'スタッフ',
100+ self::ADMIN => '管理者',
101+ self::FEDIVERSE => 'Fediverse',
102+ ];
82103 }
83104
84105 class Gender {
@@ -371,6 +392,17 @@
371392 $suffix = $userData->gender === Gender::MALE ? 'くん' : ($userData->gender === Gender::FEMALE ? 'ちゃん' : 'さん');
372393
373394 return $color.$suffix;
395+}
396+
397+/**
398+ * Fediverseの機能を使えるか
399+ * マルチユーザーモードでは、管理者が外していなければ全員(BANされたユーザー以外)が使える。
400+ * 管理者がロールを変えると「fediDefault」が記録され、その後はFEDIVERSEのビットで判断する。
401+ */
402+function can_federate(\stdClass $user): bool {
403+ if (!ACTIVITYPUB_MULTIUSER || ($user->role ?? 0) === Roles::BANNED) return false;
404+ if (($user->role ?? 0) & Roles::FEDIVERSE) return true;
405+ return !isset($user->fediDefault);
374406 }
375407
376408 /**
@@ -0,0 +1,50 @@
1+{@ include(common/header) @}
2+{@ include(microblog/nav) @}
3+ <h1 class="paragraph">Fediverseの管理</h1>
4+{@ if ($error) @}
5+ <div class="errormes">{{ $error }}</div>
6+{@ endif @}
7+ <p class="paragraph">配信待ち:{{ $queue }}件(cronのapdeliverが送ります)</p>
8+ <h2 class="paragraph">ブロックしているサーバー</h2>
9+ <p class="paragraph mb-small">ブロックしたサーバー(とそのサブドメイン)からは何も受け取らず、何も送りません。</p>
10+{@ foreach ($domains as $d) @}
11+ <div class="mb-row">
12+ {{ $d }}
13+ <form method="post" action="/microblog/admin">
14+ <input type="hidden" name="csrf_token" value="{{ $csrf }}" />
15+ <input type="hidden" name="domain" value="{{ $d }}" />
16+ <button name="do" value="unblockinstance">解除</button>
17+ </form>
18+ </div>
19+{@ endforeach @}
20+ <form class="mb-row" method="post" action="/microblog/admin">
21+ <input type="hidden" name="csrf_token" value="{{ $csrf }}" />
22+ <input type="text" name="domain" placeholder="example.com" />
23+ <button name="do" value="blockinstance" class="danger">サーバーをブロック</button>
24+ </form>
25+ <h2 class="paragraph">通報</h2>
26+{@ if (empty($reports)) @}
27+ <p class="paragraph">通報はありません。</p>
28+{@ endif @}
29+{@ foreach ($reports as $r) @}
30+ <div class="mb-report-item{{ $r['resolved'] ? ' resolved' : '' }}">
31+ <p>
32+ {{ $r['at'] }}:<a href="{{ $r['fromWho']['page'] }}">{{ $r['fromWho']['handle'] }}</a>{{ $r['remote'] ? '(他のサーバー)' : '' }}から
33+ <a href="{{ $r['actorWho']['page'] }}">{{ $r['actorWho']['handle'] }}</a>への通報{{ $r['resolved'] ? '(対応済み)' : '' }}
34+ </p>
35+{@ if ($r['content'] !== '') @}
36+ <blockquote>{{ $r['content'] }}</blockquote>
37+{@ endif @}
38+{@ foreach ($r['objectPages'] as $op) @}
39+ <a href="{{ $op }}" class="mb-small">対象の投稿</a>
40+{@ endforeach @}
41+{@ if (!$r['resolved']) @}
42+ <form method="post" action="/microblog/admin">
43+ <input type="hidden" name="csrf_token" value="{{ $csrf }}" />
44+ <input type="hidden" name="id" value="{{ $r['id'] }}" />
45+ <button name="do" value="resolve">対応済みにする</button>
46+ </form>
47+{@ endif @}
48+ </div>
49+{@ endforeach @}
50+{@ include(common/footer) @}
@@ -0,0 +1,52 @@
1+{@ include(common/header) @}
2+{@ include(microblog/nav) @}
3+ <h1 class="paragraph">ブロック</h1>
4+ <h2 class="paragraph">アカウント</h2>
5+{@ if (empty($actors)) @}
6+ <p class="paragraph">ブロックしているアカウントはありません。</p>
7+{@ endif @}
8+{@ foreach ($actors as $a) @}
9+ <div class="mb-row">
10+ <a href="{{ $a['who']['page'] }}">{{ $a['who']['name'] }}</a> <span class="mb-handle">{{ $a['who']['handle'] }}</span>
11+ <form method="post" action="/microblog/action">
12+ <input type="hidden" name="csrf_token" value="{{ $csrf }}" />
13+ <input type="hidden" name="back" value="/microblog/blocks" />
14+ <input type="hidden" name="target" value="{{ $a['actor'] }}" />
15+ <button name="do" value="unblock">解除</button>
16+ </form>
17+ </div>
18+{@ endforeach @}
19+ <h2 class="paragraph">ドメイン</h2>
20+{@ foreach ($domains as $d) @}
21+ <div class="mb-row">
22+ {{ $d }}
23+ <form method="post" action="/microblog/action">
24+ <input type="hidden" name="csrf_token" value="{{ $csrf }}" />
25+ <input type="hidden" name="back" value="/microblog/blocks" />
26+ <input type="hidden" name="target" value="{{ $d }}" />
27+ <button name="do" value="unblockdomain">解除</button>
28+ </form>
29+ </div>
30+{@ endforeach @}
31+ <form class="mb-row" method="post" action="/microblog/action">
32+ <input type="hidden" name="csrf_token" value="{{ $csrf }}" />
33+ <input type="hidden" name="back" value="/microblog/blocks" />
34+ <input type="text" name="target" placeholder="example.com" />
35+ <button name="do" value="blockdomain" class="danger">ドメインをブロック</button>
36+ </form>
37+ <h2 class="paragraph">フォロワー</h2>
38+{@ if (empty($followers)) @}
39+ <p class="paragraph">フォロワーはいません。</p>
40+{@ endif @}
41+{@ foreach ($followers as $a) @}
42+ <div class="mb-row">
43+ <a href="{{ $a['who']['page'] }}">{{ $a['who']['name'] }}</a> <span class="mb-handle">{{ $a['who']['handle'] }}</span>
44+ <form method="post" action="/microblog/action">
45+ <input type="hidden" name="csrf_token" value="{{ $csrf }}" />
46+ <input type="hidden" name="back" value="/microblog/blocks" />
47+ <input type="hidden" name="target" value="{{ $a['actor'] }}" />
48+ <button name="do" value="removefollower">フォロワーから外す</button>
49+ </form>
50+ </div>
51+{@ endforeach @}
52+{@ include(common/footer) @}
@@ -0,0 +1,29 @@
1+{@ if ($me) @}
2+ <form class="mb-compose" method="post" action="/microblog/post" enctype="multipart/form-data" id="reply">
3+ <input type="hidden" name="csrf_token" value="{{ $csrf }}" />
4+ <input type="hidden" name="back" value="{{ $here }}" />
5+{@ if ($replyTo) @}
6+ <input type="hidden" name="inReplyTo" value="{{ $replyTo }}" />
7+ <p class="mb-small">返信</p>
8+{@ endif @}
9+ <input type="text" name="cw" maxlength="200" placeholder="注意書き(任意。入力すると本文が隠れます)" value="{{ $draftCw }}" />
10+ <textarea name="content" rows="4" maxlength="{{ $maxLength }}" placeholder="いまどうしてる?">{{ $draft !== '' ? $draft : $replyText }}</textarea>
11+ <details>
12+ <summary>画像を添付({{ $maxMedia }}枚まで、PNG・JPEG・GIF)</summary>
13+{@ for ($i = 0; $i < $maxMedia; $i++) @}
14+ <div class="mb-file">
15+ <input type="file" name="media[]" accept="image/png,image/jpeg,image/gif" />
16+ <input type="text" name="alt[]" maxlength="1500" placeholder="画像の説明(代替テキスト)" />
17+ </div>
18+{@ endfor @}
19+ </details>
20+ <div class="mb-compose-bottom">
21+ <select name="visibility">
22+{@ foreach ($visibilities as $vk => $vl) @}
23+ <option value="{{ $vk }}"{{{ $vk === $replyVis ? ' selected="selected"' : '' }}}>{{ $vl }}</option>
24+{@ endforeach @}
25+ </select>
26+ <button>投稿</button>
27+ </div>
28+ </form>
29+{@ endif @}
@@ -0,0 +1,17 @@
1+{@ include(common/header) @}
2+{@ include(microblog/nav) @}
3+ <h1 class="paragraph">投稿の編集</h1>
4+{@ if ($error) @}
5+ <div class="errormes">{{ $error }}</div>
6+{@ endif @}
7+ <form class="mb-compose" method="post" action="/microblog/edit">
8+ <input type="hidden" name="csrf_token" value="{{ $csrf }}" />
9+ <input type="hidden" name="id" value="{{ $note['id'] }}" />
10+ <input type="text" name="cw" maxlength="200" placeholder="注意書き(任意)" value="{{ $note['summary'] }}" />
11+ <textarea name="content" rows="6" maxlength="{{ $maxLength }}">{{ $note['source'] }}</textarea>
12+ <p class="mb-small">公開範囲({{ $visibilities[$note['visibility']] }})と添付は変えられません。</p>
13+ <div class="mb-compose-bottom">
14+ <button>保存</button>
15+ </div>
16+ </form>
17+{@ include(common/footer) @}
@@ -0,0 +1,73 @@
1+ <article class="mb-note">
2+{@ if ($it['booster']) @}
3+ <p class="mb-small">🔁 <a href="{{ $it['booster']['page'] }}">{{ $it['booster']['name'] }}</a>がブースト</p>
4+{@ endif @}
5+ <header class="mb-author">
6+ <img src="{{ $it['author']['avatar'] }}" alt="" class="mb-avatar" loading="lazy" />
7+ <div>
8+ <a href="{{ $it['author']['page'] }}" class="mb-name">{{ $it['author']['name'] }}</a>
9+ <span class="mb-handle">{{ $it['author']['handle'] }}</span>
10+ </div>
11+ </header>
12+{@ if ($it['reply']) @}
13+ <p class="mb-small"><a href="{{ $it['reply'] }}">↩ 返信先</a></p>
14+{@ endif @}
15+{@ if ($it['sensitive']) @}
16+ <details class="mb-cw">
17+ <summary>{{ $it['summary'] !== '' ? $it['summary'] : 'センシティブな内容' }}</summary>
18+{@ endif @}
19+ <div class="mb-content">{{{ $it['content'] }}}</div>
20+{@ if (!empty($it['attachments'])) @}
21+ <div class="mb-media">
22+{@ foreach ($it['attachments'] as $a) @}
23+{@ if ($a['kind'] === 'image') @}
24+ <a href="{{ $a['url'] }}"><img src="{{ $a['url'] }}" alt="{{ $a['alt'] }}" title="{{ $a['alt'] }}" loading="lazy" /></a>
25+{@ elif ($a['kind'] === 'video') @}
26+ <video src="{{ $a['url'] }}" controls="controls" preload="none" title="{{ $a['alt'] }}"></video>
27+{@ elif ($a['kind'] === 'audio') @}
28+ <audio src="{{ $a['url'] }}" controls="controls" preload="none" title="{{ $a['alt'] }}"></audio>
29+{@ else @}
30+ <a href="{{ $a['url'] }}">{{ $a['alt'] !== '' ? $a['alt'] : $a['url'] }}</a>
31+{@ endif @}
32+{@ endforeach @}
33+ </div>
34+{@ endif @}
35+{@ if ($it['sensitive']) @}
36+ </details>
37+{@ endif @}
38+ <footer class="mb-meta">
39+ <a href="{{ $it['page'] }}">{{ $it['published'] }}</a>{{ $it['edited'] ? '(編集済み)' : '' }} · {{ $it['vis'] }}
40+{@ if ($it['likes'] !== null) @}
41+ · ☆{{ $it['likes'] }} 🔁{{ $it['boosts'] }} 💬{{ $it['replies'] }}
42+{@ endif @}
43+ · <a href="{{ $it['original'] }}">元の投稿</a>
44+ </footer>
45+{@ if ($me) @}
46+ <div class="mb-actions">
47+ <a href="{{ $it['page'] }}#reply" class="mb-button">返信</a>
48+ <form method="post" action="/microblog/action">
49+ <input type="hidden" name="csrf_token" value="{{ $csrf }}" />
50+ <input type="hidden" name="back" value="{{ $here }}" />
51+ <input type="hidden" name="target" value="{{ $it['id'] }}" />
52+ <button name="do" value="{{ $it['liked'] ? 'unlike' : 'like' }}" class="{{ $it['liked'] ? 'on' : '' }}">{{ $it['liked'] ? '★ いいね済み' : '☆ いいね' }}</button>
53+{@ if ($it['canBoost']) @}
54+ <button name="do" value="{{ $it['boosted'] ? 'unboost' : 'boost' }}" class="{{ $it['boosted'] ? 'on' : '' }}">{{ $it['boosted'] ? '🔁 ブースト済み' : '🔁 ブースト' }}</button>
55+{@ endif @}
56+ </form>
57+{@ if ($it['mine']) @}
58+{@ if ($it['editUrl']) @}
59+ <a href="{{ $it['editUrl'] }}" class="mb-button">編集</a>
60+{@ endif @}
61+ <details class="mb-confirm">
62+ <summary>削除</summary>
63+ <form method="post" action="/microblog/action">
64+ <input type="hidden" name="csrf_token" value="{{ $csrf }}" />
65+ <input type="hidden" name="back" value="{{ $here }}" />
66+ <input type="hidden" name="target" value="{{ $it['id'] }}" />
67+ <button name="do" value="delete" class="danger">本当に削除する</button>
68+ </form>
69+ </details>
70+{@ endif @}
71+ </div>
72+{@ endif @}
73+ </article>
@@ -0,0 +1,5 @@
1+{@ include(common/header) @}
2+{@ include(microblog/nav) @}
3+ <div class="errormes">{{ $msg }}</div>
4+ <p class="paragraph"><a href="{{ $back }}">戻る</a></p>
5+{@ include(common/footer) @}
@@ -0,0 +1,21 @@
1+ <div class="mb-nav">
2+ <nav class="mb-tabs">
3+{@ if ($me) @}
4+ <a href="/microblog" class="{{ $scope === 'home' ? 'active' : '' }}">ホーム</a>
5+{@ endif @}
6+ <a href="/microblog/local" class="{{ $scope === 'local' ? 'active' : '' }}">ローカル</a>
7+ <a href="/microblog/federated" class="{{ $scope === 'federated' ? 'active' : '' }}">連合</a>
8+{@ if ($me) @}
9+ <a href="/microblog/notifications" class="{{ $scope === 'notifications' ? 'active' : '' }}">通知{{ $unread > 0 ? '('.$unread.')' : '' }}</a>
10+ <a href="/microblog/user/{{ $me }}">自分</a>
11+ <a href="/microblog/blocks">ブロック</a>
12+{@ endif @}
13+{@ if ($isAdmin) @}
14+ <a href="/microblog/admin">管理</a>
15+{@ endif @}
16+ </nav>
17+ <form class="mb-search" method="get" action="/microblog/search">
18+ <input type="search" name="q" placeholder="@user@example.com、URL、#タグ、文字" value="{{ $q ?? '' }}" />
19+ <button>検索</button>
20+ </form>
21+ </div>
@@ -0,0 +1,37 @@
1+{@ include(common/header) @}
2+{@ include(microblog/nav) @}
3+{@ foreach ($ancestors as $it) @}
4+ <div class="mb-ancestor">
5+{@ include(microblog/item) @}
6+ </div>
7+{@ endforeach @}
8+ <div class="mb-focus">
9+{$ $it = $item $}
10+{@ include(microblog/item) @}
11+ </div>
12+{@ if ($me && !$item['mine']) @}
13+ <details class="mb-report">
14+ <summary>この投稿を通報する</summary>
15+ <form method="post" action="/microblog/action">
16+ <input type="hidden" name="csrf_token" value="{{ $csrf }}" />
17+ <input type="hidden" name="back" value="{{ $here }}" />
18+ <input type="hidden" name="target" value="{{ $item['author']['url'] }}" />
19+ <input type="hidden" name="object" value="{{ $item['id'] }}" />
20+ <textarea name="comment" rows="2" maxlength="1000" placeholder="理由(任意)"></textarea>
21+{@ if (!str_starts_with($item['author']['page'], '/microblog/user/')) @}
22+ <label><input type="checkbox" name="forward" value="1" checked="checked" /> 相手のサーバーの管理者にも送る</label>
23+{@ endif @}
24+ <button name="do" value="report">通報</button>
25+ </form>
26+ </details>
27+{@ endif @}
28+{@ include(microblog/compose) @}
29+{@ if (!empty($replies)) @}
30+ <h2 class="paragraph">返信</h2>
31+{@ foreach ($replies as $it) @}
32+ <div class="mb-replyitem">
33+{@ include(microblog/item) @}
34+ </div>
35+{@ endforeach @}
36+{@ endif @}
37+{@ include(common/footer) @}
@@ -0,0 +1,34 @@
1+{@ include(common/header) @}
2+{@ include(microblog/nav) @}
3+{@ if (!empty($requests)) @}
4+ <h2 class="paragraph">フォローリクエスト</h2>
5+{@ foreach ($requests as $r) @}
6+ <div class="mb-row">
7+ <img src="{{ $r['who']['avatar'] }}" alt="" class="mb-avatar" />
8+ <a href="{{ $r['who']['page'] }}">{{ $r['who']['name'] }}</a> <span class="mb-handle">{{ $r['who']['handle'] }}</span>
9+ <form method="post" action="/microblog/action">
10+ <input type="hidden" name="csrf_token" value="{{ $csrf }}" />
11+ <input type="hidden" name="back" value="/microblog/notifications" />
12+ <input type="hidden" name="target" value="{{ $r['actor'] }}" />
13+ <button name="do" value="accept" class="on">承認</button>
14+ <button name="do" value="reject">拒否</button>
15+ </form>
16+ </div>
17+{@ endforeach @}
18+{@ endif @}
19+ <h1 class="paragraph">通知</h1>
20+{@ if (empty($list)) @}
21+ <p class="paragraph">通知はありません。</p>
22+{@ endif @}
23+{@ foreach ($list as $n) @}
24+ <div class="mb-row{{ $n['read'] ? '' : ' unread' }}">
25+ <img src="{{ $n['who']['avatar'] }}" alt="" class="mb-avatar" />
26+ <div>
27+ <a href="{{ $n['who']['page'] }}">{{ $n['who']['name'] }}</a>{{ $n['label'] }} <span class="mb-small">{{ $n['at'] }}</span>
28+{@ if ($n['page']) @}
29+ <br /><a href="{{ $n['page'] }}" class="mb-small">{{ $n['excerpt'] !== '' ? $n['excerpt'] : '投稿' }}</a>
30+{@ endif @}
31+ </div>
32+ </div>
33+{@ endforeach @}
34+{@ include(common/footer) @}
@@ -0,0 +1,84 @@
1+{@ include(common/header) @}
2+{@ include(microblog/nav) @}
3+ <section class="mb-profile">
4+ <img src="{{ $profile['avatar'] }}" alt="" class="mb-avatar-large" />
5+ <div>
6+ <h1>{{ $profile['name'] }}{{ $profile['locked'] ? ' 🔒' : '' }}</h1>
7+ <p class="mb-handle">{{ $profile['handle'] }}</p>
8+{@ if ($profile['bio'] !== '') @}
9+ <div class="mb-bio">{{{ $profile['bio'] }}}</div>
10+{@ endif @}
11+ <p class="mb-small">
12+{@ if ($profile['followers'] !== null) @}
13+ フォロワー {{ $profile['followers'] }} · フォロー {{ $profile['following'] }} ·
14+{@ endif @}
15+{@ if ($profile['profilePage'] !== '') @}
16+ <a href="{{ $profile['profilePage'] }}">プロフィール</a>
17+{@ endif @}
18+{@ if (!$profile['local']) @}
19+ · <a href="/microblog/remote?actor={{ rawurlencode($profile['url']) }}&amp;refresh=1">更新</a>
20+{@ endif @}
21+ </p>
22+{@ if ($relation['can'] && !$relation['self']) @}
23+ <p class="mb-small">{{ $relation['followsYou'] ? 'あなたをフォローしています' : '' }}</p>
24+ <div class="mb-actions">
25+ <form method="post" action="/microblog/action">
26+ <input type="hidden" name="csrf_token" value="{{ $csrf }}" />
27+ <input type="hidden" name="back" value="{{ $here }}" />
28+ <input type="hidden" name="target" value="{{ $profile['url'] }}" />
29+{@ if ($relation['blocked']) @}
30+ <button name="do" value="unblock">ブロックを解除</button>
31+{@ else @}
32+{@ if ($relation['following'] || $relation['pending']) @}
33+ <button name="do" value="unfollow">{{ $relation['pending'] ? 'リクエストを取り消す' : 'フォロー解除' }}</button>
34+{@ else @}
35+ <button name="do" value="follow" class="on">フォロー</button>
36+{@ endif @}
37+{@ if ($relation['followsYou']) @}
38+ <button name="do" value="removefollower">フォロワーから外す</button>
39+{@ endif @}
40+{@ endif @}
41+ </form>
42+{@ if (!$relation['blocked']) @}
43+ <details class="mb-confirm">
44+ <summary>ブロック・通報</summary>
45+ <form method="post" action="/microblog/action">
46+ <input type="hidden" name="csrf_token" value="{{ $csrf }}" />
47+ <input type="hidden" name="back" value="{{ $here }}" />
48+ <input type="hidden" name="target" value="{{ $profile['url'] }}" />
49+ <button name="do" value="block" class="danger">ブロック</button>
50+ </form>
51+{@ if ($relation['remote']) @}
52+ <form method="post" action="/microblog/action">
53+ <input type="hidden" name="csrf_token" value="{{ $csrf }}" />
54+ <input type="hidden" name="back" value="{{ $here }}" />
55+ <input type="hidden" name="target" value="{{ $relation['domain'] }}" />
56+ <button name="do" value="blockdomain" class="danger">{{ $relation['domain'] }}を全部ブロック</button>
57+ </form>
58+{@ endif @}
59+ <form method="post" action="/microblog/action">
60+ <input type="hidden" name="csrf_token" value="{{ $csrf }}" />
61+ <input type="hidden" name="back" value="{{ $here }}" />
62+ <input type="hidden" name="target" value="{{ $profile['url'] }}" />
63+ <textarea name="comment" rows="2" maxlength="1000" placeholder="通報の理由(任意)"></textarea>
64+{@ if ($relation['remote']) @}
65+ <label><input type="checkbox" name="forward" value="1" checked="checked" /> 相手のサーバーの管理者にも送る</label>
66+{@ endif @}
67+ <button name="do" value="report">通報</button>
68+ </form>
69+ </details>
70+{@ endif @}
71+ </div>
72+{@ endif @}
73+ </div>
74+ </section>
75+{@ if (empty($items)) @}
76+ <p class="paragraph">表示出来る投稿はありません。</p>
77+{@ endif @}
78+{@ foreach ($items as $it) @}
79+{@ include(microblog/item) @}
80+{@ endforeach @}
81+{@ if ($next) @}
82+ <p class="mb-more"><a href="{{ $next }}">古い投稿</a></p>
83+{@ endif @}
84+{@ include(common/footer) @}
@@ -0,0 +1,14 @@
1+{@ include(common/header) @}
2+{@ include(microblog/nav) @}
3+ <h1 class="paragraph">検索</h1>
4+ <p class="paragraph mb-small">
5+ 「@user@example.com」で他のサーバーのアカウント、投稿のURLで他のサーバーの投稿、「#タグ」でハッシュタグ、
6+ それ以外は受け取った投稿と、このサイトの投稿の本文を探します。
7+ </p>
8+{@ if ($error) @}
9+ <div class="errormes">{{ $error }}</div>
10+{@ endif @}
11+{@ foreach ($items as $it) @}
12+{@ include(microblog/item) @}
13+{@ endforeach @}
14+{@ include(common/footer) @}
@@ -0,0 +1,19 @@
1+{@ include(common/header) @}
2+{@ include(microblog/nav) @}
3+{@ if ($error) @}
4+ <div class="errormes">{{ $error }}</div>
5+{@ endif @}
6+{@ if ($scope === 'home') @}
7+{@ include(microblog/compose) @}
8+{@ endif @}
9+ <h1 class="paragraph">{{ $heading }}</h1>
10+{@ if (empty($items)) @}
11+ <p class="paragraph">投稿はまだありません。{{ $scope === 'home' ? '検索から他の人をフォローしてみて下さい。' : '' }}</p>
12+{@ endif @}
13+{@ foreach ($items as $it) @}
14+{@ include(microblog/item) @}
15+{@ endforeach @}
16+{@ if ($next) @}
17+ <p class="mb-more"><a href="{{ $next }}">古い投稿</a></p>
18+{@ endif @}
19+{@ include(common/footer) @}