| 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 = 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 | } |