diff --git a/.gitignore b/.gitignore index ac78b449..2d880df1 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ /app/temp/debug_html/* /app/temp/log/* /app/temp/installed.lock +/app/temp/github_release_cache.json +/app/version /app/vendor/ /public/uploads/[0-9a-zA-Z] /public/config.php diff --git a/app/config.sample.php b/app/config.sample.php index fd46d382..2af6451f 100644 --- a/app/config.sample.php +++ b/app/config.sample.php @@ -21,3 +21,6 @@ // publicとappの位置関係を修正した場合には変更してください // Please edit the path when change `app` and `public` relative path condition. define('WWW_DIR', __DIR__ . '/../public/'); // this path need finish with slash. + +// 別のGitHub repoを追従する場合に設定してください +// define('GITHUB_REPO', '/uzulla/fc2blog'); diff --git a/app/locale/en_US.UTF-8/LC_MESSAGES/messages.mo b/app/locale/en_US.UTF-8/LC_MESSAGES/messages.mo index b18d1452..1b9283ec 100644 Binary files a/app/locale/en_US.UTF-8/LC_MESSAGES/messages.mo and b/app/locale/en_US.UTF-8/LC_MESSAGES/messages.mo differ diff --git a/app/locale/en_US.UTF-8/LC_MESSAGES/messages.po b/app/locale/en_US.UTF-8/LC_MESSAGES/messages.po index e8bb58da..b49f5176 100644 --- a/app/locale/en_US.UTF-8/LC_MESSAGES/messages.po +++ b/app/locale/en_US.UTF-8/LC_MESSAGES/messages.po @@ -2503,3 +2503,30 @@ msgstr "" msgid "Name that cannot be specified" msgstr "" + +msgid "System Update" +msgstr "" + +msgid "Releases" +msgstr "" + +msgid "Version" +msgstr "" + +msgid "Operation" +msgstr "" + +msgid "Release information query failed. Please try again later." +msgstr "" + +msgid "Please backup your site before update." +msgstr "更新前にはかならずサイトのバックアップを作成してください。" + +msgid "Request failed: invalid sig, please retry." +msgstr "" + +msgid "Request failed: notfound request version." +msgstr "" + +msgid "Update success." +msgstr "" diff --git a/app/locale/ja_JP.UTF-8/LC_MESSAGES/messages.mo b/app/locale/ja_JP.UTF-8/LC_MESSAGES/messages.mo index 099b8ce6..b938683e 100644 Binary files a/app/locale/ja_JP.UTF-8/LC_MESSAGES/messages.mo and b/app/locale/ja_JP.UTF-8/LC_MESSAGES/messages.mo differ diff --git a/app/locale/ja_JP.UTF-8/LC_MESSAGES/messages.po b/app/locale/ja_JP.UTF-8/LC_MESSAGES/messages.po index 885fd948..7fbaa341 100644 --- a/app/locale/ja_JP.UTF-8/LC_MESSAGES/messages.po +++ b/app/locale/ja_JP.UTF-8/LC_MESSAGES/messages.po @@ -2550,3 +2550,30 @@ msgstr "ホスト ポート番号" msgid "Name that cannot be specified" msgstr "指定できない名称です" + +msgid "System Update" +msgstr "システム更新" + +msgid "Releases" +msgstr "リリース一覧" + +msgid "Version" +msgstr "バージョン" + +msgid "Operation" +msgstr "操作" + +msgid "Release information query failed. Please try again later." +msgstr "情報取得に失敗しました、時間をおいてから再度お試しください。" + +msgid "Please backup your site before update." +msgstr "更新前にはかならずサイトのバックアップを作成してください。" + +msgid "Request failed: invalid sig, please retry." +msgstr "失敗しました、再度試してください。" + +msgid "Request failed: notfound request version." +msgstr "失敗しました、指定のバージョンがみつかりませんでした。" + +msgid "Update success." +msgstr "更新成功しました。" diff --git a/app/src/Model/SystemUpdateModel.php b/app/src/Model/SystemUpdateModel.php new file mode 100644 index 00000000..9c67b000 --- /dev/null +++ b/app/src/Model/SystemUpdateModel.php @@ -0,0 +1,370 @@ + (time() - (60 * 60))) { + $releases_json = file_get_contents(static::$cached_release_info_path); + try { + return json_decode($releases_json, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + error_log("Invalid cached release json. but on going."); + } + } + + // まず削除する + if (file_exists(static::$cached_release_info_path)) { + unlink(static::$cached_release_info_path); + } + + // JSONのrelease情報JSONをGitHub APIより取得 + $options = ['http' => ['header' => "User-Agent: fc2blog_installer"]]; + $releases_json = @file_get_contents( + static::getReleasesApiUrl(), + false, + stream_context_create($options) + ); + + // rate limit等 + $pos = strpos($http_response_header[0], '403'); + if ($pos !== false) { + return null; + } + + // 成功か確認 + $pos = strpos($http_response_header[0], '200'); + if ($pos === false) { + throw new RuntimeException("api request failed: status code {$http_response_header[0]}"); + } + if ($releases_json === false) { + throw new RuntimeException("api request failed"); + } + + // デコード試行 + try { + $releases_data = json_decode($releases_json, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + // JSONでデコード出来ないデータが来ている + error_log("json parse failed:{$e->getMessage()} on {$e->getFile()}:{$e->getLine()}"); + return null; + } + + // app/temp/releases.json にjsonを書き出す + file_put_contents(static::$cached_release_info_path, $releases_json); + + return $releases_data; + } + + /** + * Get latest release that has vX.X.X tag from release list. + * @param $release_list + * @return array|null + */ + public static function getValidLatestRelease($release_list): ?array + { + foreach ($release_list as $release) { + if (preg_match("/\Av[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\\z/", $release['tag_name'])) { + if (isset($release['assets']) && is_array($release['assets']) && count($release['assets']) > 0) { + return $release; + } + } + } + return null; + } + + /** + * Get this fc2blog version from version file + * @param bool $allow_raw + * @return string|null + */ + public static function getVersion(bool $allow_raw = false): ?string + { + if (!file_exists(static::$version_file_path)) { + // 開発中など、不明 + return null; + } + + $version = trim(file_get_contents(static::$version_file_path)); + + if ($allow_raw) { + return $version; + } + + if (!preg_match("/\Av[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\z/u", $version)) { + // invalid version format + return null; + } + + return $version; + } + + /** + * find release array from release list by version string. + * @param array $release_list + * @param string $version + * @return array|null + */ + public static function findByVersionFromReleaseList(array $release_list, string $version): ?array + { + foreach ($release_list as $release) { + if ($version === $release['tag_name']) { + if (isset($release['assets']) && is_array($release['assets']) && count($release['assets']) > 0) { + return $release; + } + } + } + return null; + } + + /** + * file download + * @param $src + * @param $dist + * @return bool + */ + private static function downloadFile($src, $dist): bool + { + // use stream wrapper + $download_read_fh = fopen($src, "r"); + $download_write_fh = fopen($dist, 'w'); + while (!feof($download_read_fh)) { + fwrite($download_write_fh, fread($download_read_fh, 1024 * 1024)); + } + fclose($download_read_fh); + fclose($download_write_fh); + return true; + } + + public static function updateSystemByUrl(string $zip_url) + { + // テンポラリのURLにダウンロード + $file = tmpfile(); + $zip_path = stream_get_meta_data($file)['uri']; + + if (!static::downloadFile($zip_url, $zip_path)) { + throw new RuntimeException("File download error"); + } + + try { + static::updateSystemByLocalZip($zip_path); + } finally { + @unlink($zip_url); + } + } + + public static function updateSystemByLocalZip(string $dist_zip_path) + { + $tmp_path = tempnam(sys_get_temp_dir(), "fc2blog_dist_"); + unlink($tmp_path); // use to directory. file is not important. + + if (!mkdir($tmp_path, 0777, true)) { + throw new RuntimeException("mkdir({$tmp_path}) failed"); + } + + try { + // extract zip in tmp dir + $zip = new ZipArchive(); + if (!$zip->open($dist_zip_path)) { + exit("failed open zip: {$dist_zip_path}"); + } + $zip->extractTo($tmp_path); + + // check extract file + $tmp_dir_app = $tmp_path . "/app"; + $tmp_dir_public = $tmp_path . "/public"; + if (!file_exists($tmp_dir_app) || !file_exists($tmp_dir_public)) { + throw new InvalidArgumentException("It seems failed that file extract or invalid zip."); + } + + // decide app dir. + $app_dir = APP_DIR; + $public_dir = WWW_DIR; + + // deploy files + $files_in_tmp_dir_app = glob($tmp_dir_app . '/{*,.[!.]*,..?*}', GLOB_BRACE); + foreach ($files_in_tmp_dir_app as $files_in_tmp_dir_app_row) { + // `temp` and `config.php` will skip delete/copy. + if ($tmp_dir_app . "/temp" === $files_in_tmp_dir_app_row) continue; + if ($tmp_dir_app . "/config.php" === $files_in_tmp_dir_app_row) continue; + // delete/reset dir/files. + static::rm_r($app_dir . substr($files_in_tmp_dir_app_row, strlen($tmp_dir_app) + 1)); + static::copy_r($files_in_tmp_dir_app_row, $app_dir); // todo error handling + } + + $files_in_tmp_dir_public = glob($tmp_dir_public . '/{*,.[!.]*,..?*}', GLOB_BRACE); + foreach ($files_in_tmp_dir_public as $files_in_tmp_dir_public_row) { + // `user_uploads` will skip delete/copy. + if ($tmp_dir_public . "/user_uploads" === $files_in_tmp_dir_public_row) continue; + if ($tmp_dir_public . "/uploads" === $files_in_tmp_dir_public_row) continue; + // delete/reset dir/files. + static::rm_r($public_dir . substr($files_in_tmp_dir_public_row, strlen($tmp_dir_public) + 1)); + static::copy_r($files_in_tmp_dir_public_row, WWW_DIR); // todo error handling + } + + // index.phpのみ、環境によって動的なので更新する + $index_php = file_get_contents(WWW_DIR . "/index.php"); + $index_php = preg_replace('/\n\$app_dir_path.+;/u', "\n\$app_dir_path = '" . APP_DIR . "';", $index_php); + file_put_contents(WWW_DIR . "/index.php", $index_php); + + } finally { + static::rmdir_r($tmp_path); + } + + } + + private static function rm_r(string $path): bool + { + if (!file_exists($path)) return false; + if (!is_dir($path)) return unlink($path); + if (is_dir($path)) return static::rmdir_r($path); + return false; + } + + private static function rmdir_r(string $dirPath): bool + { + if (!empty($dirPath) && is_dir($dirPath)) { + $dirObj = new RecursiveDirectoryIterator($dirPath, RecursiveDirectoryIterator::SKIP_DOTS); + $files = new RecursiveIteratorIterator($dirObj, RecursiveIteratorIterator::CHILD_FIRST); + + // remove include files,dirs,symlinks. + foreach ($files as $path) { + if ($path->isFile()) { + unlink($path->getPathname()); + } elseif ($path->isLink()) { + unlink($path->getPathname()); + } elseif ($path->isDir()) { + rmdir($path->getPathname()); + } + } + // remove target. + rmdir($dirPath); + return true; + } + return false; + } + + private static function copy_r(string $src_path, string $dest_dir) + { + $src_base_dir = realpath(dirname($src_path)); + $src_file_name = basename($src_path); + + // コピー先ディレクトリの準備 + if (!file_exists($dest_dir)) { + // コピー先ディレクトリがないので作成 + mkdir($dest_dir, 0777, true); + } else if (is_dir($src_path) && (is_file($dest_dir) || is_link($dest_dir))) { + // srcがdirだが、コピー先はdirでない既存がある場合、削除してディレクトリを作成 + // (ファイルなら後で上書きするので、そのまま進める) + unlink($dest_dir); + mkdir($dest_dir, 0777, true); + } + + // 単なるファイルやSymlinkならコピーして終わり + if (!is_dir($src_path)) { + copy($src_path, $dest_dir . "/" . $src_file_name); + return; + } + + $dirObj = new RecursiveDirectoryIterator($src_path, RecursiveDirectoryIterator::SKIP_DOTS); + /** @var SplFileInfo[] $files */ + $files = new RecursiveIteratorIterator($dirObj, RecursiveIteratorIterator::CHILD_FIRST); + + foreach ($files as $path) { + $relative_path = substr($path->getRealPath(), strlen($src_base_dir) + 1); + $src_full_path = $src_base_dir . "/" . $relative_path; + $dest_full_path = $dest_dir . "/" . $relative_path; + + if (file_exists($dest_full_path) && is_dir($dest_full_path)) { + // ディレクトリで、すでにディレクトリが存在しているならスキップ + touch($dest_full_path); // update mtime + continue; + + } else if (!file_exists($dest_full_path) && is_dir($src_full_path)) { + // ディレクトリを作成 + mkdir($dest_full_path, 0777, true); + + } else if (is_dir($src_full_path) && file_exists($dest_full_path) && !is_dir($dest_full_path)) { + // ファイルがディレクトリになっているので、削除してディレクトリへ + unlink($dest_full_path); + mkdir($dest_full_path, 0777, true); + + } else if (is_file($src_full_path) && file_exists($dest_full_path) && is_dir($dest_full_path)) { + // ディレクトリがファイルになっているので、削除してファイルへ + rmdir_r($dest_full_path); + copy($src_full_path, $dest_full_path); + + } else { + // ファイルなら、コピー + + // コピー先親ディレクトリがなければ作成 + $parent_dir = dirname($dest_full_path); + if (!file_exists($parent_dir)) { + mkdir($parent_dir, 0777, true); + } + copy($src_full_path, $dest_full_path); + touch($dest_full_path); // update file timestamp + } + } + } +} diff --git a/app/src/Web/Controller/Admin/SystemUpdateController.php b/app/src/Web/Controller/Admin/SystemUpdateController.php new file mode 100644 index 00000000..ce4d8704 --- /dev/null +++ b/app/src/Web/Controller/Admin/SystemUpdateController.php @@ -0,0 +1,60 @@ +generateNewSig(); + $release_list = SystemUpdateModel::getReleaseInfo(); + $this->set('release_list', $release_list); + $this->set('repo_site_url', SystemUpdateModel::getReleasesUrl()); + $this->set('now_version', SystemUpdateModel::getVersion(true)); + return 'admin/system_update/index.twig'; + } + + public function update(Request $request): string + { + // TODO unit test + + // check sig + if (!$request->isValidSig()) { + $this->setWarnMessage(__("Request failed: invalid sig, please retry.")); + $this->redirect($request, Html::url($request, ['controller' => 'system_update', 'action' => 'index'])); + } + + // check request + $request_version = $request->get('version'); + if (is_null($request_version)) { + $this->setWarnMessage(__("Request failed: notfound request version.")); + $this->redirect($request, Html::url($request, ['controller' => 'system_update', 'action' => 'index'])); + } + + $release_list = SystemUpdateModel::getReleaseInfo(); + // get request version + $release = SystemUpdateModel::findByVersionFromReleaseList($release_list, $request_version); + $zip_url = SystemUpdateModel::getZipDownloadUrl($release); + + // do update system. + SystemUpdateModel::updateSystemByUrl($zip_url); + + // set flash message + $this->setInfoMessage(__("Update success.")); + + // redirect to index + $this->redirect($request, Html::url($request, ['controller' => 'system_update', 'action' => 'index'])); + + throw new LogicException("must be redirect"); + } + +} + diff --git a/app/twig_templates/admin/layouts/default.twig b/app/twig_templates/admin/layouts/default.twig index 9a49f9a4..58895bbc 100644 --- a/app/twig_templates/admin/layouts/default.twig +++ b/app/twig_templates/admin/layouts/default.twig @@ -96,6 +96,7 @@
Now version {{ now_version }}
+ + {{ _('Please backup your site before update.') }} + +Source repository : {{ repo_site_url }}
+ + + + {% if not release_list %} + {{ _('Release information query failed. Please try again later.') }} + {% else %} +{{ _('Version') }} | +{{ _('Description') }} | +{{ _('Operation') }} | +
+ {% if release.tag_name == now_version %}✔{% endif %} + {{ release.tag_name }} + | +
+ {{ release.name }}+{{ release.body|nl2br }} + |
+ + + | +