master
ブランチ master
LittleBeast / src / Std / Lib / Fedi / Fedi.php
11.4 KB、313行 生 Blame 履歴
1<?php
2/*************************************************************
3# 076 License
4
5Copyright (c) テクニカル諏訪子
6
7Permission is hereby granted to any person obtaining a copy of the software
8Little Beast (the "Software") to use, modify, merge, copy, publish, distribute,
9sublicense, and/or sell copies of the Software, subject to the following conditions:
10
11 1. **Origin Attribution**:
12 - You must not misrepresent the origin of the Software; you must not claim
13 you created the original Software.
14 - If the Software is used in a product, you must either:
15 a. Provide clear attribution in the product's documentation, user interface,
16 or other visible areas, **OR**
17 b. Pay the original developers a fee they specify in writing.
18 2. **Usage Restriction**:
19 - The Software, or any derivative works, dependencies, or libraries
20 incorporating it, must not be used for censorship or to suppress freedom of
21 speech, expression, or creativity. Prohibited uses include, but are not
22 limited to:
23 - Censorship of so-called "hate speech", visuals, non-mainstream opinions,
24 ideas, or objective reality.
25 - Tools or systems designed to restrict access to information or
26 artistic works.
27 3. **Notice Preservation**:
28 - This license and the above copyright notice must remain intact in all copies
29 of the source code.
30
31THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
32INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
33PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
34HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
35CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
36OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
37 */
38namespace Std\Lib\Fedi;
39
40use 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 */
57final 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 = self::configuredDomain();
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 /**
102 * FEDIINFOの「domain」をURLにする(「example.com」だけでも「https://example.com」にする)
103 *
104 * @return string 末尾の「/」なし、未設定なら空
105 */
106 public static function configuredDomain(): string {
107 return self::normalizeDomain((string)(defined('FEDIINFO') ? (FEDIINFO['domain'] ?? '') : ''));
108 }
109
110 public static function normalizeDomain(string $domain): string {
111 $domain = rtrim(trim($domain), '/');
112 if ($domain === '') return '';
113 // 以前はスキームが無いと「example.com/ap/actor」の様な不正なIDになった
114 if (!preg_match('#^https?://#i', $domain)) $domain = 'https://'.$domain;
115 return $domain;
116 }
117
118 public static function host(): string {
119 return (string)parse_url(self::base(), PHP_URL_HOST);
120 }
121
122 public static function actorUrl(string $username): string {
123 return self::base().'/ap/users/'.$username;
124 }
125
126 public static function noteUrl(string $username, string $id): string {
127 return self::actorUrl($username).'/notes/'.$id;
128 }
129
130 /** ブログのアクター */
131 public static function blogActorUrl(): string {
132 return self::base().'/ap/actor';
133 }
134
135 public static function sharedInbox(): string {
136 return self::base().'/ap/inbox';
137 }
138
139 /** このサイトのURLか */
140 public static function isLocal(string $url): bool {
141 $base = self::base();
142 return $url === $base || str_starts_with($url, $base.'/');
143 }
144
145 /**
146 * ローカルのアクターのURLからユーザー名を取り出す
147 *
148 * @return string|null ローカルのユーザーでなければnull
149 */
150 public static function localUsername(string $actorUrl): ?string {
151 $prefix = self::base().'/ap/users/';
152 if (!str_starts_with($actorUrl, $prefix)) return null;
153 $name = substr($actorUrl, strlen($prefix));
154 return preg_match('/^[A-Za-z0-9]+$/', $name) ? $name : null;
155 }
156
157 /**
158 * ローカルの投稿のURLからIDを取り出す
159 *
160 * @return array|null [ユーザー名, ID]
161 */
162 public static function localNoteId(string $url): ?array {
163 if (!preg_match('#^'.preg_quote(self::base().'/ap/users/', '#').'([A-Za-z0-9]+)/notes/([0-9a-f]{20})$#', $url, $m)) return null;
164 return [$m[1], $m[2]];
165 }
166
167 /** URLのホスト名(小文字) */
168 public static function domainOf(string $url): string {
169 return strtolower((string)parse_url($url, PHP_URL_HOST));
170 }
171
172 //////// ユーザー
173
174 /**
175 * ローカルのユーザーのデータ(パスワード等を除く)
176 *
177 * @param string $username ユーザー名
178 * @return \stdClass|null
179 */
180 public static function localUser(string $username): ?\stdClass {
181 if (!preg_match('/^[A-Za-z0-9]+$/', $username)) return null;
182 $dir = self::$userDir ?? ROOT.'/data/user/';
183 foreach (glob($dir.'*.'.$username.'.json') ?: [] as $f) {
184 if (!preg_match('/^\d+\.'.$username.'\.json$/', basename($f))) continue;
185 $u = json_decode((string)file_get_contents($f));
186 if (!$u instanceof \stdClass || ($u->username ?? '') !== $username) continue;
187 unset($u->password, $u->tokens, $u->verifyToken, $u->twoFactor, $u->twoFactorPending, $u->twoFactorTicket);
188 return $u;
189 }
190 return null;
191 }
192
193 /**
194 * Fediverseのアクターとして使えるローカルのユーザー(ロールがあり、メール確認済み)
195 */
196 public static function localActor(string $username): ?\stdClass {
197 $u = self::localUser($username);
198 if (!$u || !can_federate($u) || (($u->verified ?? true) === false)) return null;
199 return $u;
200 }
201
202 //////// ファイル
203
204 /**
205 * data/ap/の下のパス(ディレクトリは作る)
206 */
207 public static function path(string $rel): string {
208 $base = rtrim(self::$dataDir ?? ROOT.'/data/ap', '/');
209 $path = $base.'/'.ltrim($rel, '/');
210 $dir = dirname($path);
211 if (!is_dir($dir)) @mkdir($dir, 0700, true);
212 return $path;
213 }
214
215 public static function userPath(string $username, string $file): string {
216 return self::path("users/{$username}/{$file}");
217 }
218
219 /**
220 * JSONファイルを読む
221 */
222 public static function read(string $path, mixed $default = []): mixed {
223 if (!is_file($path)) return $default;
224 $data = json_decode((string)file_get_contents($path), true);
225 return $data ?? $default;
226 }
227
228 /**
229 * JSONファイルを書く(一時ファイルから名前を変えるので、途中で読まれても壊れない)
230 */
231 public static function write(string $path, mixed $data): bool {
232 $tmp = $path.'.'.bin2hex(random_bytes(4)).'.tmp';
233 $json = json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
234 if ($json === false || file_put_contents($tmp, $json) === false) return false;
235 if (!rename($tmp, $path)) {
236 @unlink($tmp);
237 return false;
238 }
239 return true;
240 }
241
242 /**
243 * JSONファイルをロックして読み書きする
244 *
245 * @param string $path ファイル
246 * @param callable $fn データを受け取り、新しいデータを返す
247 * @param mixed $default ファイルが無い場合のデータ
248 * @return mixed 新しいデータ
249 */
250 public static function update(string $path, callable $fn, mixed $default = []): mixed {
251 $lock = fopen($path.'.lock', 'c');
252 if ($lock) flock($lock, LOCK_EX);
253 try {
254 $new = $fn(self::read($path, $default));
255 self::write($path, $new);
256 return $new;
257 } finally {
258 if ($lock) {
259 flock($lock, LOCK_UN);
260 fclose($lock);
261 }
262 }
263 }
264
265 /**
266 * 時刻順に並ぶID(20文字の16進数)
267 */
268 public static function newId(): string {
269 return sprintf('%012x', (int)(microtime(true) * 1000)).bin2hex(random_bytes(4));
270 }
271
272 /** ISO 8601(UTC) */
273 public static function now(?int $t = null): string {
274 return gmdate('Y-m-d\TH:i:s\Z', $t ?? time());
275 }
276
277 /**
278 * 宛先からの公開範囲
279 *
280 * @param array $to to
281 * @param array $cc cc
282 * @param string|null $followers 投稿者のフォロワーのコレクション
283 * @return string public, unlisted, followers, direct
284 */
285 public static function visibility(array $to, array $cc, ?string $followers = null): string {
286 if (in_array(self::PUBLIC, $to, true) || in_array('as:Public', $to, true) || in_array('Public', $to, true)) return 'public';
287 if (in_array(self::PUBLIC, $cc, true) || in_array('as:Public', $cc, true) || in_array('Public', $cc, true)) return 'unlisted';
288 $all = array_merge($to, $cc);
289 if ($followers !== null && in_array($followers, $all, true)) return 'followers';
290 foreach ($all as $a) if (is_string($a) && str_ends_with($a, '/followers')) return 'followers';
291 return 'direct';
292 }
293
294 /**
295 * IDの配列にする(文字列、オブジェクト、配列のどれでも)
296 */
297 public static function ids(mixed $v): array {
298 if ($v === null) return [];
299 if (is_string($v)) return [$v];
300 if (is_array($v) && isset($v['id']) && is_string($v['id'])) return [$v['id']];
301 if (is_array($v) && array_is_list($v)) {
302 $out = [];
303 foreach ($v as $x) $out = array_merge($out, self::ids($x));
304 return $out;
305 }
306 return [];
307 }
308
309 /** オブジェクトのID(文字列かオブジェクト) */
310 public static function id(mixed $v): ?string {
311 return self::ids($v)[0] ?? null;
312 }
313}