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