管理者パネル

守矢諏訪子 <suwako@076.moe>、2026-11-13 07:52
コミット 7288b978a39d5d959119c622e421166bce5641b0 ・親 6c02f90 ・ファイルを見る

27個のファイル、+2207 -100

変更 README-JP.md
@@ -247,7 +247,7 @@
247247 * 読む元:`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`。
248248 * オプション:`tables`(このテーブルだけ)、`drop`(書く先の同じ名前のテーブルを消す、無ければ既に有るテーブルはエラー)、`progress`(`fn(string $table, int $rows)`)。結果は `tables`(テーブル毎のコピーした行数)と `warnings`(変換しなかった物)。
249249 * 列、主キー、ユニーク・普通のインデックス、`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` が無ければ、空でないディレクトリには書きません。
251251 * サイトが使っているデータベースを読む・書く場合は、先に書き込みを止めて下さい(又は新しいデータベースに変換)。変換中に元をロックしません。
252252
253253 ### TLS 1.3・1.2(php_openssl は不要)
@@ -277,14 +277,14 @@
277277 * * * * * /usr/local/bin/php-8.5 /var/www/htdocs/cron.php
278278 ```
279279
280-ジョブはコマンドラインで管理します:
280+ジョブはコマンドライン(又は管理画面)で管理します:
281281 ```sh
282282 php cron.php add "*/10 * * * *" apfederate
283283 php cron.php list
284284 php cron.php help
285285 ```
286286
287-タスクは `cron.php` で登録します。組み込みのタスク:
287+タスクは `util.php` の `register_cron_tasks()` で登録します(`cron.php` と管理画面で使います)。組み込みのタスク:
288288 * `apfederate`:新しいブログ投稿を ActivityPub のフォロワーに送る
289289 * `apupdate`:プロフィール(アクター)の更新を ActivityPub のフォロワーに送る
290290 * `authpurge`:7日以内にメールアドレスを確認しなかったアカウントを削除する(`AUTH_VERIFY_EMAIL` が有効の場合)
@@ -313,6 +313,16 @@
313313 `config/config.php` で `AUTH_2FA_ENABLED` を `true` にすると、各ユーザーがプロフィールの編集の「二段階認証」から有効に出来ます。`false` なら誰も使えず、有効にしていたユーザーもパスワードだけでログインします。\
314314 設定画面には QR コードと秘密鍵(PC のパスワードマネージャー用)が表示されるので、認証アプリ(Aegis、Google Authenticator、KeePassXC 等)に登録して、表示された6桁のコードを入力すると有効になります。以後はパスワードの後にコードを入力します(TOTP、SHA-1、6桁、30秒。同じコードは2度使えません)。\
315315 端末を失くした場合は、管理者がそのユーザーの設定画面(`/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` を `''` に戻して下さい。管理者が居ればこのページは使えません。
316326
317327 ## 必要な PHP モジュール
318328 * php_fileinfo(OpenBSDでデフォルトで有効している)
変更 README.md
@@ -245,7 +245,7 @@
245245 * 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`.
246246 * 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).
247247 * 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.
249249 * 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.
250250
251251 ### TLS 1.3 and 1.2 (no php_openssl)
@@ -275,14 +275,14 @@
275275 * * * * * /usr/local/bin/php-8.5 /var/www/htdocs/cron.php
276276 ```
277277
278-Jobs are managed from the command line:
278+Jobs are managed from the command line (or the admin panel):
279279 ```sh
280280 php cron.php add "*/10 * * * *" apfederate
281281 php cron.php list
282282 php cron.php help
283283 ```
284284
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:
286286 * `apfederate`: sends new blog posts to ActivityPub followers
287287 * `apupdate`: sends profile (actor) updates to ActivityPub followers
288288 * `authpurge`: deletes accounts whose email address was not verified within 7 days (when `AUTH_VERIFY_EMAIL` is enabled)
@@ -303,6 +303,16 @@
303303 php cron.php add "@daily" apcleanup
304304 ```
305305 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.
306316
307317 ## Required PHP module
308318 * php_gmp
@@ -114,5 +114,7 @@
114114 define('AUTH_VERIFY_EMAIL', false); // 登録時にメールアドレスの確認を必須にする(MAILER_ENABLEDも必要)
115115 define('AUTH_2FA_ENABLED', false); // 二段階認証(認証アプリのコード)を使えるようにする(各ユーザーがプロフィールの編集から有効にする)
116116 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で最初の管理者を作る為のコード(長くて推測されにくい物、作った後は空に)
117119 define('COPYRIGHT_YEAR', '2018-'.date('Y'));
118120 define('DEBUG_MODE', false);
変更 cron.php
@@ -50,41 +50,14 @@
5050 require_once ROOT.'/util.php';
5151 mb_internal_encoding('UTF-8');
5252
53-use Site\Controller\BlogPost;
54-use Std\Lib\Activitypub;
55-use Std\Lib\Auth;
5653 use Std\Lib\Cron;
5754 use Std\Lib\CronTab;
5855
5956 $cron = new Cron();
6057
61-////////////////////////////////////////////////////////////////
62-// タスクの登録
63-// ジョブの「タスク名」と処理を結び付ける。
64-// 処理はジョブ(CronTab)を受け取り、Result・bool・nullを返す(falseかResult::Errorは失敗)。
65-////////////////////////////////////////////////////////////////
58+// タスクはutil.phpのregister_cron_tasks()で登録する(管理画面からも使う)
59+register_cron_tasks($cron);
6660
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-
8861 $usage = <<<TXT
8962 使い方: php cron.php [コマンド]
9063
@@ -134,7 +107,7 @@
134107 exit(2);
135108 }
136109 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");
138111 }
139112 return $ct;
140113 };
@@ -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 @@
4040
4141 use Std\Lib\Route;
4242
43+use Site\Controller\Admin;
4344 use Site\Controller\Atom;
4445 use Site\Controller\Comment;
4546 use Site\Controller\Fediverse;
@@ -135,6 +136,25 @@
135136
136137 $routes[] = Route::add('GET', 'memberonly', Page::class.'@memberonly');
137138 $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');
138158 }
139159
140160 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 @@
9595 }
9696
9797 if (OPENPROVIDER_ENABLED) {
98+ static $isAdmin = null;
99+ $isAdmin ??= (new \Std\Lib\Auth())->isAdmin();
98100 $menu[] = [
99101 'class' => 'menu-item',
100102 'href' => '/openprovider',
101103 'page' => 'openprovider',
102104 '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,
104119 ];
105120 }
106121
@@ -450,7 +450,7 @@
450450 public function isUserExist(?string $username = null): Result {
451451 if (!AUTH_ENABLED) return Result::Error('エラー:認証システムは無効です。');
452452 if (null === $username) return Result::Error('エラー:ユーザー名をご入力下さい。');
453- $userList = scandir($this->dataDir);
453+ $userList = @scandir($this->dataDir) ?: [];
454454
455455 foreach ($userList as $list) {
456456 if ($list === '.kara' || $list === '.' || $list === '..') continue;
@@ -478,40 +478,8 @@
478478 if ($err != '') return Result::Error($err);
479479 $err = '';
480480
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);
485482
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-
515483 // メールアドレスの確認(リンクを開くまでログイン出来ない)
516484 $verifyToken = null;
517485 if ($this->requiresVerification()) {
@@ -522,15 +490,10 @@
522490 $user->verifySent = time();
523491 }
524492
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'];
527496
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-
534497 if ($verifyToken !== null) {
535498 $sent = $this->sendVerificationMail($user, $verifyToken);
536499 if (!$sent->isSuccess) {
@@ -648,19 +611,8 @@
648611
649612 // ロール(管理者だけ)
650613 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;
664616 }
665617
666618 if (!empty($errors)) {
@@ -722,6 +674,29 @@
722674 return null;
723675 }
724676
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+
725700 private function countAdmins(): int {
726701 $n = 0;
727702 foreach ($this->userFiles() as $path) {
@@ -807,7 +782,205 @@
807782 return Result::Success('未確認のアカウント'.count($deleted).'件を削除しました。', ['deleted' => $deleted]);
808783 }
809784
785+ //////////////////// 管理画面
786+
810787 /**
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+ /**
811984 * 確認メールを送信する(テストで上書き出来る様にprotected)
812985 *
813986 * @param \stdClass $user ユーザーデータ
@@ -867,12 +1040,73 @@
8671040 */
8681041 private function userFiles(): array {
8691042 $out = [];
870- foreach (scandir($this->dataDir) ?: [] as $f) {
1043+ foreach (@scandir($this->dataDir) ?: [] as $f) {
8711044 if (preg_match('/^\d+\.[A-Za-z0-9]+\.json$/', $f)) $out[] = $this->dataDir.$f;
8721045 }
8731046 return $out;
8741047 }
8751048
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+
8761110 ////////////////////
8771111
8781112 private function verifyLogin(string $username, #[SensitiveParameter] string $password): Result {
@@ -920,7 +1154,7 @@
9201154 if (!$username && !$token) return 0;
9211155 if (!$username && !preg_match('/^[0-9a-f]{128}$/', $token)) return 0;
9221156
923- $file = scandir($this->dataDir);
1157+ $file = @scandir($this->dataDir) ?: [];
9241158 $now = time();
9251159 $hashed = $token ? hash('sha256', $token) : '';
9261160
@@ -948,7 +1182,7 @@
9481182
9491183 private function getUserData(): ?\stdClass {
9501184 if (!AUTH_ENABLED) return new \stdClass;
951- $file = scandir($this->dataDir);
1185+ $file = @scandir($this->dataDir) ?: [];
9521186 $userFile = "";
9531187 $matches = [];
9541188
@@ -981,7 +1215,7 @@
9811215
9821216 private function getUserDataFromName(string $name): \stdClass|null {
9831217 if (!AUTH_ENABLED) return new \stdClass;
984- $file = scandir($this->dataDir);
1218+ $file = @scandir($this->dataDir) ?: [];
9851219 $userFile = "";
9861220 $matches = [];
9871221
@@ -1015,7 +1249,7 @@
10151249
10161250 private function isEmailExist(?string $email = null): Result {
10171251 if (!AUTH_ENABLED) return Result::Error('エラー:認証システムは無効です。');
1018- $userList = scandir($this->dataDir);
1252+ $userList = @scandir($this->dataDir) ?: [];
10191253 $matches = [];
10201254
10211255 foreach ($userList as $f) {
@@ -1033,7 +1267,7 @@
10331267 return Result::Success('エラー:メールアドレスが存在しません。');
10341268 }
10351269
1036- private function verifyEmail(?string $email = null): Result {
1270+ private function verifyEmail(?string $email = null, bool $checkMx = true): Result {
10371271 if (!AUTH_ENABLED) return Result::Error('エラー:認証システムは無効です。');
10381272 if (null === $email || '' === $email) return Result::Error('エラー:メールアドレスをご入力下さい。');
10391273 if (strpos($email, '@') === false) return Result::Error('エラー:メールアドレスは不正です。');
@@ -1043,7 +1277,7 @@
10431277 return Result::Error('エラー:メールアドレスは不正です。');
10441278 }
10451279
1046- if (!checkdnsrr($domain, 'MX')) {
1280+ if ($checkMx && !checkdnsrr($domain, 'MX')) {
10471281 return Result::Error('エラー:メールアドレスは不正です。');
10481282 }
10491283
@@ -140,6 +140,51 @@
140140 && (isset($cache['lifespan']) && time() < ($cache['lifespan'] + $this->dataDuration)));
141141 }
142142
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+
143188 ///////
144189
145190 /**
@@ -97,6 +97,16 @@
9797 return array_keys($this->tasks);
9898 }
9999
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+
100110 public function getLastId(): int {
101111 $res = $this->list();
102112 if (!$res->isSuccess || empty($res->data)) return 0;
@@ -55,7 +55,7 @@
5555 final class Stores {
5656 public const JSON_FLAGS = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT;
5757 /** 変換しないディレクトリ・ファイル(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$#', '#(^|/)\.#'];
5959
6060 /** テーブルの定義 */
6161 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 @@
408408 });
409409 });
410410
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+
411546 // 一時ディレクトリを削除する
412547 $rm = function(string $dir) use (&$rm): void {
413548 foreach (scandir($dir) ?: [] as $f) {
@@ -112,6 +112,23 @@
112112 $test->assertEquals(1, $cache->get('login')['x'] ?? null);
113113 });
114114
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+
115132 $test->it('ディレクトリの外のファイル名を拒否するはず', function($test) use ($base): void {
116133 $cache = new Cache('test', $base);
117134 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 @@
270270 return $res;
271271 }
272272
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+
273302 function logger(LogType $section, mixed $arg): void {
274303 if (!LOGGING_ENABLED) return;
275304 $success = false;
@@ -304,6 +333,8 @@
304333 break;
305334 }
306335
336+ // log/が無ければ作る(新しいサイト等)
337+ if ($success && !is_dir(ROOT.'/log')) @mkdir(ROOT.'/log', 0755, true);
307338 if ($success) file_put_contents($logfile, $arg."\n", FILE_APPEND);
308339 }
309340
@@ -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']) }}&amp;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) }}&amp;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) @}