マイクロブログ
守矢諏訪子 <suwako@076.moe>、2026-11-10 22:15
コミット 0d792b60bab3018d1475348dedfee586bc7dd652
・親 2680b67
・ファイルを見る
38個のファイル、+4968 -24
変更
.gitignore
| @@ -3,9 +3,12 @@ | ||
| 3 | 3 | data/*.json |
| 4 | 4 | data/cron |
| 5 | 5 | data/cache |
| 6 | +data/ap | |
| 7 | +data/ratelimit | |
| 6 | 8 | data/user/*.json |
| 7 | 9 | log/*.txt |
| 8 | 10 | public/static/user |
| 11 | +public/static/media | |
| 9 | 12 | !public/static/user/.kara |
| 10 | 13 | config/config.php |
| 11 | 14 | public/static/*.pem |
変更
README-JP.md
| @@ -207,6 +207,23 @@ | ||
| 207 | 207 | * `apfederate`:新しいブログ投稿を ActivityPub のフォロワーに送る |
| 208 | 208 | * `apupdate`:プロフィール(アクター)の更新を ActivityPub のフォロワーに送る |
| 209 | 209 | * `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、テキスト)を取り除きます。 | |
| 210 | 227 | |
| 211 | 228 | ## 必要な PHP モジュール |
| 212 | 229 | * php_gmp |
変更
README.md
| @@ -207,6 +207,23 @@ | ||
| 207 | 207 | * `apfederate`: sends new blog posts to ActivityPub followers |
| 208 | 208 | * `apupdate`: sends profile (actor) updates to ActivityPub followers |
| 209 | 209 | * `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. | |
| 210 | 227 | |
| 211 | 228 | ## Required PHP module |
| 212 | 229 | * php_gmp |
| @@ -52,6 +52,10 @@ | ||
| 52 | 52 | ]); |
| 53 | 53 | define('FEDIINFO', [ |
| 54 | 54 | 'domain' => '', |
| 55 | + // falseにするとマルチユーザーモード:ブログのアクターはそのままで、 | |
| 56 | + // Fediverseのロールを持つユーザーはそれぞれアクター(@ユーザー名@ドメイン)になり、/microblogが使える。 | |
| 57 | + // AUTH_ENABLEDとcron(apdeliver)も必要 | |
| 58 | + 'isSingleUser' => true, | |
| 55 | 59 | 'actor' => 'loli', |
| 56 | 60 | 'actorNick' => 'ロリ', |
| 57 | 61 | 'desc' => '', |
変更
cron.php
| @@ -71,6 +71,13 @@ | ||
| 71 | 71 | $cron->register('apupdate', fn() => (new Activitypub())->sendActorUpdate()); |
| 72 | 72 | } |
| 73 | 73 | |
| 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 | + | |
| 74 | 81 | if (AUTH_ENABLED && AUTH_VERIFY_EMAIL) { |
| 75 | 82 | // メールアドレスを7日以内に確認しなかったアカウントを削除する |
| 76 | 83 | $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 @@ | ||
| 43 | 43 | use Site\Controller\Atom; |
| 44 | 44 | use Site\Controller\Fediverse; |
| 45 | 45 | use Site\Controller\Home; |
| 46 | +use Site\Controller\Microblog; | |
| 46 | 47 | use Site\Controller\Notfound; |
| 47 | 48 | use Site\Controller\Op; |
| 48 | 49 | use Site\Controller\Page; |
| @@ -76,6 +77,35 @@ | ||
| 76 | 77 | $routes[] = Route::add('GET', 'ap/objects/{uuid}', Fediverse::class.'@apobject'); |
| 77 | 78 | $routes[] = Route::add('POST', 'ap/inbox', Fediverse::class.'@apinbox'); |
| 78 | 79 | $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'); | |
| 79 | 109 | } |
| 80 | 110 | |
| 81 | 111 | if (AUTH_ENABLED) { |
| @@ -40,6 +40,11 @@ | ||
| 40 | 40 | use Site\Controller\BlogPost; |
| 41 | 41 | use Site\Controller\Mods; |
| 42 | 42 | 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; | |
| 43 | 48 | use LogType; |
| 44 | 49 | |
| 45 | 50 | class Fediverse extends BlogPost { |
| @@ -51,6 +56,13 @@ | ||
| 51 | 56 | */ |
| 52 | 57 | public function apfinger(array $params): void { |
| 53 | 58 | 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 | + } | |
| 54 | 66 | header('Content-Type: application/jrd+json'); |
| 55 | 67 | $ap = new Activitypub(); |
| 56 | 68 | echo $ap->getWebfinger(); |
| @@ -90,7 +102,7 @@ | ||
| 90 | 102 | exit; |
| 91 | 103 | } |
| 92 | 104 | |
| 93 | - $maxBody = 65536; | |
| 105 | + $maxBody = 262144; | |
| 94 | 106 | $input = file_get_contents('php://input', false, null, 0, $maxBody + 1); |
| 95 | 107 | if ($input === false || strlen($input) > $maxBody) { |
| 96 | 108 | header('HTTP/1.1 413 Payload Too Large'); |
| @@ -122,6 +134,11 @@ | ||
| 122 | 134 | } |
| 123 | 135 | |
| 124 | 136 | 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 | + } | |
| 125 | 142 | $ap->postInbox($activity); |
| 126 | 143 | exit; |
| 127 | 144 | } catch (\Exception $e) { |
| @@ -129,6 +146,183 @@ | ||
| 129 | 146 | echo 'フェディバースの作成に失敗: '.$e->getMessage(); |
| 130 | 147 | exit; |
| 131 | 148 | } |
| 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; | |
| 132 | 326 | } |
| 133 | 327 | |
| 134 | 328 | /** |
| @@ -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 @@ | ||
| 84 | 84 | ], |
| 85 | 85 | ]; |
| 86 | 86 | |
| 87 | + if (ACTIVITYPUB_MULTIUSER) { | |
| 88 | + $menu[] = [ | |
| 89 | + 'class' => 'menu-item', | |
| 90 | + 'href' => '/microblog', | |
| 91 | + 'page' => 'microblog', | |
| 92 | + 'text' => 'マイクロブログ', | |
| 93 | + 'show' => true, | |
| 94 | + ]; | |
| 95 | + } | |
| 96 | + | |
| 87 | 97 | if (OPENPROVIDER_ENABLED) { |
| 88 | 98 | $menu[] = [ |
| 89 | 99 | 'class' => 'menu-item', |
| @@ -40,6 +40,9 @@ | ||
| 40 | 40 | use Site\Controller\Mods; |
| 41 | 41 | use Std\Lib\Auth; |
| 42 | 42 | use Std\Lib\Template; |
| 43 | +use Std\Lib\Fedi\Actor; | |
| 44 | +use Std\Lib\Fedi\Social; | |
| 45 | +use Roles; | |
| 43 | 46 | |
| 44 | 47 | class User { |
| 45 | 48 | use Mods; |
| @@ -246,6 +249,79 @@ | ||
| 246 | 249 | $tmpl->render('verify'); |
| 247 | 250 | } |
| 248 | 251 | |
| 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 | + | |
| 249 | 325 | public function profile(array $params): void { |
| 250 | 326 | if (!AUTH_ENABLED) return; |
| 251 | 327 | $name = ''; |
| @@ -276,6 +352,7 @@ | ||
| 276 | 352 | $tmpl->assign('description', $description); |
| 277 | 353 | $tmpl->assign('user', $user); |
| 278 | 354 | $tmpl->assign('u', $u); |
| 355 | + $tmpl->assign('name', $name); | |
| 279 | 356 | |
| 280 | 357 | $tmpl->render('profile'); |
| 281 | 358 | } catch (\Exception $e) { |
| @@ -461,27 +461,42 @@ | ||
| 461 | 461 | * @return bool 署名が正しく、署名者がactorと一致すればtrue |
| 462 | 462 | */ |
| 463 | 463 | 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 | + } | |
| 465 | 469 | |
| 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 | + | |
| 466 | 481 | // Signature: keyId="…",algorithm="…",headers="…",signature="…" |
| 467 | 482 | $sigHeader = $_SERVER['HTTP_SIGNATURE'] ?? ''; |
| 468 | - if ($sigHeader === '') return false; | |
| 483 | + if ($sigHeader === '') return null; | |
| 469 | 484 | preg_match_all('/(\w+)="([^"]*)"/', $sigHeader, $m, PREG_SET_ORDER); |
| 470 | 485 | $sig = []; |
| 471 | 486 | 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; | |
| 473 | 488 | 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; | |
| 475 | 490 | |
| 476 | 491 | // 最低限これらのヘッダーが署名されている事 |
| 477 | 492 | $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; | |
| 480 | 495 | } |
| 481 | 496 | |
| 482 | 497 | // 再送攻撃対策:Dateは前後5分以内 |
| 483 | 498 | $date = strtotime($_SERVER['HTTP_DATE'] ?? ''); |
| 484 | - if (!$date || abs(time() - $date) > 300) return false; | |
| 499 | + if (!$date || abs(time() - $date) > 300) return null; | |
| 485 | 500 | |
| 486 | 501 | // Digestが本文と一致する事 / Digest must match the body |
| 487 | 502 | $expected = base64_encode(hash('sha256', $body, true)); |
| @@ -490,7 +505,7 @@ | ||
| 490 | 505 | $d = trim($d); |
| 491 | 506 | if (stripos($d, 'SHA-256=') === 0 && hash_equals($expected, substr($d, 8))) $digestOk = true; |
| 492 | 507 | } |
| 493 | - if (!$digestOk) return false; | |
| 508 | + if ($isPost && !$digestOk) return null; | |
| 494 | 509 | |
| 495 | 510 | // 署名対象の文字列を組み立てる |
| 496 | 511 | $lines = []; |
| @@ -504,29 +519,29 @@ | ||
| 504 | 519 | 'content-length' => 'CONTENT_LENGTH', |
| 505 | 520 | default => 'HTTP_'.strtoupper(str_replace('-', '_', $h)), |
| 506 | 521 | }; |
| 507 | - if (!isset($_SERVER[$key])) return false; | |
| 522 | + if (!isset($_SERVER[$key])) return null; | |
| 508 | 523 | $lines[] = "{$h}: {$_SERVER[$key]}"; |
| 509 | 524 | } |
| 510 | 525 | |
| 511 | - // 公開鍵を取得し、鍵の持ち主がアクティビティのactorと一致する事を確認する | |
| 512 | - $actor = $activity['actor'] ?? null; | |
| 513 | - if (is_array($actor)) $actor = $actor['id'] ?? null; | |
| 514 | - if (!is_string($actor)) return false; | |
| 526 | + // 公開鍵を取得する(鍵の持ち主が署名したアクター) | |
| 515 | 527 | |
| 516 | 528 | $doc = $this->fetchRemoteJson(strtok($sig['keyId'], '#')); |
| 517 | - if (!$doc) return false; | |
| 529 | + if (!$doc) return null; | |
| 518 | 530 | $keys = $doc['publicKey'] ?? $doc; |
| 519 | 531 | if (array_is_list($keys)) { |
| 520 | 532 | $keys = array_values(array_filter($keys, fn($k) => is_array($k) && ($k['id'] ?? '') === $sig['keyId']))[0] ?? []; |
| 521 | 533 | } |
| 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; | |
| 524 | 539 | |
| 525 | 540 | $pub = openssl_pkey_get_public($keys['publicKeyPem'] ?? ''); |
| 526 | 541 | $raw = base64_decode($sig['signature'], true); |
| 527 | - if (!$pub || $raw === false) return false; | |
| 542 | + if (!$pub || $raw === false) return null; | |
| 528 | 543 | |
| 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; | |
| 530 | 545 | } |
| 531 | 546 | |
| 532 | 547 | // 機能性メソッド |
| @@ -545,7 +560,7 @@ | ||
| 545 | 560 | * @param mixed $url 確認するURL |
| 546 | 561 | * @return bool 安全ならtrue |
| 547 | 562 | */ |
| 548 | - private function isSafeRemoteUrl(mixed $url): bool { | |
| 563 | + public function isSafeRemoteUrl(mixed $url): bool { | |
| 549 | 564 | if (!is_string($url) || strlen($url) > 2048) return false; |
| 550 | 565 | if (preg_match('/[\x00-\x20\x7f]/', $url)) return false; |
| 551 | 566 | |
| @@ -580,7 +595,7 @@ | ||
| 580 | 595 | * @param string $url 取得するURL |
| 581 | 596 | * @return array|null デコード済みのJSON、失敗した場合はnull |
| 582 | 597 | */ |
| 583 | - protected function fetchRemoteJson(string $url): ?array { | |
| 598 | + public function fetchRemoteJson(string $url): ?array { | |
| 584 | 599 | if (!$this->isSafeRemoteUrl($url)) { |
| 585 | 600 | logger(\LogType::ActivityPub, "安全ではないURLを拒否: {$url}"); |
| 586 | 601 | return null; |
| @@ -660,9 +675,10 @@ | ||
| 660 | 675 | * @param string $body 送信する本文(GETは空) |
| 661 | 676 | * @param \OpenSSLAsymmetricKey $priv 秘密鍵 |
| 662 | 677 | * @param string $method post又はget |
| 678 | + * @param string|null $keyId 鍵のID(既定はブログのアクターの鍵。マルチユーザーではユーザーの鍵) | |
| 663 | 679 | * @return array|null 「名前: 値」のヘッダー、署名に失敗した場合はnull |
| 664 | 680 | */ |
| 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 { | |
| 666 | 682 | $p = parse_url($url); |
| 667 | 683 | if (!$p || empty($p['host'])) return null; |
| 668 | 684 | $method = strtolower($method); |
| @@ -688,7 +704,8 @@ | ||
| 688 | 704 | return null; |
| 689 | 705 | } |
| 690 | 706 | |
| 691 | - $headers['Signature'] = "keyId=\"{$this->domain}/ap/actor#main-key\"," | |
| 707 | + $keyId ??= "{$this->domain}/ap/actor#main-key"; | |
| 708 | + $headers['Signature'] = "keyId=\"{$keyId}\"," | |
| 692 | 709 | .'algorithm="rsa-sha256",' |
| 693 | 710 | .'headers="'.implode(' ', $signed).'",' |
| 694 | 711 | .'signature="'.base64_encode($signature).'"'; |
| @@ -88,6 +88,8 @@ | ||
| 88 | 88 | $isStaff = $myself && ($myself->role & (Roles::STAFF | Roles::ADMIN)); |
| 89 | 89 | if (!$myself || ($myself->id != $user->id && !$isStaff)) $user->email = '(秘密)'; |
| 90 | 90 | $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) : ''; | |
| 91 | 93 | $user->bioHtml = nl2br(htmlspecialchars((string)($user->bio ?? ''), ENT_QUOTES, 'UTF-8'), false); |
| 92 | 94 | $user->avatar = avatar_url($user); |
| 93 | 95 | $user->name = namecolor($user); |
| @@ -432,6 +434,7 @@ | ||
| 432 | 434 | $oldAvatar = (string)($user->avatar ?? ''); |
| 433 | 435 | $newAvatar = null; |
| 434 | 436 | 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); | |
| 435 | 438 | if (!$res->isSuccess) $errors[] = 'エラー:'.$res->message; |
| 436 | 439 | else $newAvatar = $res->data['name']; |
| 437 | 440 | } else if (!empty($in['removeAvatar'])) { |
| @@ -496,6 +499,7 @@ | ||
| 496 | 499 | } |
| 497 | 500 | |
| 498 | 501 | if (!empty($errors)) { |
| 502 | + if ($newAvatar !== null) \Std\Lib\Fedi\Media::remove('/public/static/user/'.$user->username.'/'.$newAvatar); | |
| 499 | 503 | return Result::Error(implode('<br />', array_map(fn($e) => htmlspecialchars($e, ENT_QUOTES, 'UTF-8'), $errors))); |
| 500 | 504 | } |
| 501 | 505 | |
| @@ -539,6 +543,7 @@ | ||
| 539 | 543 | $user->isSelf = $me->id == $user->id; |
| 540 | 544 | $user->isAdmin = (bool)($me->role & Roles::ADMIN); |
| 541 | 545 | $user->colorHex = preg_match('/#[0-9a-fA-F]{6}/', (string)($user->namecolor ?? ''), $m) ? $m[0] : ''; |
| 546 | + $user->fedi = can_federate($user); | |
| 542 | 547 | $user->avatarUrl = avatar_url($user); |
| 543 | 548 | return $user; |
| 544 | 549 | } |
| @@ -890,6 +895,10 @@ | ||
| 890 | 895 | |
| 891 | 896 | $res = $this->isUserExist($username); |
| 892 | 897 | 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 | + } | |
| 893 | 902 | |
| 894 | 903 | $accepted = 'A-Za-z0-9'; |
| 895 | 904 | 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 @@ | ||
| 61 | 61 | } |
| 62 | 62 | protected function followersFile(): string { return $this->file; } |
| 63 | 63 | 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; } | |
| 65 | 65 | protected function sendActivity(string $inboxUrl, array $activity): bool { |
| 66 | 66 | $this->sent[] = [$inboxUrl, $activity]; |
| 67 | 67 | 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('<b><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 @@ | ||
| 37 | 37 | */ |
| 38 | 38 | if (!CURL_ENABLED) define('ACTIVITYPUB_ENABLED', false); |
| 39 | 39 | 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)); | |
| 40 | 41 | |
| 41 | 42 | enum LogType { |
| 42 | 43 | case ActivityPub; |
| @@ -79,6 +80,26 @@ | ||
| 79 | 80 | public const int FSUBSCRIBER = 1 << 7; // 128 (NINTENDNDA + PLAYSTATIONNDA + SUBSCRIBER) |
| 80 | 81 | public const int STAFF = 1 << 8; // 256 |
| 81 | 82 | 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 | + ]; | |
| 82 | 103 | } |
| 83 | 104 | |
| 84 | 105 | class Gender { |
| @@ -371,6 +392,17 @@ | ||
| 371 | 392 | $suffix = $userData->gender === Gender::MALE ? 'くん' : ($userData->gender === Gender::FEMALE ? 'ちゃん' : 'さん'); |
| 372 | 393 | |
| 373 | 394 | 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); | |
| 374 | 406 | } |
| 375 | 407 | |
| 376 | 408 | /** |
| @@ -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']) }}&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) @} |