'リモートのアクター', 'remote/webfinger' => 'WebFinger', 'remote/media' => 'リモートの画像']; private Auth $auth; private ?\stdClass $user; public function __construct() { $this->auth = new Auth(); $this->user = $this->auth->getLoggedInUser(); } //////// ページ /** 概要 */ public function index(array $params): void { $this->guard(); $data = ROOT.'/data'; $dataFiles = $this->countFiles($data, 20000); $logs = new Logs(); $cron = new Cron(); $tick = CRON_ENABLED ? $cron->lastTick() : null; $users = $this->auth->listUsers(); $warnings = []; if (!is_dir($data)) $warnings[] = 'data/が有りません(必要な時に作ります)。'; else if (!is_writable($data)) $warnings[] = 'data/に書き込めません(php-fpmのユーザーのchownをご確認下さい)。'; else if ($dataFiles['files'] === 0) $warnings[] = 'data/は空です。'; if (!is_dir($logs->dir())) $warnings[] = 'log/が有りません(最初のログを書く時に作ります。作れない場合は、php-fpmのユーザーが書けるlog/を作って下さい)。'; else if (!is_writable($logs->dir())) $warnings[] = 'log/に書き込めません。'; if (CRON_ENABLED && ($tick === null || $tick < time() - 600)) $warnings[] = 'Cronが最近実行されていません(システムのcrontabをご確認下さい)。'; if (defined('ADMIN_SETUP_CODE') && (string)ADMIN_SETUP_CODE !== '') $warnings[] = '管理者が居るので、config.phpのADMIN_SETUP_CODEは空にして下さい。'; $cacheSize = 0; $cacheFiles = 0; foreach (Cache::kinds() as $k) { foreach ((new Cache($k))->entries() as $e) { $cacheSize += $e['size']; $cacheFiles++; } } $features = []; foreach (['AUTH_ENABLED', 'AUTH_REGISTER_ENABLED', 'AUTH_VERIFY_EMAIL', 'AUTH_2FA_ENABLED', 'MAILER_ENABLED', 'LOGGING_ENABLED', 'ATOM_ENABLED', 'ACTIVITYPUB_ENABLED', 'ACTIVITYPUB_MULTIUSER', 'COMMENTS_ENABLED', 'CRON_ENABLED', 'DB_ENABLED', 'OPENPROVIDER_ENABLED', 'DEBUG_MODE'] as $c) { $features[] = ['name' => $c, 'on' => defined($c) && constant($c)]; } $this->render('admin/index', '管理画面', 'index', [ 'warnings' => $warnings, 'dataDir' => ['exists' => is_dir($data), 'writable' => is_writable($data), 'files' => $dataFiles['files'], 'size' => $this->size($dataFiles['size']), 'capped' => $dataFiles['capped']], 'logCount' => count($logs->list()), 'logSize' => $this->size(array_sum(array_column($logs->list(), 'size'))), 'userCount' => count($users), 'adminCount' => count(array_filter($users, fn($u) => $u->role & Roles::ADMIN)), 'cronTick' => $tick === null ? '-' : date('Y-m-d H:i', $tick), 'cache' => "{$cacheFiles}件(".$this->size($cacheSize).')', 'db' => defined('DB_ENABLED') && DB_ENABLED ? (string)(DBINFO['driver'] ?? 'mysql') : '無効', 'php' => PHP_VERSION, 'features' => $features, ]); } /** 最初の管理者(管理者が1人も居ない時だけ) */ public function setup(array $params): void { if ($this->auth->hasAdmin()) $this->redirect('/admin'); $code = defined('ADMIN_SETUP_CODE') ? (string)ADMIN_SETUP_CODE : ''; $error = ''; $in = ['username' => '', 'email' => '']; if ($_SERVER['REQUEST_METHOD'] === 'POST' && $code !== '') { $in = ['username' => (string)($_POST['username'] ?? ''), 'email' => (string)($_POST['email'] ?? '')]; if (!$this->csrfOk()) { $error = 'セッションの有効期限が切れました。もう一度お試し下さい。'; } else { $res = $this->auth->setupFirstAdmin((string)($_POST['code'] ?? ''), $in['username'], (string)($_POST['password'] ?? ''), (string)($_POST['passwordVerify'] ?? ''), $in['email']); if ($res->isSuccess) $this->redirect('/admin'); $error = (string)$res->message; } } $this->render('admin/setup', '最初の管理者', 'setup', ['hasCode' => $code !== '', 'error' => $error, 'in' => $in]); } /** ユーザーの一覧と作成 */ public function users(array $params): void { $this->guard(); $error = ''; $message = ''; $in = ['username' => '', 'email' => '']; if ($_SERVER['REQUEST_METHOD'] === 'POST') { $in = ['username' => (string)($_POST['username'] ?? ''), 'email' => (string)($_POST['email'] ?? '')]; if (!$this->csrfOk()) { $error = 'セッションの有効期限が切れました。もう一度お試し下さい。'; } else { $res = $this->auth->adminCreateUser($in['username'], (string)($_POST['password'] ?? ''), (string)($_POST['passwordVerify'] ?? ''), $in['email'], array_map('intval', (array)($_POST['roles'] ?? []))); if ($res->isSuccess) { $message = (string)$res->message; $in = ['username' => '', 'email' => '']; } else { $error = (string)$res->message; } } } $q = trim((string)($_GET['q'] ?? '')); $list = []; foreach ($this->auth->listUsers() as $u) { if ($q !== '' && mb_stripos($u->username.' '.$u->displayname.' '.$u->email, $q) === false) continue; $list[] = $this->presentUser($u); } $this->render('admin/users', 'ユーザー', 'users', [ 'list' => $list, 'q' => $q, 'error' => $error, 'message' => $message, 'in' => $in, 'roles' => Roles::LABELS, ]); } /** ユーザー1人のアカウント */ public function user(array $params): void { $this->guard(); $name = (string)($params['name'] ?? ''); if ($this->auth->getAccount($name) === null) $this->redirect('/admin/users'); $error = ''; $message = ''; if ($_SERVER['REQUEST_METHOD'] === 'POST') { if (!$this->csrfOk()) { $error = 'セッションの有効期限が切れました。もう一度お試し下さい。'; } else { $in = []; if (($_POST['form'] ?? '') === 'roles') { $in['roles'] = array_map('intval', (array)($_POST['roles'] ?? [])); $in['verify'] = isset($_POST['verify']); $in['logoutAll'] = isset($_POST['logoutAll']); } else { $in['email'] = (string)($_POST['email'] ?? ''); $in['password'] = (string)($_POST['password'] ?? ''); $in['passwordVerify'] = (string)($_POST['passwordVerify'] ?? ''); $in['disableTwoFactor'] = isset($_POST['disableTwoFactor']); } $res = $this->auth->adminUpdateAccount($name, $in, (string)($_POST['adminPassword'] ?? '')); if ($res->isSuccess) $message = (string)$res->message; else $error = (string)$res->message; // 自分のログインを取り消した場合 if ($res->isSuccess && $this->user && $name === $this->user->username && (!empty($in['logoutAll']) || ($in['password'] ?? '') !== '')) { $this->redirect('/login'); } } } $u = $this->auth->getAccount($name); $roles = []; foreach (Roles::LABELS as $bit => $label) $roles[] = ['bit' => $bit, 'label' => $label, 'on' => (bool)($u->role & $bit)]; $this->render('admin/user', "ユーザー:{$u->username}", 'users', [ 'u' => $this->presentUser($u), 'roles' => $roles, 'error' => $error, 'message' => $message, 'isSelf' => $this->user && $this->user->username === $u->username, ]); } /** ログ(log/) */ public function logs(array $params): void { $this->guard(); $logs = new Logs(); $file = (string)($_GET['file'] ?? $_POST['file'] ?? ''); $error = ''; $message = ''; if ($_SERVER['REQUEST_METHOD'] === 'POST') { if (!$this->csrfOk()) { $error = 'セッションの有効期限が切れました。もう一度お試し下さい。'; } else if (($_POST['do'] ?? '') === 'clear') { if ($logs->clear($file)) $message = "{$file}を空にしました。"; else $error = "{$file}を空に出来ませんでした。"; } else if (($_POST['do'] ?? '') === 'delete') { if ($logs->delete($file)) { $message = "{$file}を削除しました。"; $file = ''; } else { $error = "{$file}を削除出来ませんでした。"; } } } if ($file !== '' && isset($_GET['download'])) { $p = $logs->path($file); if ($p === null) $this->redirect('/admin/logs'); $this->sendFile($p, $file, 'text/plain; charset=UTF-8'); } $lines = (int)($_GET['lines'] ?? 200); if (!in_array($lines, [50, 200, 1000, 5000], true)) $lines = 200; $q = (string)($_GET['q'] ?? ''); $tail = $file !== '' ? $logs->tail($file, $lines, $q) : null; if ($file !== '' && $tail === null && $error === '' && $message === '') $error = "{$file}が有りません。"; $list = array_map(fn($f) => $f + ['sizeText' => $this->size($f['size']), 'time' => date('Y-m-d H:i', $f['mtime'])], $logs->list()); $this->render('admin/logs', 'ログ', 'logs', [ 'list' => $list, 'file' => $tail !== null ? $file : '', 'tail' => $tail, 'lines' => $lines, 'q' => $q, 'error' => $error, 'message' => $message, 'dirExists' => is_dir($logs->dir()), 'logging' => LOGGING_ENABLED, ]); } /** Cronのジョブ */ public function cron(array $params): void { $this->guard(); $cron = new Cron(); register_cron_tasks($cron); $error = ''; $message = ''; if ($_SERVER['REQUEST_METHOD'] === 'POST' && CRON_ENABLED) { if (!$this->csrfOk()) { $error = 'セッションの有効期限が切れました。もう一度お試し下さい。'; } else { $id = (int)($_POST['id'] ?? 0); $do = (string)($_POST['do'] ?? ''); try { $res = match ($do) { 'add' => $cron->add(CronTab::fromString(trim((string)($_POST['schedule'] ?? '')).' '.trim((string)($_POST['task'] ?? '')))), 'edit' => $cron->edit($id, CronTab::fromString(trim((string)($_POST['schedule'] ?? '')).' '.trim((string)($_POST['task'] ?? '')), (bool)($cron->show($id)->data['isActive'] ?? true))), 'toggle' => $cron->toggle($id), 'delete' => $cron->delete($id), 'run' => $this->runJob($cron, $id), default => \Result::Error('不明な操作です。'), }; } catch (\InvalidArgumentException $e) { $res = \Result::Error($e->getMessage()); } if ($res->isSuccess) { $message = (string)$res->message ?: '完了しました。'; $task = trim((string)($_POST['task'] ?? '')); if (in_array($do, ['add', 'edit'], true) && !in_array($task, $cron->tasks(), true)) $message .= "(注意:タスク「{$task}」は登録されていません)"; } else { $error = (string)$res->message; } } } $jobs = []; $tasks = $cron->tasks(); if (CRON_ENABLED) { $res = $cron->list(); if (!$res->isSuccess) $error = $error ?: (string)$res->message; $now = new \DateTimeImmutable(); foreach ($res->isSuccess ? $res->data : [] as $d) { $ct = CronTab::fromArray($d); $err = $ct->validate(); $next = $err === null && $ct->isActive ? $ct->nextRun($now) : null; $jobs[] = [ 'id' => $ct->getId(), 'active' => $ct->isActive, 'schedule' => $ct->expression(), 'task' => $ct->command, 'registered' => in_array($ct->command, $tasks, true), 'next' => $next ? $next->format('Y-m-d H:i') : '-', 'last' => $ct->lastRun ? date('Y-m-d H:i', $ct->lastRun) : '-', 'error' => $err ?? $ct->lastError ?? '', ]; } } $tick = CRON_ENABLED ? $cron->lastTick() : null; $this->render('admin/cron', 'Cron', 'cron', [ 'enabled' => CRON_ENABLED, 'jobs' => $jobs, 'tasks' => $tasks, 'error' => $error, 'message' => $message, 'tick' => $tick === null ? null : date('Y-m-d H:i', $tick), 'stale' => $tick === null || $tick < time() - 600, ]); } /** キャッシュ(data/cache/とFediverseのキャッシュ) */ public function cache(array $params): void { $this->guard(); $error = ''; $message = ''; if ($_SERVER['REQUEST_METHOD'] === 'POST') { if (!$this->csrfOk()) { $error = 'セッションの有効期限が切れました。もう一度お試し下さい。'; } else { $kind = (string)($_POST['kind'] ?? ''); $name = (string)($_POST['name'] ?? ''); $do = (string)($_POST['do'] ?? ''); try { if ($do === 'one' && in_array($kind, Cache::kinds(), true)) { $res = (new Cache($kind))->murder($name); $res->isSuccess ? $message = "{$kind}/{$name}を削除しました。" : $error = (string)$res->message; } else if ($do === 'kind' && in_array($kind, Cache::kinds(), true)) { $message = "{$kind}の".(new Cache($kind))->murderAll().'件を削除しました。'; } else if ($do === 'all') { $n = 0; foreach (Cache::kinds() as $k) $n += (new Cache($k))->murderAll(); $message = "キャッシュ{$n}件を削除しました。"; } else if ($do === 'fedi' && isset(self::FEDI_CACHES[$kind])) { $n = 0; foreach (glob(ROOT.'/data/ap/'.$kind.'/*') ?: [] as $f) if (is_file($f) && @unlink($f)) $n++; $message = self::FEDI_CACHES[$kind]."のキャッシュ{$n}件を削除しました。"; } else { $error = '不明な操作です。'; } } catch (\InvalidArgumentException $e) { $error = $e->getMessage(); } } } // 1件の中身 $view = null; $vk = (string)($_GET['kind'] ?? ''); $vn = (string)($_GET['name'] ?? ''); if ($vk !== '' && in_array($vk, Cache::kinds(), true)) { $data = (new Cache($vk))->get($vn); if (!empty($data)) $view = ['kind' => $vk, 'name' => $vn, 'json' => (string)json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)]; } $kinds = []; foreach (Cache::kinds() as $k) { $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()); $kinds[] = ['name' => $k, 'entries' => $entries, 'size' => $this->size(array_sum(array_column($entries, 'size')))]; } $fedi = []; foreach (self::FEDI_CACHES as $dir => $label) { $c = $this->countFiles(ROOT.'/data/ap/'.$dir, 100000); if ($c['files'] > 0) $fedi[] = ['dir' => $dir, 'label' => $label, 'files' => $c['files'], 'size' => $this->size($c['size'])]; } $this->render('admin/cache', 'キャッシュ', 'cache', ['kinds' => $kinds, 'fedi' => $fedi, 'view' => $view, 'error' => $error, 'message' => $message]); } /** データベースの変換 */ public function convert(array $params): void { $this->guard(); $tmp = ROOT.'/data/tmp/convert'; $this->cleanTmp($tmp); // 変換したファイルのダウンロード if (isset($_GET['download'])) { $token = (string)$_GET['download']; $meta = preg_match('/^[0-9a-f]{32}$/', $token) ? json_decode((string)@file_get_contents("{$tmp}/{$token}.json"), true) : null; if (!is_array($meta) || !is_file("{$tmp}/{$token}.out")) $this->redirect('/admin/convert'); $this->sendFile("{$tmp}/{$token}.out", (string)$meta['name'], (string)$meta['type']); } $form = $this->convertForm(); $error = ''; $result = null; $tables = null; if ($_SERVER['REQUEST_METHOD'] === 'POST') { if (empty($_POST) && (int)($_SERVER['CONTENT_LENGTH'] ?? 0) > 0) { // post_max_sizeを超えると、PHPは$_POSTを空にする $error = 'ファイルが大き過ぎます(php.iniのupload_max_filesize・post_max_size)。'; } else if (!$this->csrfOk()) { $error = 'セッションの有効期限が切れました。もう一度お試し下さい。'; } else { @set_time_limit(0); ignore_user_abort(true); try { $tmp = $this->tmpDir(); $form['src']['upload'] = $this->takeUpload($tmp, $form['src']['upload']); $src = $this->source($form['src'], $tmp); if (($_POST['do'] ?? '') === 'inspect') { $tables = array_map(fn($t) => ['name' => $t['name'], 'columns' => count($t['columns'])], $src->tables()); } else { $result = $this->runConvert($src, $form, $tmp); } } catch (\Throwable $e) { $error = $e->getMessage(); } } } $this->render('admin/convert', 'データベースの変換', 'convert', [ 'f' => $form, 'error' => $error, 'result' => $result, 'tables' => $tables, 'siteDb' => defined('DB_ENABLED') && DB_ENABLED ? (string)(DBINFO['driver'] ?? 'mysql') : '', 'zlib' => function_exists('gzopen'), ]); } //////// 変換 /** フォームの値(パスワードは画面に戻さない) */ private function convertForm(): array { $side = function(string $p, string $defKind): array { $g = fn(string $k, string $d = '') => is_string($_POST["{$p}_{$k}"] ?? null) ? trim($_POST["{$p}_{$k}"]) : $d; return [ 'kind' => $g('kind', $defKind), 'dir' => $g('dir', 'data'), 'path' => $g('path', $p === 'src' ? '' : 'data/site.sqlite'), 'host' => $g('host', '127.0.0.1'), 'port' => $g('port'), 'socket' => $g('socket'), 'username' => $g('username'), 'password' => $g('password'), 'dbname' => $g('dbname'), 'tls' => isset($_POST["{$p}_tls"]), 'dialect' => $g('dialect', 'mysql'), 'gzip' => isset($_POST["{$p}_gzip"]), 'overwrite' => isset($_POST["{$p}_overwrite"]), 'dumppath' => $g('dumppath'), 'upload' => $g('upload'), ]; }; return [ 'src' => $side('src', 'json'), 'dst' => $side('dst', 'sqlite'), 'tables' => is_string($_POST['tables'] ?? null) ? trim($_POST['tables']) : '', 'drop' => isset($_POST['drop']), ]; } /** * サーバーのパス(相対パスはROOTから。ROOTの外は不可) * * @param bool $mustExist ファイル・ディレクトリが有る事 */ private function serverPath(string $path, bool $mustExist): string { $path = str_replace('\\', '/', trim($path)); if ($path === '' || preg_match('/[\x00-\x1f]/', $path)) throw new \InvalidArgumentException('パスを入力して下さい。'); if (!preg_match('#^(/|[A-Za-z]:/)#', $path)) $path = ROOT.'/'.$path; foreach (explode('/', $path) as $part) if ($part === '..') throw new \InvalidArgumentException("「..」は使えません:{$path}"); $root = rtrim(str_replace('\\', '/', (string)realpath(ROOT)), '/'); // 有る親のディレクトリで確かめる(シンボリックリンクで外に出ない様に) $check = $path; while (!file_exists($check) && dirname($check) !== $check) $check = dirname($check); $real = str_replace('\\', '/', (string)realpath($check)); if ($real !== $root && !str_starts_with($real.'/', $root.'/')) throw new \InvalidArgumentException("サイトのディレクトリの外は使えません:{$path}"); if ($mustExist && !file_exists($path)) throw new \InvalidArgumentException("有りません:{$path}"); return $path; } /** フォームの接続の情報 */ private function connect(array $s): Db { if ($s['kind'] === 'site') { if (!(defined('DB_ENABLED') && DB_ENABLED)) throw new \InvalidArgumentException('サイトのデータベース(DB_ENABLED)は無効です。'); return new Db(); } if ($s['kind'] === 'sqlite') return new Db(['driver' => 'sqlite', 'path' => $s['path']]); $info = ['driver' => $s['kind'], 'host' => $s['host'] ?: '127.0.0.1', 'username' => $s['username'], 'password' => $s['password'], 'dbname' => $s['dbname'], 'tls' => $s['tls'], 'timeout' => 10]; if ($s['port'] !== '') $info['port'] = (int)$s['port']; if ($s['socket'] !== '') $info['socket'] = $s['socket']; if ($s['kind'] === 'mysql') $info['charset'] = 'utf8mb4'; return new Db($info); } /** アップロードしたファイル(ダンプ・SQLite)を一時ディレクトリに置く */ private function takeUpload(string $tmp, string $token): string { $f = $_FILES['src_file'] ?? null; if (is_array($f) && ($f['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE) { if ($f['error'] !== UPLOAD_ERR_OK) { throw new \RuntimeException($f['error'] === UPLOAD_ERR_INI_SIZE || $f['error'] === UPLOAD_ERR_FORM_SIZE ? 'ファイルが大き過ぎます(php.iniのupload_max_filesize・post_max_size)。' : 'アップロードに失敗しました。'); } $token = bin2hex(random_bytes(16)); if (!is_uploaded_file($f['tmp_name']) || !move_uploaded_file($f['tmp_name'], "{$tmp}/{$token}.in")) throw new \RuntimeException('アップロードしたファイルを保存出来ません。'); file_put_contents("{$tmp}/{$token}.json", json_encode(['name' => basename((string)$f['name'])], JSON_UNESCAPED_UNICODE)); return $token; } // 前の画面(テーブルの確認)でアップロードしたファイル return preg_match('/^[0-9a-f]{32}$/', $token) && is_file("{$tmp}/{$token}.in") ? $token : ''; } private function source(array $s, string $tmp): Source { switch ($s['kind']) { case 'json': return new JsonSource($this->serverPath($s['dir'], false)); case 'dump': if ($s['upload'] !== '') { $file = "{$tmp}/{$s['upload']}.in"; // SQLiteのファイルをアップロードした場合 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])); return new DumpSource($file); } if ($s['dumppath'] === '') throw new \InvalidArgumentException('ダンプのファイルをアップロードするか、サーバーのパスを入力して下さい。'); return new DumpSource($this->serverPath($s['dumppath'], true)); case 'sqlite': $s['path'] = $this->serverPath($s['path'], true); return new DbSource(new Db(['driver' => 'sqlite', 'path' => $s['path'], 'readonly' => true])); case 'mysql': case 'pgsql': case 'site': return new DbSource($this->connect($s)); } throw new \InvalidArgumentException('読む元を選んで下さい。'); } /** * 変換する * * @return array tables、warnings、seconds、download(ダウンロードのURL) */ private function runConvert(Source $src, array $f, string $tmp): array { $tables = $f['tables'] !== '' ? array_values(array_filter(array_map('trim', explode(',', $f['tables'])), fn($t) => $t !== '')) : null; // 書く先を作る前に(空のファイル等を残さない様に) $names = array_map(fn($t) => strtolower($t['name']), $src->tables()); if ($tables !== null) $names = array_intersect($names, array_map('strtolower', $tables)); if (empty($names)) throw new \RuntimeException($tables === null ? '読む元にテーブルが有りません。' : '指定したテーブルが読む元に有りません。'); $d = $f['dst']; $download = null; $token = bin2hex(random_bytes(16)); switch ($d['kind']) { case 'json': $dst = new JsonTarget($this->serverPath($d['dir'], false), $d['overwrite']); break; case 'dump': $ext = '.sql'.($d['gzip'] ? '.gz' : ''); if ($d['dumppath'] !== '') { $path = $this->serverPath($d['dumppath'], false); if (file_exists($path) && !$d['overwrite']) throw new \InvalidArgumentException("既に有ります(上書きを選んで下さい):{$path}"); $dst = new DumpTarget($path, $d['dialect']); } else { $dst = new DumpTarget("{$tmp}/{$token}.out{$ext}", $d['dialect']); $download = ['name' => 'littlebeast-'.$d['dialect'].'-'.date('Ymd-His').$ext, 'type' => $d['gzip'] ? 'application/gzip' : 'application/sql']; } break; case 'sqlite': if ($d['path'] !== '') { $d['path'] = $this->serverPath($d['path'], false); $dst = new DbTarget(new Db(['driver' => 'sqlite', 'path' => $d['path']])); } else { $dst = new DbTarget(new Db(['driver' => 'sqlite', 'path' => "{$tmp}/{$token}.out"])); $download = ['name' => 'littlebeast-'.date('Ymd-His').'.sqlite', 'type' => 'application/vnd.sqlite3']; } break; case 'mysql': case 'pgsql': case 'site': $dst = new DbTarget($this->connect($d)); break; default: throw new \InvalidArgumentException('書く先を選んで下さい。'); } // 同時に2つの変換をしない $lock = fopen("{$tmp}/.lock", 'c'); if (!$lock || !flock($lock, LOCK_EX | LOCK_NB)) throw new \RuntimeException('別の変換が実行中です。'); try { $start = microtime(true); $res = Convert::run($src, $dst, ['tables' => $tables, 'drop' => $f['drop']]); $res['seconds'] = round(microtime(true) - $start, 1); } finally { flock($lock, LOCK_UN); fclose($lock); } if ($download !== null) { // DumpTargetは.gzの拡張子で圧縮するので、名前を揃える if (is_file("{$tmp}/{$token}.out.sql.gz")) rename("{$tmp}/{$token}.out.sql.gz", "{$tmp}/{$token}.out"); if (is_file("{$tmp}/{$token}.out.sql")) rename("{$tmp}/{$token}.out.sql", "{$tmp}/{$token}.out"); file_put_contents("{$tmp}/{$token}.json", json_encode($download, JSON_UNESCAPED_UNICODE)); $res['download'] = '/admin/convert?download='.$token; $res['downloadName'] = $download['name']; $res['downloadSize'] = $this->size((int)@filesize("{$tmp}/{$token}.out")); } $res['rows'] = array_sum($res['tables']); return $res; } /** 一時ディレクトリ(data/tmp/convert、変換しない) */ private function tmpDir(): string { $dir = ROOT.'/data/tmp/convert'; if (!is_dir($dir) && !@mkdir($dir, 0700, true) && !is_dir($dir)) throw new \RuntimeException("一時ディレクトリを作れません:{$dir}"); return $dir; } private function cleanTmp(string $dir): void { if (!is_dir($dir)) return; foreach (glob($dir.'/*') ?: [] as $f) { if (is_file($f) && filemtime($f) < time() - self::TMP_TTL) @unlink($f); } } //////// 共通 /** 管理者だけ(管理者が居なければ最初の管理者の画面へ) */ private function guard(): void { if ($this->user && $this->auth->isAdmin()) return; if (!$this->auth->hasAdmin()) $this->redirect('/admin/setup'); if (!$this->user) $this->redirect('/login'); header('Location: /404'); exit; } private function runJob(Cron $cron, int $id): \Result { @set_time_limit(300); return $cron->runNow($id); } private function presentUser(\stdClass $u): array { return [ 'id' => $u->id, 'username' => $u->username, 'displayname' => $u->displayname, 'email' => $u->email, 'pendingEmail' => $u->pendingEmail, 'roles' => implode('、', $u->roleLabels), 'banned' => $u->role === Roles::BANNED, 'admin' => (bool)($u->role & Roles::ADMIN), 'reg' => $u->regDate ? date('Y-m-d', $u->regDate) : '-', 'verified' => $u->verified, 'twoFactor' => $u->twoFactorOn, 'sessions' => $u->sessions, 'lastLogin' => $u->lastLogin ? date('Y-m-d H:i', $u->lastLogin) : '-', 'avatar' => $u->avatarUrl, ]; } /** * ファイルの数と大きさ(多過ぎたら途中まで) * * @return array files、size、capped */ private function countFiles(string $dir, int $max): array { $out = ['files' => 0, 'size' => 0, 'capped' => false]; if (!is_dir($dir)) return $out; try { $it = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS)); foreach ($it as $f) { if (!$f->isFile()) continue; $out['files']++; $out['size'] += $f->getSize(); if ($out['files'] >= $max) { $out['capped'] = true; break; } } } catch (\UnexpectedValueException $e) { // 読めないディレクトリ } return $out; } private function size(int $n): string { if ($n < 1024) return "{$n} B"; if ($n < 1048576) return round($n / 1024, 1).' KB'; if ($n < 1073741824) return round($n / 1048576, 1).' MB'; return round($n / 1073741824, 2).' GB'; } private function sendFile(string $path, string $name, string $type): never { while (ob_get_level() > 0) ob_end_clean(); header('Content-Type: '.$type); header('Content-Length: '.(int)filesize($path)); header("Content-Disposition: attachment; filename=\"".preg_replace('/[^A-Za-z0-9._-]/', '_', $name)."\"; filename*=UTF-8''".rawurlencode($name)); header('Cache-Control: no-store'); header('X-Content-Type-Options: nosniff'); readfile($path); exit; } private function csrfOk(): bool { return is_string($_POST['csrf_token'] ?? null) && verify_csrf_token($_POST['csrf_token']); } private function redirect(string $to): never { header('Location: '.$to); exit; } private function render(string $view, string $title, string $section, array $vars): void { $tmpl = new Template('/'); $tmpl->assign('pagetit', $title); $tmpl->assign('curPage', 'admin'); $tmpl->assign('menu', $this->getMenu()); $tmpl->assign('support', $this->getSupport()); $tmpl->assign('sns', $this->getSns()); $tmpl->assign('description', ''); $tmpl->assign('user', $this->user); $tmpl->assign('adminSection', $section); $tmpl->assign('adminSections', ['index' => '概要', 'users' => 'ユーザー', 'logs' => 'ログ', 'cron' => 'Cron', 'cache' => 'キャッシュ', 'convert' => 'データベースの変換']); // クッキーはテンプレートの出力の前に設定する $tmpl->assign('csrf', make_csrf_token()); foreach (['error' => '', 'message' => ''] as $k => $v) $tmpl->assign($k, $vars[$k] ?? $v); foreach ($vars as $k => $v) $tmpl->assign($k, $v); $tmpl->addCss('admin'); $tmpl->render($view); } }