管理者パネル
守矢諏訪子 <suwako@076.moe>、2026-11-13 07:52
コミット 7288b978a39d5d959119c622e421166bce5641b0
・親 6c02f90
・ファイルを見る
27個のファイル、+2207 -100
変更
README-JP.md
| @@ -247,7 +247,7 @@ | ||
| 247 | 247 | * 読む元:`DbSource`(接続した `Db`)、`DumpSource`(`mysqldump`、`pg_dump`(プレーンの形式、`COPY` 又は `--inserts`)、`sqlite3 .dump`、`DumpTarget` で作った `.sql` のファイル(`.sql.gz` は PHP の zlib が必要)、方言は自動で判定)、`JsonSource`(データのディレクトリ)。書く先:`DbTarget`、`DumpTarget`(方言は `mysql`・`pgsql`・`sqlite`、`mysql`・`psql`・`sqlite3` のコマンドで読み込めます)、`JsonTarget`。 |
| 248 | 248 | * オプション:`tables`(このテーブルだけ)、`drop`(書く先の同じ名前のテーブルを消す、無ければ既に有るテーブルはエラー)、`progress`(`fn(string $table, int $rows)`)。結果は `tables`(テーブル毎のコピーした行数)と `warnings`(変換しなかった物)。 |
| 249 | 249 | * 列、主キー、ユニーク・普通のインデックス、`NOT NULL`、既定値、自動の番号(`AUTO_INCREMENT`、`SERIAL`・IDENTITY、SQLite の `INTEGER PRIMARY KEY`)を変換し、番号はコピーした行の続きから始まります。型は書く先の一番近い型にします(例:`TINYINT(1)` と `boolean`、`JSON` と `jsonb`、`BLOB` と `bytea`)。`ENUM`・`SET` は文字列、PostgreSQL の配列は JSON にします。時差の有る日時は UTC で保存します。外部キー、ビュー、トリガー、関数、全文・式のインデックスは変換しません(`warnings` に出ます)。 |
| 250 | -* JSON のデータ:ユーザー、コメント、通知、Cron のジョブ、フォロワー、ActivityPub の投稿は名前が `lb_` で始まるテーブルになり、他のファイル(`cache/`、`ratelimit/`、ロック・一時ファイル、SQLite のデータベースを除く)は `lb_files` に入るので、戻すと同じファイルになります。他のテーブルは `data/db/{テーブル}.json` になります。`data/` が空・無い場合は、テーブルが無いだけです。`JsonTarget` は、2番目の引数(上書き)が `true` か `drop` が無ければ、空でないディレクトリには書きません。 | |
| 250 | +* JSON のデータ:ユーザー、コメント、通知、Cron のジョブ、フォロワー、ActivityPub の投稿は名前が `lb_` で始まるテーブルになり、他のファイル(`cache/`、`ratelimit/`、`tmp/`、ロック・一時ファイル、SQLite のデータベースを除く)は `lb_files` に入るので、戻すと同じファイルになります。他のテーブルは `data/db/{テーブル}.json` になります。`data/` が空・無い場合は、テーブルが無いだけです。`JsonTarget` は、2番目の引数(上書き)が `true` か `drop` が無ければ、空でないディレクトリには書きません。 | |
| 251 | 251 | * サイトが使っているデータベースを読む・書く場合は、先に書き込みを止めて下さい(又は新しいデータベースに変換)。変換中に元をロックしません。 |
| 252 | 252 | |
| 253 | 253 | ### TLS 1.3・1.2(php_openssl は不要) |
| @@ -277,14 +277,14 @@ | ||
| 277 | 277 | * * * * * /usr/local/bin/php-8.5 /var/www/htdocs/cron.php |
| 278 | 278 | ``` |
| 279 | 279 | |
| 280 | -ジョブはコマンドラインで管理します: | |
| 280 | +ジョブはコマンドライン(又は管理画面)で管理します: | |
| 281 | 281 | ```sh |
| 282 | 282 | php cron.php add "*/10 * * * *" apfederate |
| 283 | 283 | php cron.php list |
| 284 | 284 | php cron.php help |
| 285 | 285 | ``` |
| 286 | 286 | |
| 287 | -タスクは `cron.php` で登録します。組み込みのタスク: | |
| 287 | +タスクは `util.php` の `register_cron_tasks()` で登録します(`cron.php` と管理画面で使います)。組み込みのタスク: | |
| 288 | 288 | * `apfederate`:新しいブログ投稿を ActivityPub のフォロワーに送る |
| 289 | 289 | * `apupdate`:プロフィール(アクター)の更新を ActivityPub のフォロワーに送る |
| 290 | 290 | * `authpurge`:7日以内にメールアドレスを確認しなかったアカウントを削除する(`AUTH_VERIFY_EMAIL` が有効の場合) |
| @@ -313,6 +313,16 @@ | ||
| 313 | 313 | `config/config.php` で `AUTH_2FA_ENABLED` を `true` にすると、各ユーザーがプロフィールの編集の「二段階認証」から有効に出来ます。`false` なら誰も使えず、有効にしていたユーザーもパスワードだけでログインします。\ |
| 314 | 314 | 設定画面には QR コードと秘密鍵(PC のパスワードマネージャー用)が表示されるので、認証アプリ(Aegis、Google Authenticator、KeePassXC 等)に登録して、表示された6桁のコードを入力すると有効になります。以後はパスワードの後にコードを入力します(TOTP、SHA-1、6桁、30秒。同じコードは2度使えません)。\ |
| 315 | 315 | 端末を失くした場合は、管理者がそのユーザーの設定画面(`/profile/ユーザー名/2fa`)で自分のパスワードを入力すると無効に出来ます。 |
| 316 | + | |
| 317 | +### 管理画面 | |
| 318 | +`config/config.php` で `ADMIN_ENABLED` を `true` にすると(`AUTH_ENABLED` も必要)、管理者のロールを持つユーザーのメニューに「管理」が出て、`/admin` を開けます: | |
| 319 | +* ユーザー:アカウントの一覧と検索、作成(登録が無効でも、メールアドレスの確認なし)、ロールの変更(全部外すと BAN)、メールアドレスを確認済みにする、全部のログインの取り消し、メールアドレス・パスワードの変更と二段階認証の無効化(この3つは自分のパスワードが必要)。表示名・アイコン等は今まで通りプロフィールの編集で変更します。 | |
| 320 | +* ログ:`log/` のファイル。最後の行、検索、ダウンロード、空にする、削除。 | |
| 321 | +* Cron:ジョブの追加、変更、有効・無効、削除、今すぐ実行と、システムの cron の前回の実行。 | |
| 322 | +* キャッシュ:`data/cache/` を種類毎に表示・削除(1件、種類毎、全部)と、Fediverse のキャッシュ(リモートのアクター、WebFinger、画像)の削除。 | |
| 323 | +* データベースの変換:上の変換をフォームで。読む元・書く先は JSON のデータ(`data/`)、SQLite のファイル、MySQL・MariaDB 又は PostgreSQL のサーバー、SQL のダンプ(アップロード又はサーバーのファイル、書く先はダウンロード又はサーバーに保存)、サイトのデータベース(`DBINFO`)。パスはサイトのディレクトリからの相対パスで、その外は使えません。アップロードしたファイルと変換したファイルは `data/tmp/convert/` に1時間残ります。大きなファイルをアップロードするには、`php.ini` の `upload_max_filesize` と `post_max_size` を大きくして下さい。 | |
| 324 | + | |
| 325 | +新しいサイトには管理者が居なく、`data/` が空の場合も有ります。`ADMIN_SETUP_CODE` に長いランダムな文字列(例:`openssl rand -hex 16` の出力)を設定して `/admin` を開くと `/admin/setup` に移動するので、そのコードで最初の管理者を作ってログインします。その後 `ADMIN_SETUP_CODE` を `''` に戻して下さい。管理者が居ればこのページは使えません。 | |
| 316 | 326 | |
| 317 | 327 | ## 必要な PHP モジュール |
| 318 | 328 | * php_fileinfo(OpenBSDでデフォルトで有効している) |
変更
README.md
| @@ -245,7 +245,7 @@ | ||
| 245 | 245 | * Sources: `DbSource` (a connected `Db`), `DumpSource` (a `.sql` file, or `.sql.gz` with PHP's zlib, from `mysqldump`, `pg_dump` (plain format, `COPY` or `--inserts`), `sqlite3 .dump` or `DumpTarget`; the dialect is detected) and `JsonSource` (a data directory). Targets: `DbTarget`, `DumpTarget` (dialect `mysql`, `pgsql` or `sqlite`; the file can be loaded with `mysql`, `psql` or `sqlite3`) and `JsonTarget`. |
| 246 | 246 | * Options: `tables` (only these tables), `drop` (drop tables with the same name in the target; without it, a table that already exists is an error) and `progress` (`fn(string $table, int $rows)`). The result has `tables` (rows copied per table) and `warnings` (things that were not converted). |
| 247 | 247 | * Columns, primary keys, unique and plain indexes, `NOT NULL`, defaults and auto increment (`AUTO_INCREMENT`, `SERIAL`/identity, SQLite's `INTEGER PRIMARY KEY`) are converted, and the next number continues after the copied rows. Types are mapped to the nearest type of the target (for example `TINYINT(1)` and `boolean`, `JSON` and `jsonb`, `BLOB` and `bytea`); `ENUM`/`SET` become text and PostgreSQL arrays become JSON. Times with a time zone are stored in UTC. Foreign keys, views, triggers, functions, full-text and expression indexes are not converted (they are listed in `warnings`). |
| 248 | -* JSON data: users, comments, notifications, Cron jobs, followers and ActivityPub notes become tables whose names start with `lb_`, and every other file (except `cache/`, `ratelimit/`, lock and temporary files and SQLite databases) goes into `lb_files`, so converting back gives the same files. Other tables become `data/db/{table}.json`. An empty or missing `data/` directory simply has no tables. `JsonTarget` refuses to write into a directory that is not empty unless its second argument (overwrite) is `true` or `drop` is set. | |
| 248 | +* JSON data: users, comments, notifications, Cron jobs, followers and ActivityPub notes become tables whose names start with `lb_`, and every other file (except `cache/`, `ratelimit/`, `tmp/`, lock and temporary files and SQLite databases) goes into `lb_files`, so converting back gives the same files. Other tables become `data/db/{table}.json`. An empty or missing `data/` directory simply has no tables. `JsonTarget` refuses to write into a directory that is not empty unless its second argument (overwrite) is `true` or `drop` is set. | |
| 249 | 249 | * When the source or target is the database the site is using, stop writes to it first (or convert into a new database), as the conversion does not lock the source. |
| 250 | 250 | |
| 251 | 251 | ### TLS 1.3 and 1.2 (no php_openssl) |
| @@ -275,14 +275,14 @@ | ||
| 275 | 275 | * * * * * /usr/local/bin/php-8.5 /var/www/htdocs/cron.php |
| 276 | 276 | ``` |
| 277 | 277 | |
| 278 | -Jobs are managed from the command line: | |
| 278 | +Jobs are managed from the command line (or the admin panel): | |
| 279 | 279 | ```sh |
| 280 | 280 | php cron.php add "*/10 * * * *" apfederate |
| 281 | 281 | php cron.php list |
| 282 | 282 | php cron.php help |
| 283 | 283 | ``` |
| 284 | 284 | |
| 285 | -Tasks are registered in `cron.php`. Built-in tasks: | |
| 285 | +Tasks are registered in `register_cron_tasks()` in `util.php` (used by `cron.php` and the admin panel). Built-in tasks: | |
| 286 | 286 | * `apfederate`: sends new blog posts to ActivityPub followers |
| 287 | 287 | * `apupdate`: sends profile (actor) updates to ActivityPub followers |
| 288 | 288 | * `authpurge`: deletes accounts whose email address was not verified within 7 days (when `AUTH_VERIFY_EMAIL` is enabled) |
| @@ -303,6 +303,16 @@ | ||
| 303 | 303 | php cron.php add "@daily" apcleanup |
| 304 | 304 | ``` |
| 305 | 305 | Uploaded images (PNG/JPEG/GIF, up to 4 MiB; check PHP's `upload_max_filesize`) have their metadata (Exif, text chunks) removed. |
| 306 | + | |
| 307 | +### Admin panel | |
| 308 | +Set `ADMIN_ENABLED` to `true` in `config/config.php` (it needs `AUTH_ENABLED`). Users with the admin role then see 「管理」 in the menu, which opens `/admin`: | |
| 309 | +* Users: list and search accounts, create accounts (even when registration is off, without email verification), change roles (removing every role bans the user), mark an email address as verified, sign a user out everywhere, and change the email address or password or turn off two-factor authentication (these three need your own password). Display name, icon and the rest stay on the user's profile edit page. | |
| 310 | +* Logs: the files in `log/`, with the last lines, search, download, clear and delete. | |
| 311 | +* Cron: the jobs, with add, edit, enable or disable, delete and run now, and when the system cron last ran. | |
| 312 | +* Cache: `data/cache/` by kind (view, or delete one entry, a kind or everything) and the Fediverse caches of remote actors, WebFinger and images. | |
| 313 | +* Database conversion: the converter above as a form. Sources and targets are JSON data (`data/`), a SQLite file, a MySQL/MariaDB or PostgreSQL server, an SQL dump (uploaded or a file on the server; as a target, downloaded or saved on the server) and the site's own database (`DBINFO`). Paths are relative to the site root and must stay inside it. Uploaded and converted files are kept in `data/tmp/convert/` for an hour. Large uploads need a higher `upload_max_filesize` and `post_max_size` in `php.ini`. | |
| 314 | + | |
| 315 | +A new site has no admin yet, and `data/` may be empty. Set `ADMIN_SETUP_CODE` to a long random string (for example the output of `openssl rand -hex 16`) and open `/admin`: it goes to `/admin/setup`, where the code creates the first admin account and signs it in. Then set `ADMIN_SETUP_CODE` back to `''`. The setup page closes as soon as an admin exists. | |
| 306 | 316 | |
| 307 | 317 | ## Required PHP module |
| 308 | 318 | * php_gmp |
| @@ -114,5 +114,7 @@ | ||
| 114 | 114 | define('AUTH_VERIFY_EMAIL', false); // 登録時にメールアドレスの確認を必須にする(MAILER_ENABLEDも必要) |
| 115 | 115 | define('AUTH_2FA_ENABLED', false); // 二段階認証(認証アプリのコード)を使えるようにする(各ユーザーがプロフィールの編集から有効にする) |
| 116 | 116 | define('COMMENTS_ENABLED', false); // 記事のコメント(書き込みはAUTH_ENABLED、Fediverseからの返信はACTIVITYPUB_ENABLEDも必要) |
| 117 | +define('ADMIN_ENABLED', false); // 管理画面(/admin、管理者のロールとAUTH_ENABLEDが必要)。ユーザー、ログ、Cron、キャッシュ、データベースの変換 | |
| 118 | +define('ADMIN_SETUP_CODE', ''); // 管理者が居ない時に、/admin/setupで最初の管理者を作る為のコード(長くて推測されにくい物、作った後は空に) | |
| 117 | 119 | define('COPYRIGHT_YEAR', '2018-'.date('Y')); |
| 118 | 120 | define('DEBUG_MODE', false); |
変更
cron.php
| @@ -50,41 +50,14 @@ | ||
| 50 | 50 | require_once ROOT.'/util.php'; |
| 51 | 51 | mb_internal_encoding('UTF-8'); |
| 52 | 52 | |
| 53 | -use Site\Controller\BlogPost; | |
| 54 | -use Std\Lib\Activitypub; | |
| 55 | -use Std\Lib\Auth; | |
| 56 | 53 | use Std\Lib\Cron; |
| 57 | 54 | use Std\Lib\CronTab; |
| 58 | 55 | |
| 59 | 56 | $cron = new Cron(); |
| 60 | 57 | |
| 61 | -//////////////////////////////////////////////////////////////// | |
| 62 | -// タスクの登録 | |
| 63 | -// ジョブの「タスク名」と処理を結び付ける。 | |
| 64 | -// 処理はジョブ(CronTab)を受け取り、Result・bool・nullを返す(falseかResult::Errorは失敗)。 | |
| 65 | -//////////////////////////////////////////////////////////////// | |
| 58 | +// タスクはutil.phpのregister_cron_tasks()で登録する(管理画面からも使う) | |
| 59 | +register_cron_tasks($cron); | |
| 66 | 60 | |
| 67 | -if (ACTIVITYPUB_ENABLED) { | |
| 68 | - // 新しいブログ投稿をフォロワーに送る | |
| 69 | - $cron->register('apfederate', fn() => (new Activitypub((new BlogPost())->federatedPosts()))->federate()); | |
| 70 | - // プロフィール(アクター)の更新をフォロワーに送る | |
| 71 | - $cron->register('apupdate', fn() => (new Activitypub())->sendActorUpdate()); | |
| 72 | -} | |
| 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 | - | |
| 81 | -if (AUTH_ENABLED && AUTH_VERIFY_EMAIL) { | |
| 82 | - // メールアドレスを7日以内に確認しなかったアカウントを削除する | |
| 83 | - $cron->register('authpurge', fn() => (new Auth())->purgeUnverified()); | |
| 84 | -} | |
| 85 | - | |
| 86 | -//////////////////////////////////////////////////////////////// | |
| 87 | - | |
| 88 | 61 | $usage = <<<TXT |
| 89 | 62 | 使い方: php cron.php [コマンド] |
| 90 | 63 | |
| @@ -134,7 +107,7 @@ | ||
| 134 | 107 | exit(2); |
| 135 | 108 | } |
| 136 | 109 | if (!in_array($ct->command, $cron->tasks(), true)) { |
| 137 | - fwrite(STDERR, "注意:タスク「{$ct->command}」は登録されていません(cron.phpの「タスクの登録」を参照)。\n"); | |
| 110 | + fwrite(STDERR, "注意:タスク「{$ct->command}」は登録されていません(util.phpのregister_cron_tasks()を参照)。\n"); | |
| 138 | 111 | } |
| 139 | 112 | return $ct; |
| 140 | 113 | }; |
| @@ -0,0 +1,179 @@ | ||
| 1 | +/* 管理画面(/admin)。JavaScriptなし */ | |
| 2 | +.admin-nav { | |
| 3 | + display: flex; | |
| 4 | + flex-wrap: wrap; | |
| 5 | + gap: 8px; | |
| 6 | + border-bottom: 1px solid #c016c6; | |
| 7 | + padding-bottom: 8px; | |
| 8 | + margin-bottom: 8px; | |
| 9 | +} | |
| 10 | + | |
| 11 | +.admin-nav a { | |
| 12 | + padding: 2px 8px; | |
| 13 | + text-decoration: none; | |
| 14 | +} | |
| 15 | + | |
| 16 | +.admin-nav a.active { | |
| 17 | + border: 1px solid #ea79d8; | |
| 18 | + border-radius: 10px; | |
| 19 | + background-color: #550f75; | |
| 20 | +} | |
| 21 | + | |
| 22 | +.admin-message { | |
| 23 | + border: 1px solid #2c980c; | |
| 24 | + border-radius: 2px; | |
| 25 | + padding: 4px 8px; | |
| 26 | +} | |
| 27 | + | |
| 28 | +.admin-warn { | |
| 29 | + border-left: 4px solid #ee4030; | |
| 30 | + padding: 2px 8px; | |
| 31 | +} | |
| 32 | + | |
| 33 | +small.admin-warn { | |
| 34 | + display: block; | |
| 35 | + border: 0; | |
| 36 | + padding: 0; | |
| 37 | + color: #ee4030; | |
| 38 | +} | |
| 39 | + | |
| 40 | +.admin-cards { | |
| 41 | + display: grid; | |
| 42 | + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); | |
| 43 | + gap: 8px; | |
| 44 | + margin: 8px 0; | |
| 45 | +} | |
| 46 | + | |
| 47 | +.admin-card { | |
| 48 | + display: flex; | |
| 49 | + flex-direction: column; | |
| 50 | + gap: 4px; | |
| 51 | + border: 1px solid #c016c6; | |
| 52 | + border-radius: 2px; | |
| 53 | + padding: 8px; | |
| 54 | + text-decoration: none; | |
| 55 | +} | |
| 56 | + | |
| 57 | +.admin-table { | |
| 58 | + width: 100%; | |
| 59 | + max-width: none; | |
| 60 | + margin: 8px 0; | |
| 61 | +} | |
| 62 | + | |
| 63 | +.admin-table th { | |
| 64 | + width: auto; | |
| 65 | +} | |
| 66 | + | |
| 67 | +.admin-table form { | |
| 68 | + display: inline; | |
| 69 | +} | |
| 70 | + | |
| 71 | +.admin-scroll { | |
| 72 | + overflow-x: auto; | |
| 73 | +} | |
| 74 | + | |
| 75 | +.admin-user img { | |
| 76 | + width: 24px; | |
| 77 | + height: 24px; | |
| 78 | + vertical-align: middle; | |
| 79 | + margin-right: 4px; | |
| 80 | +} | |
| 81 | + | |
| 82 | +.admin-banned, .admin-off { | |
| 83 | + opacity: 0.6; | |
| 84 | +} | |
| 85 | + | |
| 86 | +.admin-current td { | |
| 87 | + background-color: #550f75; | |
| 88 | +} | |
| 89 | + | |
| 90 | +.admin-features span { | |
| 91 | + display: inline-block; | |
| 92 | + margin: 2px; | |
| 93 | + padding: 0 6px; | |
| 94 | + border: 1px solid #746c75; | |
| 95 | + border-radius: 10px; | |
| 96 | +} | |
| 97 | + | |
| 98 | +.admin-features .admin-on { | |
| 99 | + border-color: #2c980c; | |
| 100 | +} | |
| 101 | + | |
| 102 | +.admin-features .admin-off { | |
| 103 | + text-decoration: line-through; | |
| 104 | +} | |
| 105 | + | |
| 106 | +.admin-row { | |
| 107 | + display: flex; | |
| 108 | + flex-wrap: wrap; | |
| 109 | + align-items: center; | |
| 110 | + gap: 4px; | |
| 111 | + margin: 4px 0; | |
| 112 | +} | |
| 113 | + | |
| 114 | +.admin-form, .admin-convert, .admin-convert fieldset { | |
| 115 | + display: flex; | |
| 116 | + flex-direction: column; | |
| 117 | + align-items: flex-start; | |
| 118 | + gap: 6px; | |
| 119 | + margin: 8px 0; | |
| 120 | +} | |
| 121 | + | |
| 122 | +.admin-convert fieldset { | |
| 123 | + border: 1px solid #c016c6; | |
| 124 | + border-radius: 2px; | |
| 125 | + padding: 8px; | |
| 126 | +} | |
| 127 | + | |
| 128 | +.admin-checks { | |
| 129 | + display: flex; | |
| 130 | + flex-wrap: wrap; | |
| 131 | + gap: 4px 12px; | |
| 132 | +} | |
| 133 | + | |
| 134 | +.admin-kind { | |
| 135 | + display: flex; | |
| 136 | + flex-wrap: wrap; | |
| 137 | + gap: 6px 12px; | |
| 138 | + border-left: 2px solid #746c75; | |
| 139 | + padding-left: 8px; | |
| 140 | +} | |
| 141 | + | |
| 142 | +/* 選んだ種類の欄だけ(:hasが無いブラウザーでは全部) */ | |
| 143 | +@supports selector(:has(a)) { | |
| 144 | + .admin-kind { | |
| 145 | + display: none; | |
| 146 | + } | |
| 147 | + | |
| 148 | + fieldset:has(input[value="json"]:checked) .admin-kind[data-kind="json"], | |
| 149 | + fieldset:has(input[value="sqlite"]:checked) .admin-kind[data-kind="sqlite"], | |
| 150 | + fieldset:has(input[value="mysql"]:checked) .admin-kind[data-kind="server"], | |
| 151 | + fieldset:has(input[value="pgsql"]:checked) .admin-kind[data-kind="server"], | |
| 152 | + fieldset:has(input[value="dump"]:checked) .admin-kind[data-kind="dump"] { | |
| 153 | + display: flex; | |
| 154 | + } | |
| 155 | +} | |
| 156 | + | |
| 157 | +.admin-result { | |
| 158 | + border: 1px solid #2c980c; | |
| 159 | + border-radius: 2px; | |
| 160 | + padding: 8px; | |
| 161 | + margin: 8px 0; | |
| 162 | +} | |
| 163 | + | |
| 164 | +.admin-pre { | |
| 165 | + overflow-x: auto; | |
| 166 | + padding: 8px; | |
| 167 | + border: 1px solid #746c75; | |
| 168 | + white-space: pre; | |
| 169 | +} | |
| 170 | + | |
| 171 | +.admin-log { | |
| 172 | + max-height: 70vh; | |
| 173 | + overflow-y: auto; | |
| 174 | +} | |
| 175 | + | |
| 176 | +button.danger { | |
| 177 | + border-color: #ee4030; | |
| 178 | + color: #ee4030; | |
| 179 | +} |
変更
route.php
| @@ -40,6 +40,7 @@ | ||
| 40 | 40 | |
| 41 | 41 | use Std\Lib\Route; |
| 42 | 42 | |
| 43 | +use Site\Controller\Admin; | |
| 43 | 44 | use Site\Controller\Atom; |
| 44 | 45 | use Site\Controller\Comment; |
| 45 | 46 | use Site\Controller\Fediverse; |
| @@ -135,6 +136,25 @@ | ||
| 135 | 136 | |
| 136 | 137 | $routes[] = Route::add('GET', 'memberonly', Page::class.'@memberonly'); |
| 137 | 138 | $routes[] = Route::add('GET', 'staffonly', Page::class.'@staffonly'); |
| 139 | +} | |
| 140 | + | |
| 141 | +// 管理画面(管理者のロールが必要) | |
| 142 | +if (AUTH_ENABLED && defined('ADMIN_ENABLED') && ADMIN_ENABLED) { | |
| 143 | + $routes[] = Route::add('GET', 'admin', Admin::class.'@index'); | |
| 144 | + $routes[] = Route::add('GET', 'admin/setup', Admin::class.'@setup'); | |
| 145 | + $routes[] = Route::add('POST', 'admin/setup', Admin::class.'@setup'); | |
| 146 | + $routes[] = Route::add('GET', 'admin/users', Admin::class.'@users'); | |
| 147 | + $routes[] = Route::add('POST', 'admin/users', Admin::class.'@users'); | |
| 148 | + $routes[] = Route::add('GET', 'admin/users/{name}', Admin::class.'@user'); | |
| 149 | + $routes[] = Route::add('POST', 'admin/users/{name}', Admin::class.'@user'); | |
| 150 | + $routes[] = Route::add('GET', 'admin/logs', Admin::class.'@logs'); | |
| 151 | + $routes[] = Route::add('POST', 'admin/logs', Admin::class.'@logs'); | |
| 152 | + $routes[] = Route::add('GET', 'admin/cron', Admin::class.'@cron'); | |
| 153 | + $routes[] = Route::add('POST', 'admin/cron', Admin::class.'@cron'); | |
| 154 | + $routes[] = Route::add('GET', 'admin/cache', Admin::class.'@cache'); | |
| 155 | + $routes[] = Route::add('POST', 'admin/cache', Admin::class.'@cache'); | |
| 156 | + $routes[] = Route::add('GET', 'admin/convert', Admin::class.'@convert'); | |
| 157 | + $routes[] = Route::add('POST', 'admin/convert', Admin::class.'@convert'); | |
| 138 | 158 | } |
| 139 | 159 | |
| 140 | 160 | if (AUTH_ENABLED && \Std\Lib\Comments::enabled()) { |
| @@ -0,0 +1,699 @@ | ||
| 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\Cache; | |
| 43 | +use Std\Lib\Cron; | |
| 44 | +use Std\Lib\CronTab; | |
| 45 | +use Std\Lib\Db; | |
| 46 | +use Std\Lib\Db\Convert; | |
| 47 | +use Std\Lib\Db\Convert\DbSource; | |
| 48 | +use Std\Lib\Db\Convert\DbTarget; | |
| 49 | +use Std\Lib\Db\Convert\DumpSource; | |
| 50 | +use Std\Lib\Db\Convert\DumpTarget; | |
| 51 | +use Std\Lib\Db\Convert\JsonSource; | |
| 52 | +use Std\Lib\Db\Convert\JsonTarget; | |
| 53 | +use Std\Lib\Db\Convert\Source; | |
| 54 | +use Std\Lib\Db\Convert\Target; | |
| 55 | +use Std\Lib\Logs; | |
| 56 | +use Std\Lib\Template; | |
| 57 | +use Roles; | |
| 58 | + | |
| 59 | +/** | |
| 60 | + * 管理画面(/admin)。管理者のロールが必要 | |
| 61 | + * | |
| 62 | + * ユーザー、ログ(log/)、Cron、キャッシュ、データベースの変換。 | |
| 63 | + * data/が空でも使える(管理者が居なければ、config.phpのADMIN_SETUP_CODEで最初の管理者を作る)。 | |
| 64 | + */ | |
| 65 | +class Admin { | |
| 66 | + use Mods; | |
| 67 | + | |
| 68 | + /** 変換の一時ファイル(アップロード・ダウンロード)を残す秒数 */ | |
| 69 | + private const TMP_TTL = 3600; | |
| 70 | + /** Fediverseのキャッシュ(data/ap/の下、消しても必要な時に取り直す) */ | |
| 71 | + private const FEDI_CACHES = ['remote/actors' => 'リモートのアクター', 'remote/webfinger' => 'WebFinger', 'remote/media' => 'リモートの画像']; | |
| 72 | + | |
| 73 | + private Auth $auth; | |
| 74 | + private ?\stdClass $user; | |
| 75 | + | |
| 76 | + public function __construct() { | |
| 77 | + $this->auth = new Auth(); | |
| 78 | + $this->user = $this->auth->getLoggedInUser(); | |
| 79 | + } | |
| 80 | + | |
| 81 | + //////// ページ | |
| 82 | + | |
| 83 | + /** 概要 */ | |
| 84 | + public function index(array $params): void { | |
| 85 | + $this->guard(); | |
| 86 | + $data = ROOT.'/data'; | |
| 87 | + $dataFiles = $this->countFiles($data, 20000); | |
| 88 | + $logs = new Logs(); | |
| 89 | + $cron = new Cron(); | |
| 90 | + $tick = CRON_ENABLED ? $cron->lastTick() : null; | |
| 91 | + $users = $this->auth->listUsers(); | |
| 92 | + $warnings = []; | |
| 93 | + if (!is_dir($data)) $warnings[] = 'data/が有りません(必要な時に作ります)。'; | |
| 94 | + else if (!is_writable($data)) $warnings[] = 'data/に書き込めません(php-fpmのユーザーのchownをご確認下さい)。'; | |
| 95 | + else if ($dataFiles['files'] === 0) $warnings[] = 'data/は空です。'; | |
| 96 | + if (!is_dir($logs->dir())) $warnings[] = 'log/が有りません(最初のログを書く時に作ります。作れない場合は、php-fpmのユーザーが書けるlog/を作って下さい)。'; | |
| 97 | + else if (!is_writable($logs->dir())) $warnings[] = 'log/に書き込めません。'; | |
| 98 | + if (CRON_ENABLED && ($tick === null || $tick < time() - 600)) $warnings[] = 'Cronが最近実行されていません(システムのcrontabをご確認下さい)。'; | |
| 99 | + if (defined('ADMIN_SETUP_CODE') && (string)ADMIN_SETUP_CODE !== '') $warnings[] = '管理者が居るので、config.phpのADMIN_SETUP_CODEは空にして下さい。'; | |
| 100 | + | |
| 101 | + $cacheSize = 0; | |
| 102 | + $cacheFiles = 0; | |
| 103 | + foreach (Cache::kinds() as $k) { | |
| 104 | + foreach ((new Cache($k))->entries() as $e) { | |
| 105 | + $cacheSize += $e['size']; | |
| 106 | + $cacheFiles++; | |
| 107 | + } | |
| 108 | + } | |
| 109 | + $features = []; | |
| 110 | + foreach (['AUTH_ENABLED', 'AUTH_REGISTER_ENABLED', 'AUTH_VERIFY_EMAIL', 'AUTH_2FA_ENABLED', 'MAILER_ENABLED', 'LOGGING_ENABLED', 'ATOM_ENABLED', | |
| 111 | + 'ACTIVITYPUB_ENABLED', 'ACTIVITYPUB_MULTIUSER', 'COMMENTS_ENABLED', 'CRON_ENABLED', 'DB_ENABLED', 'OPENPROVIDER_ENABLED', 'DEBUG_MODE'] as $c) { | |
| 112 | + $features[] = ['name' => $c, 'on' => defined($c) && constant($c)]; | |
| 113 | + } | |
| 114 | + | |
| 115 | + $this->render('admin/index', '管理画面', 'index', [ | |
| 116 | + 'warnings' => $warnings, | |
| 117 | + 'dataDir' => ['exists' => is_dir($data), 'writable' => is_writable($data), 'files' => $dataFiles['files'], 'size' => $this->size($dataFiles['size']), 'capped' => $dataFiles['capped']], | |
| 118 | + 'logCount' => count($logs->list()), | |
| 119 | + 'logSize' => $this->size(array_sum(array_column($logs->list(), 'size'))), | |
| 120 | + 'userCount' => count($users), | |
| 121 | + 'adminCount' => count(array_filter($users, fn($u) => $u->role & Roles::ADMIN)), | |
| 122 | + 'cronTick' => $tick === null ? '-' : date('Y-m-d H:i', $tick), | |
| 123 | + 'cache' => "{$cacheFiles}件(".$this->size($cacheSize).')', | |
| 124 | + 'db' => defined('DB_ENABLED') && DB_ENABLED ? (string)(DBINFO['driver'] ?? 'mysql') : '無効', | |
| 125 | + 'php' => PHP_VERSION, | |
| 126 | + 'features' => $features, | |
| 127 | + ]); | |
| 128 | + } | |
| 129 | + | |
| 130 | + /** 最初の管理者(管理者が1人も居ない時だけ) */ | |
| 131 | + public function setup(array $params): void { | |
| 132 | + if ($this->auth->hasAdmin()) $this->redirect('/admin'); | |
| 133 | + $code = defined('ADMIN_SETUP_CODE') ? (string)ADMIN_SETUP_CODE : ''; | |
| 134 | + $error = ''; | |
| 135 | + $in = ['username' => '', 'email' => '']; | |
| 136 | + if ($_SERVER['REQUEST_METHOD'] === 'POST' && $code !== '') { | |
| 137 | + $in = ['username' => (string)($_POST['username'] ?? ''), 'email' => (string)($_POST['email'] ?? '')]; | |
| 138 | + if (!$this->csrfOk()) { | |
| 139 | + $error = 'セッションの有効期限が切れました。もう一度お試し下さい。'; | |
| 140 | + } else { | |
| 141 | + $res = $this->auth->setupFirstAdmin((string)($_POST['code'] ?? ''), $in['username'], (string)($_POST['password'] ?? ''), | |
| 142 | + (string)($_POST['passwordVerify'] ?? ''), $in['email']); | |
| 143 | + if ($res->isSuccess) $this->redirect('/admin'); | |
| 144 | + $error = (string)$res->message; | |
| 145 | + } | |
| 146 | + } | |
| 147 | + $this->render('admin/setup', '最初の管理者', 'setup', ['hasCode' => $code !== '', 'error' => $error, 'in' => $in]); | |
| 148 | + } | |
| 149 | + | |
| 150 | + /** ユーザーの一覧と作成 */ | |
| 151 | + public function users(array $params): void { | |
| 152 | + $this->guard(); | |
| 153 | + $error = ''; | |
| 154 | + $message = ''; | |
| 155 | + $in = ['username' => '', 'email' => '']; | |
| 156 | + if ($_SERVER['REQUEST_METHOD'] === 'POST') { | |
| 157 | + $in = ['username' => (string)($_POST['username'] ?? ''), 'email' => (string)($_POST['email'] ?? '')]; | |
| 158 | + if (!$this->csrfOk()) { | |
| 159 | + $error = 'セッションの有効期限が切れました。もう一度お試し下さい。'; | |
| 160 | + } else { | |
| 161 | + $res = $this->auth->adminCreateUser($in['username'], (string)($_POST['password'] ?? ''), (string)($_POST['passwordVerify'] ?? ''), | |
| 162 | + $in['email'], array_map('intval', (array)($_POST['roles'] ?? []))); | |
| 163 | + if ($res->isSuccess) { | |
| 164 | + $message = (string)$res->message; | |
| 165 | + $in = ['username' => '', 'email' => '']; | |
| 166 | + } else { | |
| 167 | + $error = (string)$res->message; | |
| 168 | + } | |
| 169 | + } | |
| 170 | + } | |
| 171 | + $q = trim((string)($_GET['q'] ?? '')); | |
| 172 | + $list = []; | |
| 173 | + foreach ($this->auth->listUsers() as $u) { | |
| 174 | + if ($q !== '' && mb_stripos($u->username.' '.$u->displayname.' '.$u->email, $q) === false) continue; | |
| 175 | + $list[] = $this->presentUser($u); | |
| 176 | + } | |
| 177 | + $this->render('admin/users', 'ユーザー', 'users', [ | |
| 178 | + 'list' => $list, 'q' => $q, 'error' => $error, 'message' => $message, 'in' => $in, 'roles' => Roles::LABELS, | |
| 179 | + ]); | |
| 180 | + } | |
| 181 | + | |
| 182 | + /** ユーザー1人のアカウント */ | |
| 183 | + public function user(array $params): void { | |
| 184 | + $this->guard(); | |
| 185 | + $name = (string)($params['name'] ?? ''); | |
| 186 | + if ($this->auth->getAccount($name) === null) $this->redirect('/admin/users'); | |
| 187 | + $error = ''; | |
| 188 | + $message = ''; | |
| 189 | + if ($_SERVER['REQUEST_METHOD'] === 'POST') { | |
| 190 | + if (!$this->csrfOk()) { | |
| 191 | + $error = 'セッションの有効期限が切れました。もう一度お試し下さい。'; | |
| 192 | + } else { | |
| 193 | + $in = []; | |
| 194 | + if (($_POST['form'] ?? '') === 'roles') { | |
| 195 | + $in['roles'] = array_map('intval', (array)($_POST['roles'] ?? [])); | |
| 196 | + $in['verify'] = isset($_POST['verify']); | |
| 197 | + $in['logoutAll'] = isset($_POST['logoutAll']); | |
| 198 | + } else { | |
| 199 | + $in['email'] = (string)($_POST['email'] ?? ''); | |
| 200 | + $in['password'] = (string)($_POST['password'] ?? ''); | |
| 201 | + $in['passwordVerify'] = (string)($_POST['passwordVerify'] ?? ''); | |
| 202 | + $in['disableTwoFactor'] = isset($_POST['disableTwoFactor']); | |
| 203 | + } | |
| 204 | + $res = $this->auth->adminUpdateAccount($name, $in, (string)($_POST['adminPassword'] ?? '')); | |
| 205 | + if ($res->isSuccess) $message = (string)$res->message; | |
| 206 | + else $error = (string)$res->message; | |
| 207 | + // 自分のログインを取り消した場合 | |
| 208 | + if ($res->isSuccess && $this->user && $name === $this->user->username && (!empty($in['logoutAll']) || ($in['password'] ?? '') !== '')) { | |
| 209 | + $this->redirect('/login'); | |
| 210 | + } | |
| 211 | + } | |
| 212 | + } | |
| 213 | + $u = $this->auth->getAccount($name); | |
| 214 | + $roles = []; | |
| 215 | + foreach (Roles::LABELS as $bit => $label) $roles[] = ['bit' => $bit, 'label' => $label, 'on' => (bool)($u->role & $bit)]; | |
| 216 | + $this->render('admin/user', "ユーザー:{$u->username}", 'users', [ | |
| 217 | + 'u' => $this->presentUser($u), 'roles' => $roles, 'error' => $error, 'message' => $message, | |
| 218 | + 'isSelf' => $this->user && $this->user->username === $u->username, | |
| 219 | + ]); | |
| 220 | + } | |
| 221 | + | |
| 222 | + /** ログ(log/) */ | |
| 223 | + public function logs(array $params): void { | |
| 224 | + $this->guard(); | |
| 225 | + $logs = new Logs(); | |
| 226 | + $file = (string)($_GET['file'] ?? $_POST['file'] ?? ''); | |
| 227 | + $error = ''; | |
| 228 | + $message = ''; | |
| 229 | + if ($_SERVER['REQUEST_METHOD'] === 'POST') { | |
| 230 | + if (!$this->csrfOk()) { | |
| 231 | + $error = 'セッションの有効期限が切れました。もう一度お試し下さい。'; | |
| 232 | + } else if (($_POST['do'] ?? '') === 'clear') { | |
| 233 | + if ($logs->clear($file)) $message = "{$file}を空にしました。"; | |
| 234 | + else $error = "{$file}を空に出来ませんでした。"; | |
| 235 | + } else if (($_POST['do'] ?? '') === 'delete') { | |
| 236 | + if ($logs->delete($file)) { | |
| 237 | + $message = "{$file}を削除しました。"; | |
| 238 | + $file = ''; | |
| 239 | + } else { | |
| 240 | + $error = "{$file}を削除出来ませんでした。"; | |
| 241 | + } | |
| 242 | + } | |
| 243 | + } | |
| 244 | + if ($file !== '' && isset($_GET['download'])) { | |
| 245 | + $p = $logs->path($file); | |
| 246 | + if ($p === null) $this->redirect('/admin/logs'); | |
| 247 | + $this->sendFile($p, $file, 'text/plain; charset=UTF-8'); | |
| 248 | + } | |
| 249 | + $lines = (int)($_GET['lines'] ?? 200); | |
| 250 | + if (!in_array($lines, [50, 200, 1000, 5000], true)) $lines = 200; | |
| 251 | + $q = (string)($_GET['q'] ?? ''); | |
| 252 | + $tail = $file !== '' ? $logs->tail($file, $lines, $q) : null; | |
| 253 | + if ($file !== '' && $tail === null && $error === '' && $message === '') $error = "{$file}が有りません。"; | |
| 254 | + $list = array_map(fn($f) => $f + ['sizeText' => $this->size($f['size']), 'time' => date('Y-m-d H:i', $f['mtime'])], $logs->list()); | |
| 255 | + $this->render('admin/logs', 'ログ', 'logs', [ | |
| 256 | + 'list' => $list, 'file' => $tail !== null ? $file : '', 'tail' => $tail, 'lines' => $lines, 'q' => $q, | |
| 257 | + 'error' => $error, 'message' => $message, 'dirExists' => is_dir($logs->dir()), 'logging' => LOGGING_ENABLED, | |
| 258 | + ]); | |
| 259 | + } | |
| 260 | + | |
| 261 | + /** Cronのジョブ */ | |
| 262 | + public function cron(array $params): void { | |
| 263 | + $this->guard(); | |
| 264 | + $cron = new Cron(); | |
| 265 | + register_cron_tasks($cron); | |
| 266 | + $error = ''; | |
| 267 | + $message = ''; | |
| 268 | + if ($_SERVER['REQUEST_METHOD'] === 'POST' && CRON_ENABLED) { | |
| 269 | + if (!$this->csrfOk()) { | |
| 270 | + $error = 'セッションの有効期限が切れました。もう一度お試し下さい。'; | |
| 271 | + } else { | |
| 272 | + $id = (int)($_POST['id'] ?? 0); | |
| 273 | + $do = (string)($_POST['do'] ?? ''); | |
| 274 | + try { | |
| 275 | + $res = match ($do) { | |
| 276 | + 'add' => $cron->add(CronTab::fromString(trim((string)($_POST['schedule'] ?? '')).' '.trim((string)($_POST['task'] ?? '')))), | |
| 277 | + 'edit' => $cron->edit($id, CronTab::fromString(trim((string)($_POST['schedule'] ?? '')).' '.trim((string)($_POST['task'] ?? '')), | |
| 278 | + (bool)($cron->show($id)->data['isActive'] ?? true))), | |
| 279 | + 'toggle' => $cron->toggle($id), | |
| 280 | + 'delete' => $cron->delete($id), | |
| 281 | + 'run' => $this->runJob($cron, $id), | |
| 282 | + default => \Result::Error('不明な操作です。'), | |
| 283 | + }; | |
| 284 | + } catch (\InvalidArgumentException $e) { | |
| 285 | + $res = \Result::Error($e->getMessage()); | |
| 286 | + } | |
| 287 | + if ($res->isSuccess) { | |
| 288 | + $message = (string)$res->message ?: '完了しました。'; | |
| 289 | + $task = trim((string)($_POST['task'] ?? '')); | |
| 290 | + if (in_array($do, ['add', 'edit'], true) && !in_array($task, $cron->tasks(), true)) $message .= "(注意:タスク「{$task}」は登録されていません)"; | |
| 291 | + } else { | |
| 292 | + $error = (string)$res->message; | |
| 293 | + } | |
| 294 | + } | |
| 295 | + } | |
| 296 | + $jobs = []; | |
| 297 | + $tasks = $cron->tasks(); | |
| 298 | + if (CRON_ENABLED) { | |
| 299 | + $res = $cron->list(); | |
| 300 | + if (!$res->isSuccess) $error = $error ?: (string)$res->message; | |
| 301 | + $now = new \DateTimeImmutable(); | |
| 302 | + foreach ($res->isSuccess ? $res->data : [] as $d) { | |
| 303 | + $ct = CronTab::fromArray($d); | |
| 304 | + $err = $ct->validate(); | |
| 305 | + $next = $err === null && $ct->isActive ? $ct->nextRun($now) : null; | |
| 306 | + $jobs[] = [ | |
| 307 | + 'id' => $ct->getId(), 'active' => $ct->isActive, 'schedule' => $ct->expression(), 'task' => $ct->command, | |
| 308 | + 'registered' => in_array($ct->command, $tasks, true), 'next' => $next ? $next->format('Y-m-d H:i') : '-', | |
| 309 | + 'last' => $ct->lastRun ? date('Y-m-d H:i', $ct->lastRun) : '-', 'error' => $err ?? $ct->lastError ?? '', | |
| 310 | + ]; | |
| 311 | + } | |
| 312 | + } | |
| 313 | + $tick = CRON_ENABLED ? $cron->lastTick() : null; | |
| 314 | + $this->render('admin/cron', 'Cron', 'cron', [ | |
| 315 | + 'enabled' => CRON_ENABLED, 'jobs' => $jobs, 'tasks' => $tasks, 'error' => $error, 'message' => $message, | |
| 316 | + 'tick' => $tick === null ? null : date('Y-m-d H:i', $tick), 'stale' => $tick === null || $tick < time() - 600, | |
| 317 | + ]); | |
| 318 | + } | |
| 319 | + | |
| 320 | + /** キャッシュ(data/cache/とFediverseのキャッシュ) */ | |
| 321 | + public function cache(array $params): void { | |
| 322 | + $this->guard(); | |
| 323 | + $error = ''; | |
| 324 | + $message = ''; | |
| 325 | + if ($_SERVER['REQUEST_METHOD'] === 'POST') { | |
| 326 | + if (!$this->csrfOk()) { | |
| 327 | + $error = 'セッションの有効期限が切れました。もう一度お試し下さい。'; | |
| 328 | + } else { | |
| 329 | + $kind = (string)($_POST['kind'] ?? ''); | |
| 330 | + $name = (string)($_POST['name'] ?? ''); | |
| 331 | + $do = (string)($_POST['do'] ?? ''); | |
| 332 | + try { | |
| 333 | + if ($do === 'one' && in_array($kind, Cache::kinds(), true)) { | |
| 334 | + $res = (new Cache($kind))->murder($name); | |
| 335 | + $res->isSuccess ? $message = "{$kind}/{$name}を削除しました。" : $error = (string)$res->message; | |
| 336 | + } else if ($do === 'kind' && in_array($kind, Cache::kinds(), true)) { | |
| 337 | + $message = "{$kind}の".(new Cache($kind))->murderAll().'件を削除しました。'; | |
| 338 | + } else if ($do === 'all') { | |
| 339 | + $n = 0; | |
| 340 | + foreach (Cache::kinds() as $k) $n += (new Cache($k))->murderAll(); | |
| 341 | + $message = "キャッシュ{$n}件を削除しました。"; | |
| 342 | + } else if ($do === 'fedi' && isset(self::FEDI_CACHES[$kind])) { | |
| 343 | + $n = 0; | |
| 344 | + foreach (glob(ROOT.'/data/ap/'.$kind.'/*') ?: [] as $f) if (is_file($f) && @unlink($f)) $n++; | |
| 345 | + $message = self::FEDI_CACHES[$kind]."のキャッシュ{$n}件を削除しました。"; | |
| 346 | + } else { | |
| 347 | + $error = '不明な操作です。'; | |
| 348 | + } | |
| 349 | + } catch (\InvalidArgumentException $e) { | |
| 350 | + $error = $e->getMessage(); | |
| 351 | + } | |
| 352 | + } | |
| 353 | + } | |
| 354 | + // 1件の中身 | |
| 355 | + $view = null; | |
| 356 | + $vk = (string)($_GET['kind'] ?? ''); | |
| 357 | + $vn = (string)($_GET['name'] ?? ''); | |
| 358 | + if ($vk !== '' && in_array($vk, Cache::kinds(), true)) { | |
| 359 | + $data = (new Cache($vk))->get($vn); | |
| 360 | + if (!empty($data)) $view = ['kind' => $vk, 'name' => $vn, 'json' => (string)json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)]; | |
| 361 | + } | |
| 362 | + $kinds = []; | |
| 363 | + foreach (Cache::kinds() as $k) { | |
| 364 | + $entries = array_map(fn($e) => $e + ['sizeText' => $this->size($e['size']), 'time' => date('Y-m-d H:i', $e['created'] ?? $e['mtime'])], (new Cache($k))->entries()); | |
| 365 | + $kinds[] = ['name' => $k, 'entries' => $entries, 'size' => $this->size(array_sum(array_column($entries, 'size')))]; | |
| 366 | + } | |
| 367 | + $fedi = []; | |
| 368 | + foreach (self::FEDI_CACHES as $dir => $label) { | |
| 369 | + $c = $this->countFiles(ROOT.'/data/ap/'.$dir, 100000); | |
| 370 | + if ($c['files'] > 0) $fedi[] = ['dir' => $dir, 'label' => $label, 'files' => $c['files'], 'size' => $this->size($c['size'])]; | |
| 371 | + } | |
| 372 | + $this->render('admin/cache', 'キャッシュ', 'cache', ['kinds' => $kinds, 'fedi' => $fedi, 'view' => $view, 'error' => $error, 'message' => $message]); | |
| 373 | + } | |
| 374 | + | |
| 375 | + /** データベースの変換 */ | |
| 376 | + public function convert(array $params): void { | |
| 377 | + $this->guard(); | |
| 378 | + $tmp = ROOT.'/data/tmp/convert'; | |
| 379 | + $this->cleanTmp($tmp); | |
| 380 | + // 変換したファイルのダウンロード | |
| 381 | + if (isset($_GET['download'])) { | |
| 382 | + $token = (string)$_GET['download']; | |
| 383 | + $meta = preg_match('/^[0-9a-f]{32}$/', $token) ? json_decode((string)@file_get_contents("{$tmp}/{$token}.json"), true) : null; | |
| 384 | + if (!is_array($meta) || !is_file("{$tmp}/{$token}.out")) $this->redirect('/admin/convert'); | |
| 385 | + $this->sendFile("{$tmp}/{$token}.out", (string)$meta['name'], (string)$meta['type']); | |
| 386 | + } | |
| 387 | + | |
| 388 | + $form = $this->convertForm(); | |
| 389 | + $error = ''; | |
| 390 | + $result = null; | |
| 391 | + $tables = null; | |
| 392 | + if ($_SERVER['REQUEST_METHOD'] === 'POST') { | |
| 393 | + if (empty($_POST) && (int)($_SERVER['CONTENT_LENGTH'] ?? 0) > 0) { | |
| 394 | + // post_max_sizeを超えると、PHPは$_POSTを空にする | |
| 395 | + $error = 'ファイルが大き過ぎます(php.iniのupload_max_filesize・post_max_size)。'; | |
| 396 | + } else if (!$this->csrfOk()) { | |
| 397 | + $error = 'セッションの有効期限が切れました。もう一度お試し下さい。'; | |
| 398 | + } else { | |
| 399 | + @set_time_limit(0); | |
| 400 | + ignore_user_abort(true); | |
| 401 | + try { | |
| 402 | + $tmp = $this->tmpDir(); | |
| 403 | + $form['src']['upload'] = $this->takeUpload($tmp, $form['src']['upload']); | |
| 404 | + $src = $this->source($form['src'], $tmp); | |
| 405 | + if (($_POST['do'] ?? '') === 'inspect') { | |
| 406 | + $tables = array_map(fn($t) => ['name' => $t['name'], 'columns' => count($t['columns'])], $src->tables()); | |
| 407 | + } else { | |
| 408 | + $result = $this->runConvert($src, $form, $tmp); | |
| 409 | + } | |
| 410 | + } catch (\Throwable $e) { | |
| 411 | + $error = $e->getMessage(); | |
| 412 | + } | |
| 413 | + } | |
| 414 | + } | |
| 415 | + $this->render('admin/convert', 'データベースの変換', 'convert', [ | |
| 416 | + 'f' => $form, 'error' => $error, 'result' => $result, 'tables' => $tables, | |
| 417 | + 'siteDb' => defined('DB_ENABLED') && DB_ENABLED ? (string)(DBINFO['driver'] ?? 'mysql') : '', | |
| 418 | + 'zlib' => function_exists('gzopen'), | |
| 419 | + ]); | |
| 420 | + } | |
| 421 | + | |
| 422 | + //////// 変換 | |
| 423 | + | |
| 424 | + /** フォームの値(パスワードは画面に戻さない) */ | |
| 425 | + private function convertForm(): array { | |
| 426 | + $side = function(string $p, string $defKind): array { | |
| 427 | + $g = fn(string $k, string $d = '') => is_string($_POST["{$p}_{$k}"] ?? null) ? trim($_POST["{$p}_{$k}"]) : $d; | |
| 428 | + return [ | |
| 429 | + 'kind' => $g('kind', $defKind), 'dir' => $g('dir', 'data'), 'path' => $g('path', $p === 'src' ? '' : 'data/site.sqlite'), | |
| 430 | + 'host' => $g('host', '127.0.0.1'), 'port' => $g('port'), 'socket' => $g('socket'), 'username' => $g('username'), | |
| 431 | + 'password' => $g('password'), 'dbname' => $g('dbname'), 'tls' => isset($_POST["{$p}_tls"]), | |
| 432 | + 'dialect' => $g('dialect', 'mysql'), 'gzip' => isset($_POST["{$p}_gzip"]), 'overwrite' => isset($_POST["{$p}_overwrite"]), | |
| 433 | + 'dumppath' => $g('dumppath'), 'upload' => $g('upload'), | |
| 434 | + ]; | |
| 435 | + }; | |
| 436 | + return [ | |
| 437 | + 'src' => $side('src', 'json'), | |
| 438 | + 'dst' => $side('dst', 'sqlite'), | |
| 439 | + 'tables' => is_string($_POST['tables'] ?? null) ? trim($_POST['tables']) : '', | |
| 440 | + 'drop' => isset($_POST['drop']), | |
| 441 | + ]; | |
| 442 | + } | |
| 443 | + | |
| 444 | + /** | |
| 445 | + * サーバーのパス(相対パスはROOTから。ROOTの外は不可) | |
| 446 | + * | |
| 447 | + * @param bool $mustExist ファイル・ディレクトリが有る事 | |
| 448 | + */ | |
| 449 | + private function serverPath(string $path, bool $mustExist): string { | |
| 450 | + $path = str_replace('\\', '/', trim($path)); | |
| 451 | + if ($path === '' || preg_match('/[\x00-\x1f]/', $path)) throw new \InvalidArgumentException('パスを入力して下さい。'); | |
| 452 | + if (!preg_match('#^(/|[A-Za-z]:/)#', $path)) $path = ROOT.'/'.$path; | |
| 453 | + foreach (explode('/', $path) as $part) if ($part === '..') throw new \InvalidArgumentException("「..」は使えません:{$path}"); | |
| 454 | + $root = rtrim(str_replace('\\', '/', (string)realpath(ROOT)), '/'); | |
| 455 | + // 有る親のディレクトリで確かめる(シンボリックリンクで外に出ない様に) | |
| 456 | + $check = $path; | |
| 457 | + while (!file_exists($check) && dirname($check) !== $check) $check = dirname($check); | |
| 458 | + $real = str_replace('\\', '/', (string)realpath($check)); | |
| 459 | + if ($real !== $root && !str_starts_with($real.'/', $root.'/')) throw new \InvalidArgumentException("サイトのディレクトリの外は使えません:{$path}"); | |
| 460 | + if ($mustExist && !file_exists($path)) throw new \InvalidArgumentException("有りません:{$path}"); | |
| 461 | + return $path; | |
| 462 | + } | |
| 463 | + | |
| 464 | + /** フォームの接続の情報 */ | |
| 465 | + private function connect(array $s): Db { | |
| 466 | + if ($s['kind'] === 'site') { | |
| 467 | + if (!(defined('DB_ENABLED') && DB_ENABLED)) throw new \InvalidArgumentException('サイトのデータベース(DB_ENABLED)は無効です。'); | |
| 468 | + return new Db(); | |
| 469 | + } | |
| 470 | + if ($s['kind'] === 'sqlite') return new Db(['driver' => 'sqlite', 'path' => $s['path']]); | |
| 471 | + $info = ['driver' => $s['kind'], 'host' => $s['host'] ?: '127.0.0.1', 'username' => $s['username'], 'password' => $s['password'], | |
| 472 | + 'dbname' => $s['dbname'], 'tls' => $s['tls'], 'timeout' => 10]; | |
| 473 | + if ($s['port'] !== '') $info['port'] = (int)$s['port']; | |
| 474 | + if ($s['socket'] !== '') $info['socket'] = $s['socket']; | |
| 475 | + if ($s['kind'] === 'mysql') $info['charset'] = 'utf8mb4'; | |
| 476 | + return new Db($info); | |
| 477 | + } | |
| 478 | + | |
| 479 | + /** アップロードしたファイル(ダンプ・SQLite)を一時ディレクトリに置く */ | |
| 480 | + private function takeUpload(string $tmp, string $token): string { | |
| 481 | + $f = $_FILES['src_file'] ?? null; | |
| 482 | + if (is_array($f) && ($f['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE) { | |
| 483 | + if ($f['error'] !== UPLOAD_ERR_OK) { | |
| 484 | + throw new \RuntimeException($f['error'] === UPLOAD_ERR_INI_SIZE || $f['error'] === UPLOAD_ERR_FORM_SIZE | |
| 485 | + ? 'ファイルが大き過ぎます(php.iniのupload_max_filesize・post_max_size)。' : 'アップロードに失敗しました。'); | |
| 486 | + } | |
| 487 | + $token = bin2hex(random_bytes(16)); | |
| 488 | + if (!is_uploaded_file($f['tmp_name']) || !move_uploaded_file($f['tmp_name'], "{$tmp}/{$token}.in")) throw new \RuntimeException('アップロードしたファイルを保存出来ません。'); | |
| 489 | + file_put_contents("{$tmp}/{$token}.json", json_encode(['name' => basename((string)$f['name'])], JSON_UNESCAPED_UNICODE)); | |
| 490 | + return $token; | |
| 491 | + } | |
| 492 | + // 前の画面(テーブルの確認)でアップロードしたファイル | |
| 493 | + return preg_match('/^[0-9a-f]{32}$/', $token) && is_file("{$tmp}/{$token}.in") ? $token : ''; | |
| 494 | + } | |
| 495 | + | |
| 496 | + private function source(array $s, string $tmp): Source { | |
| 497 | + switch ($s['kind']) { | |
| 498 | + case 'json': | |
| 499 | + return new JsonSource($this->serverPath($s['dir'], false)); | |
| 500 | + case 'dump': | |
| 501 | + if ($s['upload'] !== '') { | |
| 502 | + $file = "{$tmp}/{$s['upload']}.in"; | |
| 503 | + // SQLiteのファイルをアップロードした場合 | |
| 504 | + if ((string)@file_get_contents($file, false, null, 0, 16) === "SQLite format 3\0") return new DbSource(new Db(['driver' => 'sqlite', 'path' => $file, 'readonly' => true])); | |
| 505 | + return new DumpSource($file); | |
| 506 | + } | |
| 507 | + if ($s['dumppath'] === '') throw new \InvalidArgumentException('ダンプのファイルをアップロードするか、サーバーのパスを入力して下さい。'); | |
| 508 | + return new DumpSource($this->serverPath($s['dumppath'], true)); | |
| 509 | + case 'sqlite': | |
| 510 | + $s['path'] = $this->serverPath($s['path'], true); | |
| 511 | + return new DbSource(new Db(['driver' => 'sqlite', 'path' => $s['path'], 'readonly' => true])); | |
| 512 | + case 'mysql': | |
| 513 | + case 'pgsql': | |
| 514 | + case 'site': | |
| 515 | + return new DbSource($this->connect($s)); | |
| 516 | + } | |
| 517 | + throw new \InvalidArgumentException('読む元を選んで下さい。'); | |
| 518 | + } | |
| 519 | + | |
| 520 | + /** | |
| 521 | + * 変換する | |
| 522 | + * | |
| 523 | + * @return array tables、warnings、seconds、download(ダウンロードのURL) | |
| 524 | + */ | |
| 525 | + private function runConvert(Source $src, array $f, string $tmp): array { | |
| 526 | + $tables = $f['tables'] !== '' ? array_values(array_filter(array_map('trim', explode(',', $f['tables'])), fn($t) => $t !== '')) : null; | |
| 527 | + // 書く先を作る前に(空のファイル等を残さない様に) | |
| 528 | + $names = array_map(fn($t) => strtolower($t['name']), $src->tables()); | |
| 529 | + if ($tables !== null) $names = array_intersect($names, array_map('strtolower', $tables)); | |
| 530 | + if (empty($names)) throw new \RuntimeException($tables === null ? '読む元にテーブルが有りません。' : '指定したテーブルが読む元に有りません。'); | |
| 531 | + $d = $f['dst']; | |
| 532 | + $download = null; | |
| 533 | + $token = bin2hex(random_bytes(16)); | |
| 534 | + switch ($d['kind']) { | |
| 535 | + case 'json': | |
| 536 | + $dst = new JsonTarget($this->serverPath($d['dir'], false), $d['overwrite']); | |
| 537 | + break; | |
| 538 | + case 'dump': | |
| 539 | + $ext = '.sql'.($d['gzip'] ? '.gz' : ''); | |
| 540 | + if ($d['dumppath'] !== '') { | |
| 541 | + $path = $this->serverPath($d['dumppath'], false); | |
| 542 | + if (file_exists($path) && !$d['overwrite']) throw new \InvalidArgumentException("既に有ります(上書きを選んで下さい):{$path}"); | |
| 543 | + $dst = new DumpTarget($path, $d['dialect']); | |
| 544 | + } else { | |
| 545 | + $dst = new DumpTarget("{$tmp}/{$token}.out{$ext}", $d['dialect']); | |
| 546 | + $download = ['name' => 'littlebeast-'.$d['dialect'].'-'.date('Ymd-His').$ext, 'type' => $d['gzip'] ? 'application/gzip' : 'application/sql']; | |
| 547 | + } | |
| 548 | + break; | |
| 549 | + case 'sqlite': | |
| 550 | + if ($d['path'] !== '') { | |
| 551 | + $d['path'] = $this->serverPath($d['path'], false); | |
| 552 | + $dst = new DbTarget(new Db(['driver' => 'sqlite', 'path' => $d['path']])); | |
| 553 | + } else { | |
| 554 | + $dst = new DbTarget(new Db(['driver' => 'sqlite', 'path' => "{$tmp}/{$token}.out"])); | |
| 555 | + $download = ['name' => 'littlebeast-'.date('Ymd-His').'.sqlite', 'type' => 'application/vnd.sqlite3']; | |
| 556 | + } | |
| 557 | + break; | |
| 558 | + case 'mysql': | |
| 559 | + case 'pgsql': | |
| 560 | + case 'site': | |
| 561 | + $dst = new DbTarget($this->connect($d)); | |
| 562 | + break; | |
| 563 | + default: | |
| 564 | + throw new \InvalidArgumentException('書く先を選んで下さい。'); | |
| 565 | + } | |
| 566 | + // 同時に2つの変換をしない | |
| 567 | + $lock = fopen("{$tmp}/.lock", 'c'); | |
| 568 | + if (!$lock || !flock($lock, LOCK_EX | LOCK_NB)) throw new \RuntimeException('別の変換が実行中です。'); | |
| 569 | + try { | |
| 570 | + $start = microtime(true); | |
| 571 | + $res = Convert::run($src, $dst, ['tables' => $tables, 'drop' => $f['drop']]); | |
| 572 | + $res['seconds'] = round(microtime(true) - $start, 1); | |
| 573 | + } finally { | |
| 574 | + flock($lock, LOCK_UN); | |
| 575 | + fclose($lock); | |
| 576 | + } | |
| 577 | + if ($download !== null) { | |
| 578 | + // DumpTargetは.gzの拡張子で圧縮するので、名前を揃える | |
| 579 | + if (is_file("{$tmp}/{$token}.out.sql.gz")) rename("{$tmp}/{$token}.out.sql.gz", "{$tmp}/{$token}.out"); | |
| 580 | + if (is_file("{$tmp}/{$token}.out.sql")) rename("{$tmp}/{$token}.out.sql", "{$tmp}/{$token}.out"); | |
| 581 | + file_put_contents("{$tmp}/{$token}.json", json_encode($download, JSON_UNESCAPED_UNICODE)); | |
| 582 | + $res['download'] = '/admin/convert?download='.$token; | |
| 583 | + $res['downloadName'] = $download['name']; | |
| 584 | + $res['downloadSize'] = $this->size((int)@filesize("{$tmp}/{$token}.out")); | |
| 585 | + } | |
| 586 | + $res['rows'] = array_sum($res['tables']); | |
| 587 | + return $res; | |
| 588 | + } | |
| 589 | + | |
| 590 | + /** 一時ディレクトリ(data/tmp/convert、変換しない) */ | |
| 591 | + private function tmpDir(): string { | |
| 592 | + $dir = ROOT.'/data/tmp/convert'; | |
| 593 | + if (!is_dir($dir) && !@mkdir($dir, 0700, true) && !is_dir($dir)) throw new \RuntimeException("一時ディレクトリを作れません:{$dir}"); | |
| 594 | + return $dir; | |
| 595 | + } | |
| 596 | + | |
| 597 | + private function cleanTmp(string $dir): void { | |
| 598 | + if (!is_dir($dir)) return; | |
| 599 | + foreach (glob($dir.'/*') ?: [] as $f) { | |
| 600 | + if (is_file($f) && filemtime($f) < time() - self::TMP_TTL) @unlink($f); | |
| 601 | + } | |
| 602 | + } | |
| 603 | + | |
| 604 | + //////// 共通 | |
| 605 | + | |
| 606 | + /** 管理者だけ(管理者が居なければ最初の管理者の画面へ) */ | |
| 607 | + private function guard(): void { | |
| 608 | + if ($this->user && $this->auth->isAdmin()) return; | |
| 609 | + if (!$this->auth->hasAdmin()) $this->redirect('/admin/setup'); | |
| 610 | + if (!$this->user) $this->redirect('/login'); | |
| 611 | + header('Location: /404'); | |
| 612 | + exit; | |
| 613 | + } | |
| 614 | + | |
| 615 | + private function runJob(Cron $cron, int $id): \Result { | |
| 616 | + @set_time_limit(300); | |
| 617 | + return $cron->runNow($id); | |
| 618 | + } | |
| 619 | + | |
| 620 | + private function presentUser(\stdClass $u): array { | |
| 621 | + return [ | |
| 622 | + 'id' => $u->id, 'username' => $u->username, 'displayname' => $u->displayname, 'email' => $u->email, 'pendingEmail' => $u->pendingEmail, | |
| 623 | + 'roles' => implode('、', $u->roleLabels), 'banned' => $u->role === Roles::BANNED, 'admin' => (bool)($u->role & Roles::ADMIN), | |
| 624 | + 'reg' => $u->regDate ? date('Y-m-d', $u->regDate) : '-', 'verified' => $u->verified, 'twoFactor' => $u->twoFactorOn, | |
| 625 | + 'sessions' => $u->sessions, 'lastLogin' => $u->lastLogin ? date('Y-m-d H:i', $u->lastLogin) : '-', 'avatar' => $u->avatarUrl, | |
| 626 | + ]; | |
| 627 | + } | |
| 628 | + | |
| 629 | + /** | |
| 630 | + * ファイルの数と大きさ(多過ぎたら途中まで) | |
| 631 | + * | |
| 632 | + * @return array files、size、capped | |
| 633 | + */ | |
| 634 | + private function countFiles(string $dir, int $max): array { | |
| 635 | + $out = ['files' => 0, 'size' => 0, 'capped' => false]; | |
| 636 | + if (!is_dir($dir)) return $out; | |
| 637 | + try { | |
| 638 | + $it = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS)); | |
| 639 | + foreach ($it as $f) { | |
| 640 | + if (!$f->isFile()) continue; | |
| 641 | + $out['files']++; | |
| 642 | + $out['size'] += $f->getSize(); | |
| 643 | + if ($out['files'] >= $max) { | |
| 644 | + $out['capped'] = true; | |
| 645 | + break; | |
| 646 | + } | |
| 647 | + } | |
| 648 | + } catch (\UnexpectedValueException $e) { | |
| 649 | + // 読めないディレクトリ | |
| 650 | + } | |
| 651 | + return $out; | |
| 652 | + } | |
| 653 | + | |
| 654 | + private function size(int $n): string { | |
| 655 | + if ($n < 1024) return "{$n} B"; | |
| 656 | + if ($n < 1048576) return round($n / 1024, 1).' KB'; | |
| 657 | + if ($n < 1073741824) return round($n / 1048576, 1).' MB'; | |
| 658 | + return round($n / 1073741824, 2).' GB'; | |
| 659 | + } | |
| 660 | + | |
| 661 | + private function sendFile(string $path, string $name, string $type): never { | |
| 662 | + while (ob_get_level() > 0) ob_end_clean(); | |
| 663 | + header('Content-Type: '.$type); | |
| 664 | + header('Content-Length: '.(int)filesize($path)); | |
| 665 | + header("Content-Disposition: attachment; filename=\"".preg_replace('/[^A-Za-z0-9._-]/', '_', $name)."\"; filename*=UTF-8''".rawurlencode($name)); | |
| 666 | + header('Cache-Control: no-store'); | |
| 667 | + header('X-Content-Type-Options: nosniff'); | |
| 668 | + readfile($path); | |
| 669 | + exit; | |
| 670 | + } | |
| 671 | + | |
| 672 | + private function csrfOk(): bool { | |
| 673 | + return is_string($_POST['csrf_token'] ?? null) && verify_csrf_token($_POST['csrf_token']); | |
| 674 | + } | |
| 675 | + | |
| 676 | + private function redirect(string $to): never { | |
| 677 | + header('Location: '.$to); | |
| 678 | + exit; | |
| 679 | + } | |
| 680 | + | |
| 681 | + private function render(string $view, string $title, string $section, array $vars): void { | |
| 682 | + $tmpl = new Template('/'); | |
| 683 | + $tmpl->assign('pagetit', $title); | |
| 684 | + $tmpl->assign('curPage', 'admin'); | |
| 685 | + $tmpl->assign('menu', $this->getMenu()); | |
| 686 | + $tmpl->assign('support', $this->getSupport()); | |
| 687 | + $tmpl->assign('sns', $this->getSns()); | |
| 688 | + $tmpl->assign('description', ''); | |
| 689 | + $tmpl->assign('user', $this->user); | |
| 690 | + $tmpl->assign('adminSection', $section); | |
| 691 | + $tmpl->assign('adminSections', ['index' => '概要', 'users' => 'ユーザー', 'logs' => 'ログ', 'cron' => 'Cron', 'cache' => 'キャッシュ', 'convert' => 'データベースの変換']); | |
| 692 | + // クッキーはテンプレートの出力の前に設定する | |
| 693 | + $tmpl->assign('csrf', make_csrf_token()); | |
| 694 | + foreach (['error' => '', 'message' => ''] as $k => $v) $tmpl->assign($k, $vars[$k] ?? $v); | |
| 695 | + foreach ($vars as $k => $v) $tmpl->assign($k, $v); | |
| 696 | + $tmpl->addCss('admin'); | |
| 697 | + $tmpl->render($view); | |
| 698 | + } | |
| 699 | +} |
| @@ -95,12 +95,27 @@ | ||
| 95 | 95 | } |
| 96 | 96 | |
| 97 | 97 | if (OPENPROVIDER_ENABLED) { |
| 98 | + static $isAdmin = null; | |
| 99 | + $isAdmin ??= (new \Std\Lib\Auth())->isAdmin(); | |
| 98 | 100 | $menu[] = [ |
| 99 | 101 | 'class' => 'menu-item', |
| 100 | 102 | 'href' => '/openprovider', |
| 101 | 103 | 'page' => 'openprovider', |
| 102 | 104 | 'text' => 'OpenProvider管理', |
| 103 | - 'show' => true, | |
| 105 | + 'show' => $isAdmin, | |
| 106 | + ]; | |
| 107 | + } | |
| 108 | + | |
| 109 | + if (AUTH_ENABLED && defined('ADMIN_ENABLED') && ADMIN_ENABLED) { | |
| 110 | + // 管理者だけに表示する | |
| 111 | + static $isAdmin = null; | |
| 112 | + $isAdmin ??= (new \Std\Lib\Auth())->isAdmin(); | |
| 113 | + $menu[] = [ | |
| 114 | + 'class' => 'menu-item', | |
| 115 | + 'href' => '/admin', | |
| 116 | + 'page' => 'admin', | |
| 117 | + 'text' => '管理', | |
| 118 | + 'show' => $isAdmin, | |
| 104 | 119 | ]; |
| 105 | 120 | } |
| 106 | 121 |
| @@ -450,7 +450,7 @@ | ||
| 450 | 450 | public function isUserExist(?string $username = null): Result { |
| 451 | 451 | if (!AUTH_ENABLED) return Result::Error('エラー:認証システムは無効です。'); |
| 452 | 452 | if (null === $username) return Result::Error('エラー:ユーザー名をご入力下さい。'); |
| 453 | - $userList = scandir($this->dataDir); | |
| 453 | + $userList = @scandir($this->dataDir) ?: []; | |
| 454 | 454 | |
| 455 | 455 | foreach ($userList as $list) { |
| 456 | 456 | if ($list === '.kara' || $list === '.' || $list === '..') continue; |
| @@ -478,40 +478,8 @@ | ||
| 478 | 478 | if ($err != '') return Result::Error($err); |
| 479 | 479 | $err = ''; |
| 480 | 480 | |
| 481 | - if (!mkdir($this->assDir.$username, 0755)) { | |
| 482 | - logger(LogType::Auth, '【mkUser】アセットディレクトリを作成出来なかった。chownをご確認下さい。パス:'.$this->assDir.$username); | |
| 483 | - return Result::Error('エラー:ユーザーのアイコンディレクトリの作成に失敗。'); | |
| 484 | - } | |
| 481 | + $user = $this->newUser($username, $password, $email, Roles::MEMBER); | |
| 485 | 482 | |
| 486 | - $file = scandir($this->dataDir); | |
| 487 | - $lastId = 0; | |
| 488 | - if ($file) { | |
| 489 | - foreach ($file as $f) { | |
| 490 | - if ($f === '.kara' || $f === '.' || $f === '..') continue; | |
| 491 | - $base = substr($f, 0, -5); // .jsonの削除 | |
| 492 | - $dot = strpos($base, '.'); | |
| 493 | - if (!$dot) continue; | |
| 494 | - $id = substr($base, 0, $dot); | |
| 495 | - if (ctype_digit($id)) { | |
| 496 | - if ((int)$id > $lastId) $lastId = $id; | |
| 497 | - } | |
| 498 | - } | |
| 499 | - $lastId++; | |
| 500 | - } | |
| 501 | - | |
| 502 | - $user = new \stdClass; | |
| 503 | - $user->id = $lastId; | |
| 504 | - $user->username = $username; | |
| 505 | - $user->password = password_hash($password, $this->algo); | |
| 506 | - $user->avatar = ''; | |
| 507 | - $user->email = $email; | |
| 508 | - $user->regDate = time(); | |
| 509 | - $user->namecolor = ''; | |
| 510 | - $user->displayname = ''; | |
| 511 | - $user->gender = Gender::UNKNOWN; | |
| 512 | - $user->role = Roles::MEMBER; | |
| 513 | - $user->tokens = []; | |
| 514 | - | |
| 515 | 483 | // メールアドレスの確認(リンクを開くまでログイン出来ない) |
| 516 | 484 | $verifyToken = null; |
| 517 | 485 | if ($this->requiresVerification()) { |
| @@ -522,15 +490,10 @@ | ||
| 522 | 490 | $user->verifySent = time(); |
| 523 | 491 | } |
| 524 | 492 | |
| 525 | - $path = "{$this->dataDir}{$lastId}.{$username}.json"; | |
| 526 | - $json = json_encode($user, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); | |
| 493 | + $res = $this->storeNewUser($user, 'mkUser'); | |
| 494 | + if (!$res->isSuccess) return $res; | |
| 495 | + $path = $res->data['path']; | |
| 527 | 496 | |
| 528 | - if (file_put_contents($path, $json) === false) { | |
| 529 | - logger(LogType::Auth, '【mkUser】JSONファイルを作成出来なかった。パス:'.$path); | |
| 530 | - rmdir($this->assDir.$username); | |
| 531 | - return Result::Error('エラー:ユーザーデータの保存に失敗。'); | |
| 532 | - } | |
| 533 | - | |
| 534 | 497 | if ($verifyToken !== null) { |
| 535 | 498 | $sent = $this->sendVerificationMail($user, $verifyToken); |
| 536 | 499 | if (!$sent->isSuccess) { |
| @@ -648,19 +611,8 @@ | ||
| 648 | 611 | |
| 649 | 612 | // ロール(管理者だけ) |
| 650 | 613 | if ($admin && isset($in['roles']) && is_array($in['roles'])) { |
| 651 | - $role = Roles::BANNED; | |
| 652 | - foreach ($in['roles'] as $bit) { | |
| 653 | - $bit = (int)$bit; | |
| 654 | - if (isset(Roles::LABELS[$bit])) $role |= $bit; | |
| 655 | - } | |
| 656 | - // 最後の管理者は管理者を外せない | |
| 657 | - if (($user->role & Roles::ADMIN) && !($role & Roles::ADMIN) && $this->countAdmins() <= 1) { | |
| 658 | - $errors[] = 'エラー:最後の管理者のロールは外せません。'; | |
| 659 | - } else { | |
| 660 | - $user->role = $role; | |
| 661 | - // Fediverseのロールの既定(全員に付与)を使わず、以後はビットで判断する | |
| 662 | - $user->fediDefault = true; | |
| 663 | - } | |
| 614 | + $err = $this->applyRoles($user, $in['roles']); | |
| 615 | + if ($err !== null) $errors[] = $err; | |
| 664 | 616 | } |
| 665 | 617 | |
| 666 | 618 | if (!empty($errors)) { |
| @@ -722,6 +674,29 @@ | ||
| 722 | 674 | return null; |
| 723 | 675 | } |
| 724 | 676 | |
| 677 | + /** | |
| 678 | + * ロールを設定する(管理者だけ) | |
| 679 | + * | |
| 680 | + * @param \stdClass $user ユーザーのデータ(変更する) | |
| 681 | + * @param array $roles ビットの配列(Roles::LABELSに無いビットは無視) | |
| 682 | + * @return string|null エラー | |
| 683 | + */ | |
| 684 | + private function applyRoles(\stdClass $user, array $roles): ?string { | |
| 685 | + $role = Roles::BANNED; | |
| 686 | + foreach ($roles as $bit) { | |
| 687 | + $bit = (int)$bit; | |
| 688 | + if (isset(Roles::LABELS[$bit])) $role |= $bit; | |
| 689 | + } | |
| 690 | + // 最後の管理者は管理者を外せない | |
| 691 | + if (((int)($user->role ?? 0) & Roles::ADMIN) && !($role & Roles::ADMIN) && $this->countAdmins() <= 1) { | |
| 692 | + return 'エラー:最後の管理者のロールは外せません。'; | |
| 693 | + } | |
| 694 | + $user->role = $role; | |
| 695 | + // Fediverseのロールの既定(全員に付与)を使わず、以後はビットで判断する | |
| 696 | + $user->fediDefault = true; | |
| 697 | + return null; | |
| 698 | + } | |
| 699 | + | |
| 725 | 700 | private function countAdmins(): int { |
| 726 | 701 | $n = 0; |
| 727 | 702 | foreach ($this->userFiles() as $path) { |
| @@ -807,7 +782,205 @@ | ||
| 807 | 782 | return Result::Success('未確認のアカウント'.count($deleted).'件を削除しました。', ['deleted' => $deleted]); |
| 808 | 783 | } |
| 809 | 784 | |
| 785 | + //////////////////// 管理画面 | |
| 786 | + | |
| 810 | 787 | /** |
| 788 | + * ログイン中のユーザーが管理者か | |
| 789 | + */ | |
| 790 | + public function isAdmin(): bool { | |
| 791 | + $me = $this->id > 0 ? $this->getUserData() : null; | |
| 792 | + return $me instanceof \stdClass && ((int)($me->role ?? 0) & Roles::ADMIN); | |
| 793 | + } | |
| 794 | + | |
| 795 | + /** | |
| 796 | + * 管理者のアカウントが有るか(無ければ管理画面で最初の管理者を作れる) | |
| 797 | + */ | |
| 798 | + public function hasAdmin(): bool { | |
| 799 | + return $this->countAdmins() > 0; | |
| 800 | + } | |
| 801 | + | |
| 802 | + /** | |
| 803 | + * 全部のユーザー(管理者だけ)。パスワード・トークン等は含まない | |
| 804 | + * | |
| 805 | + * @return array stdClass(id、username、displayname、email、pendingEmail、role、roleLabels、regDate、verified、 | |
| 806 | + * twoFactorOn、sessions(有効なログインの数)、lastLogin、avatarUrl)、IDの順 | |
| 807 | + */ | |
| 808 | + public function listUsers(): array { | |
| 809 | + if (!AUTH_ENABLED || !$this->isAdmin()) return []; | |
| 810 | + $out = []; | |
| 811 | + foreach ($this->userFiles() as $path) { | |
| 812 | + $u = json_decode((string)file_get_contents($path)); | |
| 813 | + if (!$u instanceof \stdClass || !isset($u->id, $u->username)) continue; | |
| 814 | + $out[] = $this->accountSummary($u); | |
| 815 | + } | |
| 816 | + usort($out, fn($a, $b) => $a->id <=> $b->id); | |
| 817 | + return $out; | |
| 818 | + } | |
| 819 | + | |
| 820 | + /** | |
| 821 | + * ユーザー1人(管理者だけ、listUsers()と同じ形) | |
| 822 | + */ | |
| 823 | + public function getAccount(string $username): ?\stdClass { | |
| 824 | + if (!AUTH_ENABLED || !$this->isAdmin()) return null; | |
| 825 | + $path = $this->userPath($username); | |
| 826 | + $u = $path !== null ? json_decode((string)file_get_contents($path)) : null; | |
| 827 | + return $u instanceof \stdClass ? $this->accountSummary($u) : null; | |
| 828 | + } | |
| 829 | + | |
| 830 | + private function accountSummary(\stdClass $u): \stdClass { | |
| 831 | + $now = time(); | |
| 832 | + $tokens = array_values(array_filter((array)($u->tokens ?? []), fn($t) => is_object($t) && ($t->expDate ?? 0) > $now)); | |
| 833 | + $last = 0; | |
| 834 | + foreach ($tokens as $t) $last = max($last, (int)($t->lastDate ?? $t->createDate ?? 0)); | |
| 835 | + $role = (int)($u->role ?? 0); | |
| 836 | + $labels = []; | |
| 837 | + foreach (Roles::LABELS as $bit => $label) if ($role & $bit) $labels[] = $label; | |
| 838 | + return (object)[ | |
| 839 | + 'id' => (int)$u->id, | |
| 840 | + 'username' => (string)$u->username, | |
| 841 | + 'displayname' => (string)($u->displayname ?? ''), | |
| 842 | + 'email' => (string)($u->email ?? ''), | |
| 843 | + 'pendingEmail' => (string)($u->pendingEmail ?? ''), | |
| 844 | + 'role' => $role, | |
| 845 | + 'roleLabels' => $role === Roles::BANNED ? ['BAN'] : $labels, | |
| 846 | + 'regDate' => (int)($u->regDate ?? 0), | |
| 847 | + 'verified' => ($u->verified ?? true) !== false, | |
| 848 | + 'twoFactorOn' => !empty($u->twoFactor->secret), | |
| 849 | + 'sessions' => count($tokens), | |
| 850 | + 'lastLogin' => $last ?: null, | |
| 851 | + 'avatarUrl' => avatar_url($u), | |
| 852 | + ]; | |
| 853 | + } | |
| 854 | + | |
| 855 | + /** | |
| 856 | + * 管理者がアカウントを変更する(プロフィールの欄はupdateProfile()) | |
| 857 | + * | |
| 858 | + * メールアドレス・パスワード・二段階認証の変更には管理者自身のパスワードが必要。 | |
| 859 | + * | |
| 860 | + * @param string $username 変更するユーザー | |
| 861 | + * @param array $in email、password・passwordVerify(新しいパスワード、全部のログインを取り消す)、verify(メールアドレスを確認済みにする)、 | |
| 862 | + * disableTwoFactor、logoutAll(全部のログインを取り消す)、roles(ビットの配列) | |
| 863 | + * @param string $adminPassword 管理者のパスワード | |
| 864 | + * @return Result | |
| 865 | + */ | |
| 866 | + public function adminUpdateAccount(string $username, array $in, #[SensitiveParameter] string $adminPassword = ''): Result { | |
| 867 | + if (!AUTH_ENABLED) return Result::Error('エラー:認証システムは無効です。'); | |
| 868 | + $me = $this->id > 0 ? $this->getUserData() : null; | |
| 869 | + if (!$me || !((int)($me->role ?? 0) & Roles::ADMIN)) return Result::Error('エラー:権限がありません。'); | |
| 870 | + $path = $this->userPath($username); | |
| 871 | + $user = $path !== null ? json_decode((string)file_get_contents($path)) : null; | |
| 872 | + if (!$user instanceof \stdClass) return Result::Error('エラー:ユーザーが見つかりません。'); | |
| 873 | + | |
| 874 | + $email = trim((string)($in['email'] ?? '')); | |
| 875 | + $changeEmail = $email !== '' && $email !== (string)($user->email ?? ''); | |
| 876 | + $password = (string)($in['password'] ?? ''); | |
| 877 | + $disable2fa = !empty($in['disableTwoFactor']) && !empty($user->twoFactor->secret); | |
| 878 | + if (($changeEmail || $password !== '' || $disable2fa) && !password_verify($adminPassword, (string)$me->password)) { | |
| 879 | + return Result::Error('エラー:管理者のパスワードが一致していません。'); | |
| 880 | + } | |
| 881 | + | |
| 882 | + $errors = []; | |
| 883 | + $done = []; | |
| 884 | + if ($changeEmail) { | |
| 885 | + $res = $this->verifyEmail($email, false); | |
| 886 | + if (!$res->isSuccess) $errors[] = $res->message; | |
| 887 | + else { | |
| 888 | + $user->email = $email; | |
| 889 | + unset($user->pendingEmail); | |
| 890 | + $done[] = 'メールアドレスを変更しました。'; | |
| 891 | + } | |
| 892 | + } | |
| 893 | + if ($password !== '') { | |
| 894 | + $res = $this->verifyPassword($password, (string)($in['passwordVerify'] ?? '')); | |
| 895 | + if (!$res->isSuccess) $errors[] = $res->message; | |
| 896 | + else { | |
| 897 | + $user->password = password_hash($password, $this->algo); | |
| 898 | + $user->tokens = []; | |
| 899 | + unset($user->twoFactorTicket); | |
| 900 | + $done[] = 'パスワードを変更し、全部のログインを取り消しました。'; | |
| 901 | + } | |
| 902 | + } | |
| 903 | + if (!empty($in['verify']) && (($user->verified ?? true) === false || isset($user->pendingEmail))) { | |
| 904 | + $user->verified = true; | |
| 905 | + if (isset($user->pendingEmail) && !$changeEmail) $user->email = $user->pendingEmail; | |
| 906 | + unset($user->pendingEmail, $user->verifyToken, $user->verifyExpire, $user->verifySent); | |
| 907 | + $done[] = 'メールアドレスを確認済みにしました。'; | |
| 908 | + } | |
| 909 | + if ($disable2fa) { | |
| 910 | + unset($user->twoFactor, $user->twoFactorPending, $user->twoFactorTicket); | |
| 911 | + $done[] = '二段階認証を無効にしました。'; | |
| 912 | + } | |
| 913 | + if (!empty($in['logoutAll']) && !empty($user->tokens)) { | |
| 914 | + $user->tokens = []; | |
| 915 | + $done[] = '全部のログインを取り消しました。'; | |
| 916 | + } | |
| 917 | + if (isset($in['roles']) && is_array($in['roles'])) { | |
| 918 | + $old = (int)($user->role ?? 0); | |
| 919 | + $err = $this->applyRoles($user, $in['roles']); | |
| 920 | + if ($err !== null) $errors[] = $err; | |
| 921 | + else if ($old !== $user->role) $done[] = 'ロールを変更しました。'; | |
| 922 | + } | |
| 923 | + | |
| 924 | + if (!empty($errors)) return Result::Error(implode(' ', $errors)); | |
| 925 | + if (empty($done)) return Result::Success('変更はありません。'); | |
| 926 | + if (!$this->saveUser($user, 'adminUpdateAccount')) return Result::Error('エラー:ユーザーデータの保存に失敗。'); | |
| 927 | + logger(LogType::Auth, '【adminUpdateAccount】'.date('Y-m-d H:i:s')." {$me->username}が{$user->username}を変更:".implode(' ', $done)); | |
| 928 | + return Result::Success(implode(' ', $done)); | |
| 929 | + } | |
| 930 | + | |
| 931 | + /** | |
| 932 | + * 管理者がユーザーを作る(登録が無効でも、メールアドレスの確認なし) | |
| 933 | + * | |
| 934 | + * @param array $roles ビットの配列 | |
| 935 | + * @return Result data:username | |
| 936 | + */ | |
| 937 | + public function adminCreateUser(string $username, #[SensitiveParameter] string $password, #[SensitiveParameter] string $passwordVerify, string $email, array $roles = [Roles::MEMBER]): Result { | |
| 938 | + if (!AUTH_ENABLED) return Result::Error('エラー:認証システムは無効です。'); | |
| 939 | + if (!$this->isAdmin()) return Result::Error('エラー:権限がありません。'); | |
| 940 | + return $this->createAccount($username, $password, $passwordVerify, $email, $roles, 'adminCreateUser'); | |
| 941 | + } | |
| 942 | + | |
| 943 | + /** | |
| 944 | + * 最初の管理者を作ってログインする(管理者が1人も居ない時だけ、config.phpのADMIN_SETUP_CODEが必要) | |
| 945 | + * | |
| 946 | + * @param string $code 入力されたコード | |
| 947 | + * @param string|null $setupCode 正しいコード(nullならADMIN_SETUP_CODE) | |
| 948 | + * @return Result | |
| 949 | + */ | |
| 950 | + public function setupFirstAdmin(string $code, string $username, #[SensitiveParameter] string $password, #[SensitiveParameter] string $passwordVerify, string $email, #[SensitiveParameter] ?string $setupCode = null): Result { | |
| 951 | + if (!AUTH_ENABLED) return Result::Error('エラー:認証システムは無効です。'); | |
| 952 | + $setupCode ??= defined('ADMIN_SETUP_CODE') ? (string)ADMIN_SETUP_CODE : ''; | |
| 953 | + if ($setupCode === '' || $this->hasAdmin()) return Result::Error('エラー:最初の管理者は作れません。'); | |
| 954 | + if ($this->isLoginThrottled('admin-setup')) return Result::Error('エラー:試行回数が多過ぎます。暫くしてから再度お試し下さい。'); | |
| 955 | + if (!hash_equals($setupCode, $code)) { | |
| 956 | + $this->recordLoginFailure('admin-setup'); | |
| 957 | + return Result::Error('エラー:セットアップのコードが一致していません。'); | |
| 958 | + } | |
| 959 | + $res = $this->createAccount($username, $password, $passwordVerify, $email, [Roles::MEMBER, Roles::ADMIN], 'setupFirstAdmin'); | |
| 960 | + if (!$res->isSuccess) return $res; | |
| 961 | + $user = json_decode((string)file_get_contents($res->data['path'])); | |
| 962 | + $this->id = (int)$user->id; | |
| 963 | + $this->user = $user; | |
| 964 | + logger(LogType::Auth, '【setupFirstAdmin】'.date('Y-m-d H:i:s')." 最初の管理者「{$username}」を作成"); | |
| 965 | + $login = $this->startSession($user); | |
| 966 | + return $login->isSuccess ? Result::Success('管理者を作成しました。') : $login; | |
| 967 | + } | |
| 968 | + | |
| 969 | + private function createAccount(string $username, #[SensitiveParameter] string $password, #[SensitiveParameter] string $passwordVerify, string $email, array $roles, string $where): Result { | |
| 970 | + $errors = []; | |
| 971 | + foreach ([$this->verifyUsername($username), $this->verifyPassword($password, $passwordVerify), $this->verifyEmail($email, false)] as $res) { | |
| 972 | + if (!$res->isSuccess) $errors[] = $res->message; | |
| 973 | + } | |
| 974 | + if (!empty($errors)) return Result::Error(implode(' ', $errors)); | |
| 975 | + $role = Roles::BANNED; | |
| 976 | + foreach ($roles as $bit) if (isset(Roles::LABELS[(int)$bit])) $role |= (int)$bit; | |
| 977 | + $user = $this->newUser($username, $password, $email, $role); | |
| 978 | + $res = $this->storeNewUser($user, $where); | |
| 979 | + if (!$res->isSuccess) return $res; | |
| 980 | + return Result::Success("ユーザー「{$username}」を作成しました。", ['username' => $username, 'path' => $res->data['path']]); | |
| 981 | + } | |
| 982 | + | |
| 983 | + /** | |
| 811 | 984 | * 確認メールを送信する(テストで上書き出来る様にprotected) |
| 812 | 985 | * |
| 813 | 986 | * @param \stdClass $user ユーザーデータ |
| @@ -867,12 +1040,73 @@ | ||
| 867 | 1040 | */ |
| 868 | 1041 | private function userFiles(): array { |
| 869 | 1042 | $out = []; |
| 870 | - foreach (scandir($this->dataDir) ?: [] as $f) { | |
| 1043 | + foreach (@scandir($this->dataDir) ?: [] as $f) { | |
| 871 | 1044 | if (preg_match('/^\d+\.[A-Za-z0-9]+\.json$/', $f)) $out[] = $this->dataDir.$f; |
| 872 | 1045 | } |
| 873 | 1046 | return $out; |
| 874 | 1047 | } |
| 875 | 1048 | |
| 1049 | + /** | |
| 1050 | + * 新しいユーザーのデータ | |
| 1051 | + */ | |
| 1052 | + private function newUser(string $username, #[SensitiveParameter] string $password, string $email, int $role): \stdClass { | |
| 1053 | + $user = new \stdClass; | |
| 1054 | + $user->id = 0; | |
| 1055 | + $user->username = $username; | |
| 1056 | + $user->password = password_hash($password, $this->algo); | |
| 1057 | + $user->avatar = ''; | |
| 1058 | + $user->email = $email; | |
| 1059 | + $user->regDate = time(); | |
| 1060 | + $user->namecolor = ''; | |
| 1061 | + $user->displayname = ''; | |
| 1062 | + $user->gender = Gender::UNKNOWN; | |
| 1063 | + $user->role = $role; | |
| 1064 | + $user->tokens = []; | |
| 1065 | + return $user; | |
| 1066 | + } | |
| 1067 | + | |
| 1068 | + /** | |
| 1069 | + * 新しいユーザーを保存する(IDを付け、アイコンのディレクトリも作る。data/が空・無くても良い) | |
| 1070 | + * | |
| 1071 | + * @param \stdClass $user newUser()のデータ(idを設定する) | |
| 1072 | + * @param string $where ログ用 | |
| 1073 | + * @return Result data:path | |
| 1074 | + */ | |
| 1075 | + private function storeNewUser(\stdClass $user, string $where): Result { | |
| 1076 | + if (!is_dir($this->dataDir) && !@mkdir($this->dataDir, 0755, true) && !is_dir($this->dataDir)) { | |
| 1077 | + logger(LogType::Auth, "【{$where}】ユーザーのディレクトリを作成出来なかった。chownをご確認下さい。パス:{$this->dataDir}"); | |
| 1078 | + return Result::Error('エラー:ユーザーデータの保存に失敗。'); | |
| 1079 | + } | |
| 1080 | + $assDir = $this->assDir.$user->username; | |
| 1081 | + // 以前は親のディレクトリ(user/)が無いと失敗した | |
| 1082 | + if (!is_dir($assDir) && !@mkdir($assDir, 0755, true)) { | |
| 1083 | + logger(LogType::Auth, "【{$where}】アセットディレクトリを作成出来なかった。chownをご確認下さい。パス:{$assDir}"); | |
| 1084 | + return Result::Error('エラー:ユーザーのアイコンディレクトリの作成に失敗。'); | |
| 1085 | + } | |
| 1086 | + | |
| 1087 | + // 同時に登録しても同じIDにならない様に | |
| 1088 | + $lock = @fopen($this->dataDir.'.lock', 'c'); | |
| 1089 | + if ($lock) flock($lock, LOCK_EX); | |
| 1090 | + try { | |
| 1091 | + $last = 0; | |
| 1092 | + foreach ($this->userFiles() as $f) $last = max($last, (int)strtok(basename($f), '.')); | |
| 1093 | + $user->id = $last + 1; | |
| 1094 | + $path = "{$this->dataDir}{$user->id}.{$user->username}.json"; | |
| 1095 | + $json = json_encode($user, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); | |
| 1096 | + if (file_put_contents($path, $json, LOCK_EX) === false) { | |
| 1097 | + logger(LogType::Auth, "【{$where}】JSONファイルを作成出来なかった。パス:{$path}"); | |
| 1098 | + @rmdir($assDir); | |
| 1099 | + return Result::Error('エラー:ユーザーデータの保存に失敗。'); | |
| 1100 | + } | |
| 1101 | + } finally { | |
| 1102 | + if ($lock) { | |
| 1103 | + flock($lock, LOCK_UN); | |
| 1104 | + fclose($lock); | |
| 1105 | + } | |
| 1106 | + } | |
| 1107 | + return Result::Success('', ['path' => $path]); | |
| 1108 | + } | |
| 1109 | + | |
| 876 | 1110 | //////////////////// |
| 877 | 1111 | |
| 878 | 1112 | private function verifyLogin(string $username, #[SensitiveParameter] string $password): Result { |
| @@ -920,7 +1154,7 @@ | ||
| 920 | 1154 | if (!$username && !$token) return 0; |
| 921 | 1155 | if (!$username && !preg_match('/^[0-9a-f]{128}$/', $token)) return 0; |
| 922 | 1156 | |
| 923 | - $file = scandir($this->dataDir); | |
| 1157 | + $file = @scandir($this->dataDir) ?: []; | |
| 924 | 1158 | $now = time(); |
| 925 | 1159 | $hashed = $token ? hash('sha256', $token) : ''; |
| 926 | 1160 | |
| @@ -948,7 +1182,7 @@ | ||
| 948 | 1182 | |
| 949 | 1183 | private function getUserData(): ?\stdClass { |
| 950 | 1184 | if (!AUTH_ENABLED) return new \stdClass; |
| 951 | - $file = scandir($this->dataDir); | |
| 1185 | + $file = @scandir($this->dataDir) ?: []; | |
| 952 | 1186 | $userFile = ""; |
| 953 | 1187 | $matches = []; |
| 954 | 1188 | |
| @@ -981,7 +1215,7 @@ | ||
| 981 | 1215 | |
| 982 | 1216 | private function getUserDataFromName(string $name): \stdClass|null { |
| 983 | 1217 | if (!AUTH_ENABLED) return new \stdClass; |
| 984 | - $file = scandir($this->dataDir); | |
| 1218 | + $file = @scandir($this->dataDir) ?: []; | |
| 985 | 1219 | $userFile = ""; |
| 986 | 1220 | $matches = []; |
| 987 | 1221 | |
| @@ -1015,7 +1249,7 @@ | ||
| 1015 | 1249 | |
| 1016 | 1250 | private function isEmailExist(?string $email = null): Result { |
| 1017 | 1251 | if (!AUTH_ENABLED) return Result::Error('エラー:認証システムは無効です。'); |
| 1018 | - $userList = scandir($this->dataDir); | |
| 1252 | + $userList = @scandir($this->dataDir) ?: []; | |
| 1019 | 1253 | $matches = []; |
| 1020 | 1254 | |
| 1021 | 1255 | foreach ($userList as $f) { |
| @@ -1033,7 +1267,7 @@ | ||
| 1033 | 1267 | return Result::Success('エラー:メールアドレスが存在しません。'); |
| 1034 | 1268 | } |
| 1035 | 1269 | |
| 1036 | - private function verifyEmail(?string $email = null): Result { | |
| 1270 | + private function verifyEmail(?string $email = null, bool $checkMx = true): Result { | |
| 1037 | 1271 | if (!AUTH_ENABLED) return Result::Error('エラー:認証システムは無効です。'); |
| 1038 | 1272 | if (null === $email || '' === $email) return Result::Error('エラー:メールアドレスをご入力下さい。'); |
| 1039 | 1273 | if (strpos($email, '@') === false) return Result::Error('エラー:メールアドレスは不正です。'); |
| @@ -1043,7 +1277,7 @@ | ||
| 1043 | 1277 | return Result::Error('エラー:メールアドレスは不正です。'); |
| 1044 | 1278 | } |
| 1045 | 1279 | |
| 1046 | - if (!checkdnsrr($domain, 'MX')) { | |
| 1280 | + if ($checkMx && !checkdnsrr($domain, 'MX')) { | |
| 1047 | 1281 | return Result::Error('エラー:メールアドレスは不正です。'); |
| 1048 | 1282 | } |
| 1049 | 1283 |
| @@ -140,6 +140,51 @@ | ||
| 140 | 140 | && (isset($cache['lifespan']) && time() < ($cache['lifespan'] + $this->dataDuration))); |
| 141 | 141 | } |
| 142 | 142 | |
| 143 | + /** | |
| 144 | + * キャッシュの種類(data/cache/の下のディレクトリ)の一覧(無ければ空) | |
| 145 | + * | |
| 146 | + * @param string|null $baseDir 保存先(既定はdata/cache/、テスト用) | |
| 147 | + * @return array 名前 | |
| 148 | + */ | |
| 149 | + public static function kinds(?string $baseDir = null): array { | |
| 150 | + $base = rtrim($baseDir ?? ROOT.'/data/cache', '/'); | |
| 151 | + $out = []; | |
| 152 | + foreach (@scandir($base) ?: [] as $f) { | |
| 153 | + if (preg_match('/^[A-Za-z0-9_-][A-Za-z0-9._-]{0,199}$/', $f) && !str_contains($f, '..') && is_dir("{$base}/{$f}")) $out[] = $f; | |
| 154 | + } | |
| 155 | + return $out; | |
| 156 | + } | |
| 157 | + | |
| 158 | + /** | |
| 159 | + * このキャッシュのファイル | |
| 160 | + * | |
| 161 | + * @return array name(キャッシュ名)、size、mtime、created(作った時刻)、fresh(有効期限内か)(名前の順) | |
| 162 | + */ | |
| 163 | + public function entries(): array { | |
| 164 | + $out = []; | |
| 165 | + foreach (glob($this->dataDir.'*.json') ?: [] as $f) { | |
| 166 | + $name = basename($f, '.json'); | |
| 167 | + if (!$this->isSafeName($name)) continue; | |
| 168 | + $data = $this->get($name); | |
| 169 | + $out[] = ['name' => $name, 'size' => (int)@filesize($f), 'mtime' => (int)@filemtime($f), | |
| 170 | + 'created' => isset($data['lifespan']) ? (int)$data['lifespan'] : null, 'fresh' => $this->resurrect($data)]; | |
| 171 | + } | |
| 172 | + usort($out, fn($a, $b) => strcmp($a['name'], $b['name'])); | |
| 173 | + return $out; | |
| 174 | + } | |
| 175 | + | |
| 176 | + /** | |
| 177 | + * このキャッシュを全部破壊する(空になったディレクトリも消す) | |
| 178 | + * | |
| 179 | + * @return int 削除した数 | |
| 180 | + */ | |
| 181 | + public function murderAll(): int { | |
| 182 | + $n = 0; | |
| 183 | + foreach (glob($this->dataDir.'*.json') ?: [] as $f) if (@unlink($f)) $n++; | |
| 184 | + @rmdir($this->dataDir); | |
| 185 | + return $n; | |
| 186 | + } | |
| 187 | + | |
| 143 | 188 | /////// |
| 144 | 189 | |
| 145 | 190 | /** |
| @@ -97,6 +97,16 @@ | ||
| 97 | 97 | return array_keys($this->tasks); |
| 98 | 98 | } |
| 99 | 99 | |
| 100 | + /** | |
| 101 | + * run()が最後に実行された時刻(システムのcrontabから呼ばれているかの確認用) | |
| 102 | + * | |
| 103 | + * @return int|null まだ実行されていなければnull | |
| 104 | + */ | |
| 105 | + public function lastTick(): ?int { | |
| 106 | + $state = $this->readState(); | |
| 107 | + return isset($state['lastTick']) ? (int)$state['lastTick'] : null; | |
| 108 | + } | |
| 109 | + | |
| 100 | 110 | public function getLastId(): int { |
| 101 | 111 | $res = $this->list(); |
| 102 | 112 | if (!$res->isSuccess || empty($res->data)) return 0; |
| @@ -55,7 +55,7 @@ | ||
| 55 | 55 | final class Stores { |
| 56 | 56 | public const JSON_FLAGS = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT; |
| 57 | 57 | /** 変換しないディレクトリ・ファイル(data/からの相対パス) */ |
| 58 | - public const SKIP = ['#^cache/#', '#^ratelimit/#', '#^db/#', '#\.tmp$#', '#\.lock$#', '#^cron/run\.lock$#', '#(^|/)\.#']; | |
| 58 | + public const SKIP = ['#^cache/#', '#^ratelimit/#', '#^tmp/#', '#^db/#', '#\.tmp$#', '#\.lock$#', '#^cron/run\.lock$#', '#(^|/)\.#']; | |
| 59 | 59 | |
| 60 | 60 | /** テーブルの定義 */ |
| 61 | 61 | public static function tables(): array { |
| @@ -0,0 +1,154 @@ | ||
| 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; | |
| 39 | + | |
| 40 | +/** | |
| 41 | + * ログのファイル(log/、logger()が書く)を読む・消す(管理画面用) | |
| 42 | + * | |
| 43 | + * $logs = new Logs(); | |
| 44 | + * foreach ($logs->list() as $f) echo $f['name'], ' ', $f['size'], "\n"; | |
| 45 | + * $tail = $logs->tail('db_log.txt', 100, 'エラー'); | |
| 46 | + */ | |
| 47 | +class Logs { | |
| 48 | + private string $dir; | |
| 49 | + | |
| 50 | + /** | |
| 51 | + * @param string|null $dir ログのディレクトリ(既定はROOT/log、テスト用) | |
| 52 | + */ | |
| 53 | + public function __construct(?string $dir = null) { | |
| 54 | + $this->dir = rtrim($dir ?? ROOT.'/log', '/\\'); | |
| 55 | + } | |
| 56 | + | |
| 57 | + public function dir(): string { | |
| 58 | + return $this->dir; | |
| 59 | + } | |
| 60 | + | |
| 61 | + /** | |
| 62 | + * ログのファイルの一覧(ディレクトリが無ければ空) | |
| 63 | + * | |
| 64 | + * @return array name、size、mtime(名前の順) | |
| 65 | + */ | |
| 66 | + public function list(): array { | |
| 67 | + $out = []; | |
| 68 | + foreach (@scandir($this->dir) ?: [] as $f) { | |
| 69 | + $p = $this->path($f); | |
| 70 | + if ($p === null) continue; | |
| 71 | + $out[] = ['name' => $f, 'size' => (int)filesize($p), 'mtime' => (int)filemtime($p)]; | |
| 72 | + } | |
| 73 | + return $out; | |
| 74 | + } | |
| 75 | + | |
| 76 | + /** | |
| 77 | + * ログのファイルのパス(ディレクトリの中の普通のファイルだけ) | |
| 78 | + * | |
| 79 | + * @param string $name ファイル名 | |
| 80 | + * @return string|null 無い・不正な名前ならnull | |
| 81 | + */ | |
| 82 | + public function path(string $name): ?string { | |
| 83 | + if (!preg_match('/^[A-Za-z0-9_-][A-Za-z0-9._-]{0,200}$/', $name) || str_contains($name, '..')) return null; | |
| 84 | + $p = $this->dir.'/'.$name; | |
| 85 | + return is_file($p) && !is_link($p) ? $p : null; | |
| 86 | + } | |
| 87 | + | |
| 88 | + /** | |
| 89 | + * 最後の行(新しい順ではなく、ファイルの順) | |
| 90 | + * | |
| 91 | + * @param string $name ファイル名 | |
| 92 | + * @param int $lines 行数 | |
| 93 | + * @param string $search 含む行だけ(大文字・小文字を区別しない、空なら全部) | |
| 94 | + * @return array|null lines(行の配列)、more(前にもっと有るか)。ファイルが無ければnull | |
| 95 | + */ | |
| 96 | + public function tail(string $name, int $lines = 200, string $search = ''): ?array { | |
| 97 | + $p = $this->path($name); | |
| 98 | + if ($p === null) return null; | |
| 99 | + $lines = max(1, min($lines, 10000)); | |
| 100 | + $fh = @fopen($p, 'rb'); | |
| 101 | + if (!$fh) return null; | |
| 102 | + try { | |
| 103 | + if ($search !== '') { | |
| 104 | + // 検索は全部を読む(一致した最後の行だけ残す) | |
| 105 | + $out = []; | |
| 106 | + $more = false; | |
| 107 | + while (($l = fgets($fh)) !== false) { | |
| 108 | + $l = rtrim($l, "\r\n"); | |
| 109 | + if (mb_stripos($l, $search, 0, 'UTF-8') === false) continue; | |
| 110 | + $out[] = $l; | |
| 111 | + if (count($out) > $lines) { | |
| 112 | + array_shift($out); | |
| 113 | + $more = true; | |
| 114 | + } | |
| 115 | + } | |
| 116 | + return ['lines' => $out, 'more' => $more]; | |
| 117 | + } | |
| 118 | + // 後ろから読む | |
| 119 | + $size = (int)fstat($fh)['size']; | |
| 120 | + $pos = $size; | |
| 121 | + $buf = ''; | |
| 122 | + $chunk = 65536; | |
| 123 | + while ($pos > 0 && substr_count($buf, "\n") <= $lines) { | |
| 124 | + $n = min($chunk, $pos); | |
| 125 | + $pos -= $n; | |
| 126 | + fseek($fh, $pos); | |
| 127 | + $buf = (string)fread($fh, $n).$buf; | |
| 128 | + } | |
| 129 | + $all = explode("\n", rtrim($buf, "\n")); | |
| 130 | + if ($buf === '') $all = []; | |
| 131 | + $more = $pos > 0 || count($all) > $lines; | |
| 132 | + if (count($all) > $lines) $all = array_slice($all, -$lines); | |
| 133 | + return ['lines' => array_map(fn($l) => rtrim($l, "\r"), $all), 'more' => $more]; | |
| 134 | + } finally { | |
| 135 | + fclose($fh); | |
| 136 | + } | |
| 137 | + } | |
| 138 | + | |
| 139 | + /** | |
| 140 | + * ログを空にする | |
| 141 | + */ | |
| 142 | + public function clear(string $name): bool { | |
| 143 | + $p = $this->path($name); | |
| 144 | + return $p !== null && @file_put_contents($p, '', LOCK_EX) !== false; | |
| 145 | + } | |
| 146 | + | |
| 147 | + /** | |
| 148 | + * ログのファイルを消す | |
| 149 | + */ | |
| 150 | + public function delete(string $name): bool { | |
| 151 | + $p = $this->path($name); | |
| 152 | + return $p !== null && @unlink($p); | |
| 153 | + } | |
| 154 | +} |
| @@ -408,6 +408,141 @@ | ||
| 408 | 408 | }); |
| 409 | 409 | }); |
| 410 | 410 | |
| 411 | +$test->describe('管理画面', function($test) use ($make, $read, $userFile, $pass, $base): void { | |
| 412 | + if (!AUTH_ENABLED) $test->skipAll('AUTH_ENABLEDが無効'); | |
| 413 | + | |
| 414 | + // data/user/が無い所から始める(新しいサイト) | |
| 415 | + $empty = "{$base}/empty"; | |
| 416 | + $makeIn = function(?string $username = null) use ($empty): Auth { | |
| 417 | + return new class($username, $empty) extends Auth { | |
| 418 | + public function __construct(?string $username, string $dir) { | |
| 419 | + $this->dataDir = "{$dir}/user/"; | |
| 420 | + $this->assDir = "{$dir}/ass/"; | |
| 421 | + parent::__construct($username); | |
| 422 | + } | |
| 423 | + public function requiresVerification(): bool { return false; } | |
| 424 | + }; | |
| 425 | + }; | |
| 426 | + $readIn = function(string $name) use ($empty): ?\stdClass { | |
| 427 | + foreach (glob("{$empty}/user/*.{$name}.json") ?: [] as $f) return json_decode((string)file_get_contents($f)); | |
| 428 | + return null; | |
| 429 | + }; | |
| 430 | + $_SERVER['REMOTE_ADDR'] ??= '127.0.0.1'; | |
| 431 | + | |
| 432 | + $test->it('管理者が居なければ、コードで最初の管理者を作れるはず', function($test) use ($makeIn, $readIn, $pass, $empty): void { | |
| 433 | + $auth = $makeIn(); | |
| 434 | + $test->assertFalse($auth->hasAdmin()); | |
| 435 | + $test->assertEquals([], $auth->listUsers(), 'ログインしていなければ空'); | |
| 436 | + $test->assertFalse($auth->setupFirstAdmin('x', 'boss0001', $pass, $pass, 'boss@example.com', '')->isSuccess, 'コードが無ければ作れない'); | |
| 437 | + $res = $auth->setupFirstAdmin('chigau', 'boss0001', $pass, $pass, 'boss@example.com', 'himitsu-code'); | |
| 438 | + $test->assertFalse($res->isSuccess); | |
| 439 | + $test->assertStringContains('コード', (string)$res->message); | |
| 440 | + $test->assertFalse(is_dir("{$empty}/user")); | |
| 441 | + | |
| 442 | + $res = @$makeIn()->setupFirstAdmin('himitsu-code', 'boss0001', $pass, $pass, 'boss@example.com', 'himitsu-code'); | |
| 443 | + // CLIでは出力の後なのでクッキーを設定出来ないが、アカウントとトークンは保存される | |
| 444 | + $test->assertTrue($res->isSuccess || $res->message === 'エラー:クッキーを設定に失敗。', (string)$res->message); | |
| 445 | + $u = $readIn('boss0001'); | |
| 446 | + $test->assertEquals(1, $u->id); | |
| 447 | + $test->assertEquals(\Roles::MEMBER | \Roles::ADMIN, $u->role); | |
| 448 | + $test->assertTrue(password_verify($pass, $u->password)); | |
| 449 | + $test->assertCount(1, $u->tokens); | |
| 450 | + $test->assertTrue(is_dir("{$empty}/ass/boss0001")); | |
| 451 | + $test->assertTrue($makeIn('boss0001')->isAdmin()); | |
| 452 | + $test->assertTrue($makeIn()->hasAdmin()); | |
| 453 | + $test->assertFalse($makeIn()->setupFirstAdmin('himitsu-code', 'boss0002', $pass, $pass, 'b2@example.com', 'himitsu-code')->isSuccess, '2人目は作れない'); | |
| 454 | + $makeIn()->clearLoginFailures(); | |
| 455 | + }); | |
| 456 | + | |
| 457 | + $test->it('管理者はユーザーを作れるはず(他のユーザーは作れない)', function($test) use ($makeIn, $readIn, $pass): void { | |
| 458 | + $admin = $makeIn('boss0001'); | |
| 459 | + $res = $admin->adminCreateUser('member01', $pass, $pass, 'm1@example.com'); | |
| 460 | + $test->assertTrue($res->isSuccess, (string)$res->message); | |
| 461 | + $test->assertEquals(2, $readIn('member01')->id); | |
| 462 | + $test->assertEquals(\Roles::MEMBER, $readIn('member01')->role); | |
| 463 | + $test->assertFalse(isset($readIn('member01')->verified)); | |
| 464 | + $res = $admin->adminCreateUser('staff001', $pass, $pass, 's1@example.com', [\Roles::MEMBER, \Roles::STAFF, 1 << 30]); | |
| 465 | + $test->assertTrue($res->isSuccess, (string)$res->message); | |
| 466 | + $test->assertEquals(\Roles::MEMBER | \Roles::STAFF, $readIn('staff001')->role, '不明なビットは無視'); | |
| 467 | + $test->assertFalse($admin->adminCreateUser('member01', $pass, $pass, 'm9@example.com')->isSuccess, '同じユーザー名'); | |
| 468 | + $test->assertFalse($admin->adminCreateUser('member09', $pass, $pass, 'm1@example.com')->isSuccess, '同じメールアドレス'); | |
| 469 | + $test->assertFalse($admin->adminCreateUser('member09', 'short', 'short', 'm9@example.com')->isSuccess, '弱いパスワード'); | |
| 470 | + $test->assertFalse($admin->adminCreateUser('mem', $pass, $pass, 'm9@example.com')->isSuccess, '短いユーザー名'); | |
| 471 | + $test->assertFalse($admin->adminCreateUser('member09', $pass, $pass, 'not-an-email')->isSuccess); | |
| 472 | + $test->assertFalse($makeIn('member01')->adminCreateUser('member09', $pass, $pass, 'm9@example.com')->isSuccess, '管理者以外'); | |
| 473 | + $test->assertNull($readIn('member09')); | |
| 474 | + }); | |
| 475 | + | |
| 476 | + $test->it('管理者だけがユーザーの一覧を見れるはず', function($test) use ($makeIn): void { | |
| 477 | + $list = $makeIn('boss0001')->listUsers(); | |
| 478 | + $test->assertEquals(['boss0001', 'member01', 'staff001'], array_column($list, 'username')); | |
| 479 | + $test->assertEquals(1, $list[0]->sessions); | |
| 480 | + $test->assertEquals(['メンバー', '管理者'], $list[0]->roleLabels); | |
| 481 | + $test->assertFalse(isset($list[0]->password) || isset($list[0]->tokens)); | |
| 482 | + $test->assertEquals([], $makeIn('member01')->listUsers()); | |
| 483 | + $test->assertNull($makeIn('member01')->getAccount('boss0001')); | |
| 484 | + $test->assertEquals('m1@example.com', $makeIn('boss0001')->getAccount('member01')->email); | |
| 485 | + $test->assertNull($makeIn('boss0001')->getAccount('nobody01')); | |
| 486 | + }); | |
| 487 | + | |
| 488 | + $test->it('管理者はアカウントを変更出来るはず(大事な変更には管理者のパスワード)', function($test) use ($makeIn, $readIn, $pass, $empty): void { | |
| 489 | + $path = glob("{$empty}/user/*.member01.json")[0]; | |
| 490 | + $u = $readIn('member01'); | |
| 491 | + $u->tokens = [(object)['token' => str_repeat('a', 64), 'expDate' => time() + 3600, 'createDate' => time()]]; | |
| 492 | + $u->verified = false; | |
| 493 | + $u->verifyToken = 'x'; | |
| 494 | + $u->pendingEmail = 'new@example.com'; | |
| 495 | + $u->twoFactor = (object)['secret' => 'ABCDEFGH', 'lastStep' => 0]; | |
| 496 | + file_put_contents($path, json_encode($u, JSON_PRETTY_PRINT)); | |
| 497 | + $admin = $makeIn('boss0001'); | |
| 498 | + $new = 'NewPASS56&*longenough!'; | |
| 499 | + | |
| 500 | + $test->assertFalse($makeIn('member01')->adminUpdateAccount('staff001', ['logoutAll' => true])->isSuccess, '管理者以外'); | |
| 501 | + $test->assertFalse($admin->adminUpdateAccount('nobody01', ['logoutAll' => true])->isSuccess); | |
| 502 | + $res = $admin->adminUpdateAccount('member01', ['email' => 'm1b@example.com'], 'machigatta'); | |
| 503 | + $test->assertStringContains('管理者のパスワード', (string)$res->message); | |
| 504 | + $test->assertFalse($admin->adminUpdateAccount('member01', ['disableTwoFactor' => true])->isSuccess); | |
| 505 | + $test->assertFalse($admin->adminUpdateAccount('member01', ['password' => $new, 'passwordVerify' => 'x'], $pass)->isSuccess); | |
| 506 | + $test->assertFalse($admin->adminUpdateAccount('member01', ['email' => 's1@example.com'], $pass)->isSuccess, '他のユーザーのメールアドレス'); | |
| 507 | + $test->assertEquals('m1@example.com', $readIn('member01')->email, '失敗したら何も変えない'); | |
| 508 | + | |
| 509 | + $res = $admin->adminUpdateAccount('member01', ['email' => 'm1b@example.com', 'password' => $new, 'passwordVerify' => $new, 'disableTwoFactor' => true], $pass); | |
| 510 | + $test->assertTrue($res->isSuccess, (string)$res->message); | |
| 511 | + $u = $readIn('member01'); | |
| 512 | + $test->assertEquals('m1b@example.com', $u->email); | |
| 513 | + $test->assertFalse(isset($u->pendingEmail)); | |
| 514 | + $test->assertTrue(password_verify($new, $u->password)); | |
| 515 | + $test->assertCount(0, $u->tokens, 'パスワードを変えたら全部のログインを取り消す'); | |
| 516 | + $test->assertFalse(isset($u->twoFactor)); | |
| 517 | + | |
| 518 | + // 確認済みにする(パスワードは不要) | |
| 519 | + $res = $admin->adminUpdateAccount('member01', ['verify' => true]); | |
| 520 | + $test->assertTrue($res->isSuccess, (string)$res->message); | |
| 521 | + $u = $readIn('member01'); | |
| 522 | + $test->assertTrue($u->verified); | |
| 523 | + $test->assertFalse(isset($u->verifyToken)); | |
| 524 | + $test->assertEquals('変更はありません。', $admin->adminUpdateAccount('member01', ['verify' => true, 'logoutAll' => true])->message); | |
| 525 | + | |
| 526 | + // ロール | |
| 527 | + $res = $admin->adminUpdateAccount('member01', ['roles' => [\Roles::MEMBER, \Roles::FEDIVERSE]]); | |
| 528 | + $test->assertTrue($res->isSuccess, (string)$res->message); | |
| 529 | + $test->assertEquals(\Roles::MEMBER | \Roles::FEDIVERSE, $readIn('member01')->role); | |
| 530 | + $test->assertTrue($readIn('member01')->fediDefault); | |
| 531 | + $test->assertTrue($admin->adminUpdateAccount('member01', ['roles' => []])->isSuccess); | |
| 532 | + $test->assertEquals(\Roles::BANNED, $readIn('member01')->role); | |
| 533 | + $test->assertEquals(['BAN'], $admin->getAccount('member01')->roleLabels); | |
| 534 | + $res = $admin->adminUpdateAccount('boss0001', ['roles' => [\Roles::MEMBER]]); | |
| 535 | + $test->assertStringContains('最後の管理者', (string)$res->message); | |
| 536 | + $test->assertTrue($makeIn('boss0001')->isAdmin()); | |
| 537 | + | |
| 538 | + // 全部のログインを取り消す | |
| 539 | + $res = $admin->adminUpdateAccount('boss0001', ['logoutAll' => true]); | |
| 540 | + $test->assertTrue($res->isSuccess, (string)$res->message); | |
| 541 | + $test->assertCount(0, $readIn('boss0001')->tokens); | |
| 542 | + }); | |
| 543 | + | |
| 544 | +}); | |
| 545 | + | |
| 411 | 546 | // 一時ディレクトリを削除する |
| 412 | 547 | $rm = function(string $dir) use (&$rm): void { |
| 413 | 548 | foreach (scandir($dir) ?: [] as $f) { |
| @@ -112,6 +112,23 @@ | ||
| 112 | 112 | $test->assertEquals(1, $cache->get('login')['x'] ?? null); |
| 113 | 113 | }); |
| 114 | 114 | |
| 115 | + $test->it('種類と中身の一覧を出し、全部削除出来るはず', function($test) use ($base): void { | |
| 116 | + $test->assertEquals([], Cache::kinds($base.'/nothing'), 'ディレクトリが無ければ空'); | |
| 117 | + $cache = new Cache('list', $base); | |
| 118 | + $cache->set('b', ['x' => 1]); | |
| 119 | + $cache->set('a', ['y' => 2]); | |
| 120 | + $test->assertTrue(in_array('list', Cache::kinds($base), true)); | |
| 121 | + $entries = $cache->entries(); | |
| 122 | + $test->assertEquals(['a', 'b'], array_column($entries, 'name')); | |
| 123 | + $test->assertTrue($entries[0]['fresh']); | |
| 124 | + $test->assertTrue($entries[0]['size'] > 0); | |
| 125 | + $test->assertTrue(abs($entries[0]['created'] - time()) < 5); | |
| 126 | + $test->assertEquals(2, $cache->murderAll()); | |
| 127 | + $test->assertEquals([], $cache->entries()); | |
| 128 | + $test->assertFalse(is_dir("{$base}/list"), '空のディレクトリも消す'); | |
| 129 | + $test->assertFalse(in_array('list', Cache::kinds($base), true)); | |
| 130 | + }); | |
| 131 | + | |
| 115 | 132 | $test->it('ディレクトリの外のファイル名を拒否するはず', function($test) use ($base): void { |
| 116 | 133 | $cache = new Cache('test', $base); |
| 117 | 134 | foreach (['../x', '..', 'a/b', 'a\\b', '', str_repeat('a', 201)] as $bad) { |
| @@ -0,0 +1,129 @@ | ||
| 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\Logs; | |
| 44 | + | |
| 45 | +$test = new Tester([ | |
| 46 | + 'colorOutput' => true, | |
| 47 | + 'verboseOutput' => true | |
| 48 | +]); | |
| 49 | + | |
| 50 | +$base = sys_get_temp_dir().'/lblogs_'.bin2hex(random_bytes(6)); | |
| 51 | +mkdir($base, 0700); | |
| 52 | + | |
| 53 | +$test->describe('ログ', function($test) use ($base): void { | |
| 54 | + $test->it('ディレクトリが無ければ空のはず', function($test) use ($base): void { | |
| 55 | + $logs = new Logs("{$base}/nothing"); | |
| 56 | + $test->assertEquals([], $logs->list()); | |
| 57 | + $test->assertNull($logs->tail('db_log.txt')); | |
| 58 | + $test->assertFalse($logs->clear('db_log.txt')); | |
| 59 | + }); | |
| 60 | + | |
| 61 | + $test->it('ファイルの一覧を出すはず(隠しファイル・ディレクトリを除く)', function($test) use ($base): void { | |
| 62 | + file_put_contents("{$base}/db_log.txt", "a\n"); | |
| 63 | + file_put_contents("{$base}/auth_log.txt", "bb\n"); | |
| 64 | + file_put_contents("{$base}/.hidden", "x"); | |
| 65 | + mkdir("{$base}/sub"); | |
| 66 | + $list = (new Logs($base))->list(); | |
| 67 | + $test->assertEquals(['auth_log.txt', 'db_log.txt'], array_column($list, 'name')); | |
| 68 | + $test->assertEquals(3, $list[0]['size']); | |
| 69 | + }); | |
| 70 | + | |
| 71 | + $test->it('ディレクトリの外のファイルは読めないはず', function($test) use ($base): void { | |
| 72 | + $logs = new Logs("{$base}/sub"); | |
| 73 | + file_put_contents("{$base}/sub/ok.txt", "x\n"); | |
| 74 | + foreach (['../db_log.txt', '..', '.hidden', 'a/b', '', "ok.txt\0"] as $bad) { | |
| 75 | + $test->assertNull($logs->path($bad), $bad); | |
| 76 | + $test->assertNull($logs->tail($bad), $bad); | |
| 77 | + $test->assertFalse($logs->delete($bad), $bad); | |
| 78 | + } | |
| 79 | + $test->assertNotNull($logs->path('ok.txt')); | |
| 80 | + }); | |
| 81 | + | |
| 82 | + $test->it('最後の行を読むはず', function($test) use ($base): void { | |
| 83 | + $logs = new Logs($base); | |
| 84 | + $lines = []; | |
| 85 | + for ($i = 1; $i <= 30000; $i++) $lines[] = "行{$i} ".str_repeat('x', $i % 7); | |
| 86 | + file_put_contents("{$base}/big.txt", implode("\n", $lines)."\n"); | |
| 87 | + $t = $logs->tail('big.txt', 3); | |
| 88 | + $test->assertEquals(['行29998 '.str_repeat('x', 29998 % 7), '行29999 '.str_repeat('x', 29999 % 7), '行30000 '.str_repeat('x', 30000 % 7)], $t['lines']); | |
| 89 | + $test->assertTrue($t['more']); | |
| 90 | + $t = $logs->tail('big.txt', 5000); | |
| 91 | + $test->assertCount(5000, $t['lines']); | |
| 92 | + $test->assertEquals('行25001 '.str_repeat('x', 25001 % 7), $t['lines'][0]); | |
| 93 | + | |
| 94 | + // 最後の改行が無い、CRLF、短いファイル | |
| 95 | + file_put_contents("{$base}/crlf.txt", "a\r\nb\r\nc"); | |
| 96 | + $t = $logs->tail('crlf.txt', 10); | |
| 97 | + $test->assertEquals(['a', 'b', 'c'], $t['lines']); | |
| 98 | + $test->assertFalse($t['more']); | |
| 99 | + file_put_contents("{$base}/empty.txt", ''); | |
| 100 | + $test->assertEquals(['lines' => [], 'more' => false], $logs->tail('empty.txt')); | |
| 101 | + }); | |
| 102 | + | |
| 103 | + $test->it('検索した行だけを読むはず', function($test) use ($base): void { | |
| 104 | + $logs = new Logs($base); | |
| 105 | + $t = $logs->tail('big.txt', 2, '行2999'); | |
| 106 | + $test->assertEquals(['行29998 '.str_repeat('x', 29998 % 7), '行29999 '.str_repeat('x', 29999 % 7)], $t['lines']); | |
| 107 | + $test->assertTrue($t['more']); | |
| 108 | + file_put_contents("{$base}/case.txt", "Error 1\nok\nERROR 2\n"); | |
| 109 | + $test->assertEquals(['Error 1', 'ERROR 2'], $logs->tail('case.txt', 10, 'error')['lines']); | |
| 110 | + $test->assertEquals([], $logs->tail('case.txt', 10, '無い')['lines']); | |
| 111 | + }); | |
| 112 | + | |
| 113 | + $test->it('空にして、削除出来るはず', function($test) use ($base): void { | |
| 114 | + $logs = new Logs($base); | |
| 115 | + $test->assertTrue($logs->clear('case.txt')); | |
| 116 | + clearstatcache(); | |
| 117 | + $test->assertEquals(0, filesize("{$base}/case.txt")); | |
| 118 | + $test->assertTrue($logs->delete('case.txt')); | |
| 119 | + $test->assertFalse(is_file("{$base}/case.txt")); | |
| 120 | + $test->assertFalse($logs->delete('case.txt')); | |
| 121 | + }); | |
| 122 | +}); | |
| 123 | + | |
| 124 | +foreach (glob("{$base}/sub/*") ?: [] as $f) @unlink($f); | |
| 125 | +@rmdir("{$base}/sub"); | |
| 126 | +foreach (glob("{$base}/{,.}*", GLOB_BRACE) ?: [] as $f) if (is_file($f)) @unlink($f); | |
| 127 | +@rmdir($base); | |
| 128 | + | |
| 129 | +$test->printSummary(); |
変更
util.php
| @@ -270,6 +270,35 @@ | ||
| 270 | 270 | return $res; |
| 271 | 271 | } |
| 272 | 272 | |
| 273 | +/** | |
| 274 | + * Cronのタスクの登録(cron.phpと管理画面で使う) | |
| 275 | + * ジョブの「タスク名」と処理を結び付ける。 | |
| 276 | + * 処理はジョブ(CronTab)を受け取り、Result・bool・nullを返す(falseかResult::Errorは失敗)。 | |
| 277 | + * | |
| 278 | + * @param \Std\Lib\Cron $cron | |
| 279 | + * @return void | |
| 280 | + */ | |
| 281 | +function register_cron_tasks(\Std\Lib\Cron $cron): void { | |
| 282 | + if (ACTIVITYPUB_ENABLED) { | |
| 283 | + // 新しいブログ投稿をフォロワーに送る | |
| 284 | + $cron->register('apfederate', fn() => (new \Std\Lib\Activitypub((new \Site\Controller\BlogPost())->federatedPosts()))->federate()); | |
| 285 | + // プロフィール(アクター)の更新をフォロワーに送る | |
| 286 | + $cron->register('apupdate', fn() => (new \Std\Lib\Activitypub())->sendActorUpdate()); | |
| 287 | + } | |
| 288 | + | |
| 289 | + if (ACTIVITYPUB_MULTIUSER) { | |
| 290 | + // マイクロブログの配信(毎分) | |
| 291 | + $cron->register('apdeliver', fn() => \Std\Lib\Fedi\Delivery::run()); | |
| 292 | + // 90日より古いリモートの投稿とキャッシュを消す(1日1回) | |
| 293 | + $cron->register('apcleanup', fn() => \Std\Lib\Fedi\Timeline::cleanup()); | |
| 294 | + } | |
| 295 | + | |
| 296 | + if (AUTH_ENABLED && AUTH_VERIFY_EMAIL) { | |
| 297 | + // メールアドレスを7日以内に確認しなかったアカウントを削除する | |
| 298 | + $cron->register('authpurge', fn() => (new \Std\Lib\Auth())->purgeUnverified()); | |
| 299 | + } | |
| 300 | +} | |
| 301 | + | |
| 273 | 302 | function logger(LogType $section, mixed $arg): void { |
| 274 | 303 | if (!LOGGING_ENABLED) return; |
| 275 | 304 | $success = false; |
| @@ -304,6 +333,8 @@ | ||
| 304 | 333 | break; |
| 305 | 334 | } |
| 306 | 335 | |
| 336 | + // log/が無ければ作る(新しいサイト等) | |
| 337 | + if ($success && !is_dir(ROOT.'/log')) @mkdir(ROOT.'/log', 0755, true); | |
| 307 | 338 | if ($success) file_put_contents($logfile, $arg."\n", FILE_APPEND); |
| 308 | 339 | } |
| 309 | 340 |
| @@ -0,0 +1,68 @@ | ||
| 1 | +{@ include(common/header) @} | |
| 2 | +{@ include(admin/nav) @} | |
| 3 | + <h1 class="paragraph">キャッシュ</h1> | |
| 4 | +{@ if ($view) @} | |
| 5 | + <h2 class="paragraph">{{ $view['kind'] }}/{{ $view['name'] }}</h2> | |
| 6 | + <pre class="admin-pre">{{ $view['json'] }}</pre> | |
| 7 | + <p class="paragraph"><a href="/admin/cache">戻る</a></p> | |
| 8 | +{@ endif @} | |
| 9 | +{@ if (empty($kinds) && empty($fedi)) @} | |
| 10 | + <p class="paragraph">キャッシュは有りません。</p> | |
| 11 | +{@ else @} | |
| 12 | + <form method="post" action="/admin/cache" class="admin-row"> | |
| 13 | + <input type="hidden" name="csrf_token" value="{{ $csrf }}" /> | |
| 14 | + <button name="do" value="all" class="danger">data/cache/を全部削除</button> | |
| 15 | + </form> | |
| 16 | +{@ endif @} | |
| 17 | +{@ foreach ($kinds as $k) @} | |
| 18 | + <h2 class="paragraph">{{ $k['name'] }}({{ count($k['entries']) }}件、{{ $k['size'] }})</h2> | |
| 19 | + <form method="post" action="/admin/cache" class="admin-row"> | |
| 20 | + <input type="hidden" name="csrf_token" value="{{ $csrf }}" /> | |
| 21 | + <input type="hidden" name="kind" value="{{ $k['name'] }}" /> | |
| 22 | + <button name="do" value="kind">{{ $k['name'] }}を全部削除</button> | |
| 23 | + </form> | |
| 24 | +{@ if (!empty($k['entries'])) @} | |
| 25 | + <table class="admin-table"> | |
| 26 | + <thead><tr><th>名前</th><th>大きさ</th><th>作成</th><th></th></tr></thead> | |
| 27 | + <tbody> | |
| 28 | +{@ foreach ($k['entries'] as $e) @} | |
| 29 | + <tr{{{ $e['fresh'] ? '' : ' class="admin-off"' }}}> | |
| 30 | + <td><a href="/admin/cache?kind={{ rawurlencode($k['name']) }}&name={{ rawurlencode($e['name']) }}">{{ $e['name'] }}</a></td> | |
| 31 | + <td>{{ $e['sizeText'] }}</td> | |
| 32 | + <td>{{ $e['time'] }}{{ $e['fresh'] ? '' : '(期限切れ)' }}</td> | |
| 33 | + <td> | |
| 34 | + <form method="post" action="/admin/cache"> | |
| 35 | + <input type="hidden" name="csrf_token" value="{{ $csrf }}" /> | |
| 36 | + <input type="hidden" name="kind" value="{{ $k['name'] }}" /> | |
| 37 | + <input type="hidden" name="name" value="{{ $e['name'] }}" /> | |
| 38 | + <button name="do" value="one">削除</button> | |
| 39 | + </form> | |
| 40 | + </td> | |
| 41 | + </tr> | |
| 42 | +{@ endforeach @} | |
| 43 | + </tbody> | |
| 44 | + </table> | |
| 45 | +{@ endif @} | |
| 46 | +{@ endforeach @} | |
| 47 | +{@ if (!empty($fedi)) @} | |
| 48 | + <h2 class="paragraph">Fediverse</h2> | |
| 49 | + <p class="paragraph"><small>消しても、必要な時に他のサーバーから取り直します。</small></p> | |
| 50 | + <table class="admin-table"> | |
| 51 | + <tbody> | |
| 52 | +{@ foreach ($fedi as $c) @} | |
| 53 | + <tr> | |
| 54 | + <td>{{ $c['label'] }}(data/ap/{{ $c['dir'] }})</td> | |
| 55 | + <td>{{ $c['files'] }}件、{{ $c['size'] }}</td> | |
| 56 | + <td> | |
| 57 | + <form method="post" action="/admin/cache"> | |
| 58 | + <input type="hidden" name="csrf_token" value="{{ $csrf }}" /> | |
| 59 | + <input type="hidden" name="kind" value="{{ $c['dir'] }}" /> | |
| 60 | + <button name="do" value="fedi">全部削除</button> | |
| 61 | + </form> | |
| 62 | + </td> | |
| 63 | + </tr> | |
| 64 | +{@ endforeach @} | |
| 65 | + </tbody> | |
| 66 | + </table> | |
| 67 | +{@ endif @} | |
| 68 | +{@ include(common/footer) @} |
| @@ -0,0 +1,22 @@ | ||
| 1 | + <div class="admin-checks"> | |
| 2 | +{@ foreach (['json' => 'JSON(data/)', 'sqlite' => 'SQLite', 'mysql' => 'MySQL・MariaDB', 'pgsql' => 'PostgreSQL', 'dump' => 'SQLのダンプ', 'site' => 'サイトのデータベース'] as $k => $label) @} | |
| 3 | +{@ if ($k !== 'site' || $siteDb !== '') @} | |
| 4 | + <label><input type="radio" name="{{ $p }}_kind" value="{{ $k }}"{{{ $s['kind'] === $k ? ' checked="checked"' : '' }}} /> {{ $label }}{{ $k === 'site' ? '('.$siteDb.')' : '' }}</label> | |
| 5 | +{@ endif @} | |
| 6 | +{@ endforeach @} | |
| 7 | + </div> | |
| 8 | + <div class="admin-kind" data-kind="json"> | |
| 9 | + <label>JSONのディレクトリ:<input type="text" name="{{ $p }}_dir" value="{{ $s['dir'] }}" /></label> | |
| 10 | + </div> | |
| 11 | + <div class="admin-kind" data-kind="sqlite"> | |
| 12 | + <label>SQLiteのファイル{{ $p === 'dst' ? '(空ならダウンロード)' : '' }}:<input type="text" name="{{ $p }}_path" value="{{ $s['path'] }}" placeholder="例:data/site.sqlite" /></label> | |
| 13 | + </div> | |
| 14 | + <div class="admin-kind" data-kind="server"> | |
| 15 | + <label>ホスト:<input type="text" name="{{ $p }}_host" value="{{ $s['host'] }}" /></label> | |
| 16 | + <label>ポート:<input type="text" name="{{ $p }}_port" value="{{ $s['port'] }}" placeholder="3306・5432" size="6" /></label> | |
| 17 | + <label>UNIXソケット:<input type="text" name="{{ $p }}_socket" value="{{ $s['socket'] }}" placeholder="(任意)" /></label> | |
| 18 | + <label>ユーザー:<input type="text" name="{{ $p }}_username" value="{{ $s['username'] }}" autocomplete="off" /></label> | |
| 19 | + <label>パスワード:<input type="password" name="{{ $p }}_password" value="" autocomplete="new-password" /></label> | |
| 20 | + <label>データベース:<input type="text" name="{{ $p }}_dbname" value="{{ $s['dbname'] }}" /></label> | |
| 21 | + <label><input type="checkbox" name="{{ $p }}_tls" value="1"{{{ $s['tls'] ? ' checked="checked"' : '' }}} /> TLS</label> | |
| 22 | + </div> |
| @@ -0,0 +1,83 @@ | ||
| 1 | +{@ include(common/header) @} | |
| 2 | +{@ include(admin/nav) @} | |
| 3 | + <h1 class="paragraph">データベースの変換</h1> | |
| 4 | + <p class="paragraph">MySQL・MariaDB、PostgreSQL、SQLite、SQLのダンプ、data/のJSONのデータを相互に変換します。外部キー・ビュー・トリガーは変換しません。サイトが使っているデータベースから・へ変換する場合は、先に書き込みを止めて下さい。</p> | |
| 5 | +{@ if ($result) @} | |
| 6 | + <div class="admin-result"> | |
| 7 | + <h2>変換しました({{ $result['rows'] }}行、{{ $result['seconds'] }}秒)</h2> | |
| 8 | + <table class="admin-table"> | |
| 9 | + <thead><tr><th>テーブル</th><th>行</th></tr></thead> | |
| 10 | + <tbody> | |
| 11 | +{@ foreach ($result['tables'] as $t => $n) @} | |
| 12 | + <tr><td>{{ $t }}</td><td>{{ $n }}</td></tr> | |
| 13 | +{@ endforeach @} | |
| 14 | + </tbody> | |
| 15 | + </table> | |
| 16 | +{@ if (isset($result['download'])) @} | |
| 17 | + <p class="paragraph"><a href="{{ $result['download'] }}"><b>{{ $result['downloadName'] }}をダウンロード</b></a>({{ $result['downloadSize'] }}、1時間で消えます)</p> | |
| 18 | +{@ endif @} | |
| 19 | +{@ if (!empty($result['warnings'])) @} | |
| 20 | + <h3>注意</h3> | |
| 21 | + <ul> | |
| 22 | +{@ foreach ($result['warnings'] as $w) @} | |
| 23 | + <li>{{ $w }}</li> | |
| 24 | +{@ endforeach @} | |
| 25 | + </ul> | |
| 26 | +{@ endif @} | |
| 27 | + </div> | |
| 28 | +{@ endif @} | |
| 29 | +{@ if ($tables !== null) @} | |
| 30 | + <div class="admin-result"> | |
| 31 | + <h2>読む元のテーブル({{ count($tables) }}件)</h2> | |
| 32 | +{@ if (empty($tables)) @} | |
| 33 | + <p>テーブルは有りません。</p> | |
| 34 | +{@ else @} | |
| 35 | + <p>{{ implode('、', array_map(fn($t) => $t['name'].'('.$t['columns'].'列)', $tables)) }}</p> | |
| 36 | +{@ endif @} | |
| 37 | + </div> | |
| 38 | +{@ endif @} | |
| 39 | + <form method="post" action="/admin/convert" enctype="multipart/form-data" class="admin-convert"> | |
| 40 | + <input type="hidden" name="csrf_token" value="{{ $csrf }}" /> | |
| 41 | +{$ $p = 'src' $} | |
| 42 | +{$ $s = $f['src'] $} | |
| 43 | + <fieldset> | |
| 44 | + <legend>読む元</legend> | |
| 45 | +{@ include(admin/convert-side) @} | |
| 46 | + <div class="admin-kind" data-kind="dump"> | |
| 47 | + <label>ダンプ・SQLiteのファイル:<input type="file" name="src_file" /></label> | |
| 48 | + <input type="hidden" name="src_upload" value="{{ $s['upload'] }}" /> | |
| 49 | +{@ if ($s['upload'] !== '') @} | |
| 50 | + <small>アップロードしたファイルを使います(別のファイルを選ぶと置き換え)。</small> | |
| 51 | +{@ endif @} | |
| 52 | + <label>又はサーバーのファイル:<input type="text" name="src_dumppath" value="{{ $s['dumppath'] }}" placeholder="例:data/dump.sql" /></label> | |
| 53 | + <small>mysqldump、pg_dump(プレーンの形式)、sqlite3の.dump、このページで作ったダンプ(.sql{{ $zlib ? '・.sql.gz' : '' }})</small> | |
| 54 | + </div> | |
| 55 | + </fieldset> | |
| 56 | +{$ $p = 'dst' $} | |
| 57 | +{$ $s = $f['dst'] $} | |
| 58 | + <fieldset> | |
| 59 | + <legend>書く先</legend> | |
| 60 | +{@ include(admin/convert-side) @} | |
| 61 | + <div class="admin-kind" data-kind="dump"> | |
| 62 | + <label>方言: | |
| 63 | + <select name="dst_dialect"> | |
| 64 | +{@ foreach (['mysql' => 'MySQL・MariaDB', 'pgsql' => 'PostgreSQL', 'sqlite' => 'SQLite'] as $k => $label) @} | |
| 65 | + <option value="{{ $k }}"{{{ $s['dialect'] === $k ? ' selected="selected"' : '' }}}>{{ $label }}</option> | |
| 66 | +{@ endforeach @} | |
| 67 | + </select> | |
| 68 | + </label> | |
| 69 | +{@ if ($zlib) @} | |
| 70 | + <label><input type="checkbox" name="dst_gzip" value="1"{{{ $s['gzip'] ? ' checked="checked"' : '' }}} /> gzipで圧縮</label> | |
| 71 | +{@ endif @} | |
| 72 | + <label>サーバーのファイル(空ならダウンロード):<input type="text" name="dst_dumppath" value="{{ $s['dumppath'] }}" placeholder="例:data/backup.sql" /></label> | |
| 73 | + </div> | |
| 74 | + <label><input type="checkbox" name="dst_overwrite" value="1"{{{ $s['overwrite'] ? ' checked="checked"' : '' }}} /> 空でないディレクトリ・有るファイルに書く(JSON・ダンプ)</label> | |
| 75 | + <label><input type="checkbox" name="drop" value="1"{{{ $f['drop'] ? ' checked="checked"' : '' }}} /> 同じ名前のテーブルが有れば消して置き換える</label> | |
| 76 | + </fieldset> | |
| 77 | + <label>テーブル(カンマ区切り、空なら全部):<input type="text" name="tables" value="{{ $f['tables'] }}" /></label> | |
| 78 | + <div class="admin-row"> | |
| 79 | + <button name="do" value="inspect">テーブルを確認</button> | |
| 80 | + <button name="do" value="run" class="danger">変換</button> | |
| 81 | + </div> | |
| 82 | + </form> | |
| 83 | +{@ include(common/footer) @} |
| @@ -0,0 +1,66 @@ | ||
| 1 | +{@ include(common/header) @} | |
| 2 | +{@ include(admin/nav) @} | |
| 3 | + <h1 class="paragraph">Cron</h1> | |
| 4 | +{@ if (!$enabled) @} | |
| 5 | + <p class="paragraph">CRON_ENABLEDが無効です。config.phpでtrueにして、システムのcrontabにcron.phpを追加して下さい(READMEを参照)。</p> | |
| 6 | +{@ else @} | |
| 7 | +{@ if ($stale) @} | |
| 8 | + <p class="admin-warn">Cron(cron.php)が{{ $tick === null ? 'まだ実行されていません' : '最近実行されていません(前回:'.$tick.')' }}。システムのcrontabに追加して下さい:<code>* * * * * php {{ ROOT }}/cron.php</code></p> | |
| 9 | +{@ else @} | |
| 10 | + <p class="paragraph">前回の実行:{{ $tick }}</p> | |
| 11 | +{@ endif @} | |
| 12 | +{@ if (empty($jobs)) @} | |
| 13 | + <p class="paragraph">ジョブは有りません。</p> | |
| 14 | +{@ else @} | |
| 15 | + <div class="admin-scroll"> | |
| 16 | + <table class="admin-table"> | |
| 17 | + <thead><tr><th>ID</th><th>予定・タスク</th><th>次回</th><th>前回</th><th>エラー</th><th></th></tr></thead> | |
| 18 | + <tbody> | |
| 19 | +{@ foreach ($jobs as $j) @} | |
| 20 | + <tr{{{ $j['active'] ? '' : ' class="admin-off"' }}}> | |
| 21 | + <td>{{ $j['id'] }}</td> | |
| 22 | + <td> | |
| 23 | + <form method="post" action="/admin/cron" class="admin-row"> | |
| 24 | + <input type="hidden" name="csrf_token" value="{{ $csrf }}" /> | |
| 25 | + <input type="hidden" name="id" value="{{ $j['id'] }}" /> | |
| 26 | + <input type="text" name="schedule" value="{{ $j['schedule'] }}" size="14" /> | |
| 27 | + <input type="text" name="task" value="{{ $j['task'] }}" size="12" list="admin-tasks" /> | |
| 28 | + <button name="do" value="edit">変更</button> | |
| 29 | + </form> | |
| 30 | +{@ if (!$j['registered']) @} | |
| 31 | + <small class="admin-warn">未登録のタスク</small> | |
| 32 | +{@ endif @} | |
| 33 | + </td> | |
| 34 | + <td>{{ $j['next'] }}</td> | |
| 35 | + <td>{{ $j['last'] }}</td> | |
| 36 | + <td>{{ $j['error'] }}</td> | |
| 37 | + <td> | |
| 38 | + <form method="post" action="/admin/cron" class="admin-row"> | |
| 39 | + <input type="hidden" name="csrf_token" value="{{ $csrf }}" /> | |
| 40 | + <input type="hidden" name="id" value="{{ $j['id'] }}" /> | |
| 41 | + <button name="do" value="toggle">{{ $j['active'] ? '無効にする' : '有効にする' }}</button> | |
| 42 | + <button name="do" value="run">今すぐ実行</button> | |
| 43 | + <button name="do" value="delete" class="danger">削除</button> | |
| 44 | + </form> | |
| 45 | + </td> | |
| 46 | + </tr> | |
| 47 | +{@ endforeach @} | |
| 48 | + </tbody> | |
| 49 | + </table> | |
| 50 | + </div> | |
| 51 | +{@ endif @} | |
| 52 | + <h2 class="paragraph">ジョブを追加</h2> | |
| 53 | + <form method="post" action="/admin/cron" class="admin-row"> | |
| 54 | + <input type="hidden" name="csrf_token" value="{{ $csrf }}" /> | |
| 55 | + <input type="text" name="schedule" placeholder="*/10 * * * *" required="required" size="14" /> | |
| 56 | + <input type="text" name="task" placeholder="タスク" required="required" size="12" list="admin-tasks" /> | |
| 57 | + <button name="do" value="add">追加</button> | |
| 58 | + </form> | |
| 59 | + <p class="paragraph"><small>予定は「分 時 日 月 曜日」又は@yearly・@monthly・@weekly・@daily・@hourly。登録されたタスク:{{ empty($tasks) ? '無し' : implode('、', $tasks) }}</small></p> | |
| 60 | + <datalist id="admin-tasks"> | |
| 61 | +{@ foreach ($tasks as $t) @} | |
| 62 | + <option value="{{ $t }}"></option> | |
| 63 | +{@ endforeach @} | |
| 64 | + </datalist> | |
| 65 | +{@ endif @} | |
| 66 | +{@ include(common/footer) @} |
| @@ -0,0 +1,27 @@ | ||
| 1 | +{@ include(common/header) @} | |
| 2 | +{@ include(admin/nav) @} | |
| 3 | + <h1 class="paragraph">管理画面</h1> | |
| 4 | +{@ foreach ($warnings as $w) @} | |
| 5 | + <p class="admin-warn">{{ $w }}</p> | |
| 6 | +{@ endforeach @} | |
| 7 | + <div class="admin-cards"> | |
| 8 | + <a class="admin-card" href="/admin/users"><b>ユーザー</b><span>{{ $userCount }}人(管理者:{{ $adminCount }}人)</span></a> | |
| 9 | + <a class="admin-card" href="/admin/logs"><b>ログ</b><span>{{ $logCount }}件({{ $logSize }})</span></a> | |
| 10 | + <a class="admin-card" href="/admin/cron"><b>Cron</b><span>前回の実行:{{ $cronTick }}</span></a> | |
| 11 | + <a class="admin-card" href="/admin/cache"><b>キャッシュ</b><span>{{ $cache }}</span></a> | |
| 12 | + <a class="admin-card" href="/admin/convert"><b>データベースの変換</b><span>サイトのデータベース:{{ $db }}</span></a> | |
| 13 | + </div> | |
| 14 | + <h2 class="paragraph">サーバー</h2> | |
| 15 | + <table class="admin-table"> | |
| 16 | + <tbody> | |
| 17 | + <tr><th>data/</th><td>{{ $dataDir['exists'] ? ($dataDir['writable'] ? '書き込み可' : '書き込み不可') : '無し' }}、{{ $dataDir['files'] }}{{ $dataDir['capped'] ? '件以上' : '件' }}({{ $dataDir['size'] }})</td></tr> | |
| 18 | + <tr><th>PHP</th><td>{{ $php }}</td></tr> | |
| 19 | + </tbody> | |
| 20 | + </table> | |
| 21 | + <h2 class="paragraph">機能(config.php)</h2> | |
| 22 | + <p class="admin-features"> | |
| 23 | +{@ foreach ($features as $ft) @} | |
| 24 | + <span class="{{ $ft['on'] ? 'admin-on' : 'admin-off' }}">{{ $ft['name'] }}</span> | |
| 25 | +{@ endforeach @} | |
| 26 | + </p> | |
| 27 | +{@ include(common/footer) @} |
| @@ -0,0 +1,49 @@ | ||
| 1 | +{@ include(common/header) @} | |
| 2 | +{@ include(admin/nav) @} | |
| 3 | + <h1 class="paragraph">ログ</h1> | |
| 4 | +{@ if (!$logging) @} | |
| 5 | + <p class="admin-warn">LOGGING_ENABLEDが無効なので、新しいログは残りません。</p> | |
| 6 | +{@ endif @} | |
| 7 | +{@ if (!$dirExists) @} | |
| 8 | + <p class="paragraph">log/が有りません。</p> | |
| 9 | +{@ elif (empty($list)) @} | |
| 10 | + <p class="paragraph">ログは有りません。</p> | |
| 11 | +{@ else @} | |
| 12 | + <table class="admin-table"> | |
| 13 | + <thead><tr><th>ファイル</th><th>大きさ</th><th>更新</th></tr></thead> | |
| 14 | + <tbody> | |
| 15 | +{@ foreach ($list as $l) @} | |
| 16 | + <tr{{{ $l['name'] === $file ? ' class="admin-current"' : '' }}}><td><a href="/admin/logs?file={{ rawurlencode($l['name']) }}">{{ $l['name'] }}</a></td><td>{{ $l['sizeText'] }}</td><td>{{ $l['time'] }}</td></tr> | |
| 17 | +{@ endforeach @} | |
| 18 | + </tbody> | |
| 19 | + </table> | |
| 20 | +{@ endif @} | |
| 21 | +{@ if ($file !== '') @} | |
| 22 | + <h2 class="paragraph">{{ $file }}</h2> | |
| 23 | + <form method="get" action="/admin/logs" class="admin-row"> | |
| 24 | + <input type="hidden" name="file" value="{{ $file }}" /> | |
| 25 | + <select name="lines"> | |
| 26 | +{@ foreach ([50, 200, 1000, 5000] as $n) @} | |
| 27 | + <option value="{{ $n }}"{{{ $n === $lines ? ' selected="selected"' : '' }}}>最後の{{ $n }}行</option> | |
| 28 | +{@ endforeach @} | |
| 29 | + </select> | |
| 30 | + <input type="text" name="q" value="{{ $q }}" placeholder="検索" /> | |
| 31 | + <button>表示</button> | |
| 32 | + <a href="/admin/logs?file={{ rawurlencode($file) }}&download=1">ダウンロード</a> | |
| 33 | + </form> | |
| 34 | +{@ if ($tail['more']) @} | |
| 35 | + <p class="paragraph"><small>前にもっと有ります(全部はダウンロードで)。</small></p> | |
| 36 | +{@ endif @} | |
| 37 | +{@ if (empty($tail['lines'])) @} | |
| 38 | + <p class="paragraph">{{ $q !== '' ? '一致する行は有りません。' : '空です。' }}</p> | |
| 39 | +{@ else @} | |
| 40 | + <pre class="admin-pre admin-log">{{ implode("\n", $tail['lines']) }}</pre> | |
| 41 | +{@ endif @} | |
| 42 | + <form method="post" action="/admin/logs?file={{ rawurlencode($file) }}" class="admin-row"> | |
| 43 | + <input type="hidden" name="csrf_token" value="{{ $csrf }}" /> | |
| 44 | + <input type="hidden" name="file" value="{{ $file }}" /> | |
| 45 | + <button name="do" value="clear">空にする</button> | |
| 46 | + <button name="do" value="delete" class="danger">削除</button> | |
| 47 | + </form> | |
| 48 | +{@ endif @} | |
| 49 | +{@ include(common/footer) @} |
| @@ -0,0 +1,11 @@ | ||
| 1 | + <nav class="admin-nav"> | |
| 2 | +{@ foreach ($adminSections as $k => $label) @} | |
| 3 | + <a href="/admin{{ $k === 'index' ? '' : '/'.$k }}"{{{ $adminSection === $k ? ' class="active"' : '' }}}>{{ $label }}</a> | |
| 4 | +{@ endforeach @} | |
| 5 | + </nav> | |
| 6 | +{@ if ($error) @} | |
| 7 | + <div class="errormes">{{ $error }}</div> | |
| 8 | +{@ endif @} | |
| 9 | +{@ if ($message) @} | |
| 10 | + <p class="admin-message">{{ $message }}</p> | |
| 11 | +{@ endif @} |
| @@ -0,0 +1,21 @@ | ||
| 1 | +{@ include(common/header) @} | |
| 2 | + <h1 class="paragraph">最初の管理者</h1> | |
| 3 | +{@ if ($error) @} | |
| 4 | + <div class="errormes">{{ $error }}</div> | |
| 5 | +{@ endif @} | |
| 6 | +{@ if (!$hasCode) @} | |
| 7 | + <p class="paragraph">管理者のアカウントが有りません。最初の管理者を作るには、config/config.phpのADMIN_SETUP_CODEに長くて推測されにくいコードを設定して、このページを再読み込みして下さい。</p> | |
| 8 | + <pre class="admin-pre">define('ADMIN_SETUP_CODE', '(例:openssl rand -hex 16 の出力)');</pre> | |
| 9 | +{@ else @} | |
| 10 | + <p class="paragraph">管理者のアカウントが有りません。config.phpのADMIN_SETUP_CODEと同じコードを入力して、最初の管理者を作って下さい。作った後はADMIN_SETUP_CODEを空にして下さい。</p> | |
| 11 | + <form method="post" action="/admin/setup" class="admin-form"> | |
| 12 | + <input type="hidden" name="csrf_token" value="{{ $csrf }}" /> | |
| 13 | + <label>セットアップのコード:<input type="password" name="code" autocomplete="off" required="required" /></label> | |
| 14 | + <label>ユーザー名(6文字以上の英数字):<input type="text" name="username" value="{{ $in['username'] }}" required="required" /></label> | |
| 15 | + <label>メールアドレス:<input type="email" name="email" value="{{ $in['email'] }}" required="required" /></label> | |
| 16 | + <label>パスワード:<input type="password" name="password" autocomplete="new-password" required="required" /></label> | |
| 17 | + <label>パスワード(確認):<input type="password" name="passwordVerify" autocomplete="new-password" required="required" /></label> | |
| 18 | + <button>管理者を作る</button> | |
| 19 | + </form> | |
| 20 | +{@ endif @} | |
| 21 | +{@ include(common/footer) @} |
| @@ -0,0 +1,50 @@ | ||
| 1 | +{@ include(common/header) @} | |
| 2 | +{@ include(admin/nav) @} | |
| 3 | + <h1 class="paragraph">ユーザー:{{ $u['username'] }}</h1> | |
| 4 | + <table class="admin-table"> | |
| 5 | + <tbody> | |
| 6 | + <tr><th>ID</th><td>{{ $u['id'] }}</td></tr> | |
| 7 | + <tr><th>表示名</th><td>{{ $u['displayname'] }}</td></tr> | |
| 8 | + <tr><th>メールアドレス</th><td>{{ $u['email'] }}{{ $u['pendingEmail'] !== '' ? '(確認待ち:'.$u['pendingEmail'].')' : '' }}{{ $u['verified'] ? '' : '(未確認)' }}</td></tr> | |
| 9 | + <tr><th>ロール</th><td>{{ $u['roles'] }}</td></tr> | |
| 10 | + <tr><th>登録</th><td>{{ $u['reg'] }}</td></tr> | |
| 11 | + <tr><th>二段階認証</th><td>{{ $u['twoFactor'] ? '有効' : '無効' }}</td></tr> | |
| 12 | + <tr><th>ログイン</th><td>{{ $u['sessions'] }}件(最後:{{ $u['lastLogin'] }})</td></tr> | |
| 13 | + </tbody> | |
| 14 | + </table> | |
| 15 | + <p class="paragraph"><a href="/profile/{{ $u['username'] }}">プロフィール</a> | <a href="/profile/{{ $u['username'] }}/edit">プロフィールの編集(表示名・アイコン等)</a></p> | |
| 16 | + | |
| 17 | + <h2 class="paragraph">ロールとログイン</h2> | |
| 18 | + <form method="post" action="/admin/users/{{ $u['username'] }}" class="admin-form"> | |
| 19 | + <input type="hidden" name="csrf_token" value="{{ $csrf }}" /> | |
| 20 | + <input type="hidden" name="form" value="roles" /> | |
| 21 | + <div class="admin-checks"> | |
| 22 | +{@ foreach ($roles as $r) @} | |
| 23 | + <label><input type="checkbox" name="roles[]" value="{{ $r['bit'] }}"{{{ $r['on'] ? ' checked="checked"' : '' }}} /> {{ $r['label'] }}</label> | |
| 24 | +{@ endforeach @} | |
| 25 | + </div> | |
| 26 | + <small>全部外すとBANになります。</small> | |
| 27 | +{@ if (!$u['verified'] || $u['pendingEmail'] !== '') @} | |
| 28 | + <label><input type="checkbox" name="verify" value="1" /> メールアドレスを確認済みにする</label> | |
| 29 | +{@ endif @} | |
| 30 | +{@ if ($u['sessions'] > 0) @} | |
| 31 | + <label><input type="checkbox" name="logoutAll" value="1" /> 全部のログインを取り消す{{ $isSelf ? '(自分もログアウトします)' : '' }}</label> | |
| 32 | +{@ endif @} | |
| 33 | + <button>保存</button> | |
| 34 | + </form> | |
| 35 | + | |
| 36 | + <h2 class="paragraph">メールアドレス・パスワード・二段階認証</h2> | |
| 37 | + <form method="post" action="/admin/users/{{ $u['username'] }}" class="admin-form"> | |
| 38 | + <input type="hidden" name="csrf_token" value="{{ $csrf }}" /> | |
| 39 | + <input type="hidden" name="form" value="account" /> | |
| 40 | + <label>メールアドレス:<input type="email" name="email" value="{{ $u['email'] }}" /></label> | |
| 41 | + <label>新しいパスワード:<input type="password" name="password" autocomplete="new-password" /></label> | |
| 42 | + <label>新しいパスワード(確認):<input type="password" name="passwordVerify" autocomplete="new-password" /></label> | |
| 43 | + <small>パスワードを変えると、全部のログインを取り消します。</small> | |
| 44 | +{@ if ($u['twoFactor']) @} | |
| 45 | + <label><input type="checkbox" name="disableTwoFactor" value="1" /> 二段階認証を無効にする(端末を失くした時等)</label> | |
| 46 | +{@ endif @} | |
| 47 | + <label>あなた(管理者)のパスワード:<input type="password" name="adminPassword" autocomplete="current-password" required="required" /></label> | |
| 48 | + <button>変更</button> | |
| 49 | + </form> | |
| 50 | +{@ include(common/footer) @} |
| @@ -0,0 +1,47 @@ | ||
| 1 | +{@ include(common/header) @} | |
| 2 | +{@ include(admin/nav) @} | |
| 3 | + <h1 class="paragraph">ユーザー</h1> | |
| 4 | + <form method="get" action="/admin/users" class="admin-row"> | |
| 5 | + <input type="text" name="q" value="{{ $q }}" placeholder="ユーザー名・表示名・メールアドレス" /> | |
| 6 | + <button>検索</button> | |
| 7 | + </form> | |
| 8 | +{@ if (empty($list)) @} | |
| 9 | + <p class="paragraph">ユーザーは居ません。</p> | |
| 10 | +{@ else @} | |
| 11 | + <div class="admin-scroll"> | |
| 12 | + <table class="admin-table"> | |
| 13 | + <thead> | |
| 14 | + <tr><th>ID</th><th>ユーザー</th><th>メールアドレス</th><th>ロール</th><th>登録</th><th>状態</th><th>ログイン</th></tr> | |
| 15 | + </thead> | |
| 16 | + <tbody> | |
| 17 | +{@ foreach ($list as $u) @} | |
| 18 | + <tr{{{ $u['banned'] ? ' class="admin-banned"' : '' }}}> | |
| 19 | + <td>{{ $u['id'] }}</td> | |
| 20 | + <td class="admin-user"><img src="{{ $u['avatar'] }}" alt="" /><a href="/admin/users/{{ $u['username'] }}">{{ $u['username'] }}</a>{{ $u['displayname'] !== '' ? '('.$u['displayname'].')' : '' }}</td> | |
| 21 | + <td>{{ $u['email'] }}{{ $u['pendingEmail'] !== '' ? '(確認待ち:'.$u['pendingEmail'].')' : '' }}</td> | |
| 22 | + <td>{{ $u['roles'] }}</td> | |
| 23 | + <td>{{ $u['reg'] }}</td> | |
| 24 | + <td>{{ $u['verified'] ? '' : '未確認 ' }}{{ $u['twoFactor'] ? '2FA' : '' }}</td> | |
| 25 | + <td>{{ $u['sessions'] }}件、{{ $u['lastLogin'] }}</td> | |
| 26 | + </tr> | |
| 27 | +{@ endforeach @} | |
| 28 | + </tbody> | |
| 29 | + </table> | |
| 30 | + </div> | |
| 31 | +{@ endif @} | |
| 32 | + <h2 class="paragraph">ユーザーを作る</h2> | |
| 33 | + <p class="paragraph">登録が無効でも作れます。メールアドレスの確認は不要です。</p> | |
| 34 | + <form method="post" action="/admin/users" class="admin-form"> | |
| 35 | + <input type="hidden" name="csrf_token" value="{{ $csrf }}" /> | |
| 36 | + <label>ユーザー名:<input type="text" name="username" value="{{ $in['username'] }}" required="required" /></label> | |
| 37 | + <label>メールアドレス:<input type="email" name="email" value="{{ $in['email'] }}" required="required" /></label> | |
| 38 | + <label>パスワード:<input type="password" name="password" autocomplete="new-password" required="required" /></label> | |
| 39 | + <label>パスワード(確認):<input type="password" name="passwordVerify" autocomplete="new-password" required="required" /></label> | |
| 40 | + <div class="admin-checks"> | |
| 41 | +{@ foreach ($roles as $bit => $label) @} | |
| 42 | + <label><input type="checkbox" name="roles[]" value="{{ $bit }}"{{{ $bit === \Roles::MEMBER ? ' checked="checked"' : '' }}} /> {{ $label }}</label> | |
| 43 | +{@ endforeach @} | |
| 44 | + </div> | |
| 45 | + <button>作る</button> | |
| 46 | + </form> | |
| 47 | +{@ include(common/footer) @} |