mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-09-01 21:00:58 +00:00
A single consolidation migration replaces the 150 removed historical files. It decides — from reads alone — whether to build the boundary schema fresh, skip on a fully-migrated 2.x database, or refuse a partial or inconsistent history untouched. Fresh installs run it first and the live v3 chain after; pre-2.x installs are directed through the latest 2.x release. The same verdict backs a self-updater preflight so an unsafe upgrade is refused before any file is copied. Schema verified identical to the boundary fixtures on SQLite, MariaDB and PostgreSQL.
318 lines
9.2 KiB
PHP
318 lines
9.2 KiB
PHP
<?php
|
|
|
|
namespace App\Platform\Operations\Update;
|
|
|
|
use App\Platform\Operations\Database\SchemaConsolidationGuard;
|
|
use App\Platform\Operations\Events\UpdateFinished;
|
|
use App\Platform\Operations\Models\Setting;
|
|
use GuzzleHttp\Exception\RequestException;
|
|
use Illuminate\Support\Facades\Artisan;
|
|
use Illuminate\Support\Facades\File;
|
|
use ZipArchive;
|
|
|
|
/**
|
|
* The self-update pipeline.
|
|
*
|
|
* Each stage is a separate static entry point so that the HTTP controller and
|
|
* the console command can drive them one at a time and report progress in
|
|
* between: check -> download -> unzip -> copy -> clean -> migrate -> finish.
|
|
*/
|
|
class Updater
|
|
{
|
|
use CallsReleaseServer;
|
|
|
|
/**
|
|
* Guzzle options shared by every release-server call.
|
|
*/
|
|
private const REQUEST_OPTIONS = ['timeout' => 100, 'track_redirects' => true];
|
|
|
|
/**
|
|
* Ask the release server whether a newer build exists on the given channel.
|
|
*
|
|
* The answer is handed back as the release server phrased it, except that a
|
|
* list of required extensions is graded against this machine first.
|
|
*/
|
|
public static function checkForUpdate($installed_version, $updater_channel = 'stable')
|
|
{
|
|
$response = static::getRemote(
|
|
sprintf('releases/update-check/%s?channel=%s', $installed_version, $updater_channel),
|
|
self::REQUEST_OPTIONS
|
|
);
|
|
|
|
$answer = (object) ['success' => false, 'release' => null];
|
|
|
|
if ($response && ($response->getStatusCode() == 200)) {
|
|
$answer = json_decode($response->getBody()->getContents());
|
|
}
|
|
|
|
if ($answer->success && $answer->release && property_exists($answer->release, 'extensions')) {
|
|
$answer->release->extensions = static::gradeRequirements(
|
|
$answer->release->extensions,
|
|
$answer->release->min_php_version
|
|
);
|
|
}
|
|
|
|
return $answer;
|
|
}
|
|
|
|
/**
|
|
* Pull the release archive into a private temporary directory.
|
|
*
|
|
* @return string|array|false the archive path, or a falsy/failure payload
|
|
*/
|
|
public static function download($new_version, $is_cmd = 0)
|
|
{
|
|
$response = static::getRemote('releases/download/'.$new_version.'.zip', self::REQUEST_OPTIONS);
|
|
|
|
if ($response instanceof RequestException) {
|
|
return [
|
|
'success' => false,
|
|
'error' => 'Download Exception',
|
|
'data' => [
|
|
'path' => null,
|
|
],
|
|
];
|
|
}
|
|
|
|
$archive = null;
|
|
|
|
if ($response && ($response->getStatusCode() == 200)) {
|
|
$archive = $response->getBody()->getContents();
|
|
}
|
|
|
|
$target = static::makeTempDirectory('temp-').'/upload.zip';
|
|
|
|
if (! is_int(file_put_contents($target, $archive))) {
|
|
return false;
|
|
}
|
|
|
|
return $target;
|
|
}
|
|
|
|
/**
|
|
* Expand the archive into a second temporary directory and drop the zip.
|
|
*
|
|
* @return string the directory holding the extracted release
|
|
*
|
|
* @throws \Exception when the archive is gone
|
|
*/
|
|
public static function unzip($zip_file_path)
|
|
{
|
|
if (! file_exists($zip_file_path)) {
|
|
throw new \Exception('Zip file not found');
|
|
}
|
|
|
|
$destination = static::makeTempDirectory('temp2-');
|
|
|
|
$archive = new ZipArchive;
|
|
|
|
if ($archive->open($zip_file_path)) {
|
|
$archive->extractTo($destination);
|
|
}
|
|
|
|
$archive->close();
|
|
|
|
File::delete($zip_file_path);
|
|
|
|
return $destination;
|
|
}
|
|
|
|
/**
|
|
* Overlay the extracted release on top of this installation.
|
|
*/
|
|
public static function copyFiles($temp_extract_dir)
|
|
{
|
|
if (! File::copyDirectory($temp_extract_dir.'/InvoiceShelf', base_path())) {
|
|
return false;
|
|
}
|
|
|
|
File::deleteDirectory($temp_extract_dir);
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Legacy clean-up: remove exactly the paths the release server listed.
|
|
*
|
|
* @param string $json JSON array of installation-relative paths
|
|
*/
|
|
public static function deleteFiles($json)
|
|
{
|
|
foreach (json_decode($json) as $relative) {
|
|
File::delete(base_path($relative));
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Manifest clean-up: drop everything the shipped manifest does not list.
|
|
*
|
|
* Protected prefixes (local state, dependencies, VCS data, mobile apps)
|
|
* are never touched. Directories left empty by the sweep are removed in a
|
|
* second walk, so a pruned subtree disappears entirely.
|
|
*/
|
|
public static function cleanStaleFiles(): array
|
|
{
|
|
$manifestPath = base_path('manifest.json');
|
|
|
|
if (! File::exists($manifestPath)) {
|
|
return ['success' => true, 'cleaned' => 0];
|
|
}
|
|
|
|
$manifest = json_decode(File::get($manifestPath), true);
|
|
|
|
if (! is_array($manifest)) {
|
|
return ['success' => false, 'error' => 'Invalid manifest'];
|
|
}
|
|
|
|
$shipped = array_flip($manifest);
|
|
$keep = config('invoiceshelf.update_protected_paths', []);
|
|
$cleaned = 0;
|
|
|
|
foreach (static::walkInstallation() as $entry) {
|
|
$relative = static::relativeToInstallation($entry->getPathname());
|
|
|
|
if (static::isKeptPath($relative, $keep)) {
|
|
continue;
|
|
}
|
|
|
|
if ($entry->isFile() && ! isset($shipped[$relative])) {
|
|
File::delete($entry->getPathname());
|
|
$cleaned++;
|
|
}
|
|
}
|
|
|
|
foreach (static::walkInstallation() as $entry) {
|
|
if (! $entry->isDir()) {
|
|
continue;
|
|
}
|
|
|
|
$relative = static::relativeToInstallation($entry->getPathname());
|
|
|
|
if (static::isKeptPath($relative, $keep)) {
|
|
continue;
|
|
}
|
|
|
|
if (static::hasNoEntries($entry->getPathname())) {
|
|
@rmdir($entry->getPathname());
|
|
}
|
|
}
|
|
|
|
return ['success' => true, 'cleaned' => $cleaned];
|
|
}
|
|
|
|
/**
|
|
* Bring the schema up to date with the freshly copied code.
|
|
*
|
|
* The base-schema consolidation refuses databases whose 2.x history is
|
|
* incomplete, or whose history and schema disagree. Asking it for that
|
|
* verdict first turns such a database into a refusal carrying the
|
|
* consolidation's own explanation, instead of an upgrade that copies the
|
|
* new release over the installation and only then stops on a failed
|
|
* migration.
|
|
*
|
|
* @throws \RuntimeException when the consolidation would refuse this database
|
|
*/
|
|
public static function migrateUpdate()
|
|
{
|
|
$verdict = SchemaConsolidationGuard::preflight();
|
|
|
|
if ($verdict?->isAbort()) {
|
|
$verdict->fail();
|
|
}
|
|
|
|
Artisan::call('migrate --force');
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Record the new version and announce the finished update.
|
|
*/
|
|
public static function finishUpdate($installed, $version)
|
|
{
|
|
Setting::setSetting('version', $version);
|
|
|
|
event(new UpdateFinished($installed, $version));
|
|
|
|
return [
|
|
'success' => true,
|
|
'error' => false,
|
|
'data' => [],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Turn the release's requirement list into a name => satisfied map, with a
|
|
* synthetic entry for the minimum interpreter version.
|
|
*/
|
|
private static function gradeRequirements($required, $minimumPhpVersion): array
|
|
{
|
|
$graded = [];
|
|
|
|
foreach ($required as $extension) {
|
|
$graded[$extension] = phpversion($extension) !== false;
|
|
}
|
|
|
|
$graded[sprintf('php(%s)', $minimumPhpVersion)] = version_compare(phpversion(), $minimumPhpVersion, '>=');
|
|
|
|
return $graded;
|
|
}
|
|
|
|
/**
|
|
* Create an empty, randomly named working directory in private storage.
|
|
*/
|
|
private static function makeTempDirectory(string $prefix): string
|
|
{
|
|
$directory = storage_path('app/'.$prefix.md5(mt_rand()));
|
|
|
|
if (! File::isDirectory($directory)) {
|
|
File::makeDirectory($directory);
|
|
}
|
|
|
|
return $directory;
|
|
}
|
|
|
|
/**
|
|
* Depth-first walk over the whole installation, children before parents.
|
|
*/
|
|
private static function walkInstallation(): \RecursiveIteratorIterator
|
|
{
|
|
return new \RecursiveIteratorIterator(
|
|
new \RecursiveDirectoryIterator(base_path(), \RecursiveDirectoryIterator::SKIP_DOTS),
|
|
\RecursiveIteratorIterator::CHILD_FIRST
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Does this directory hold nothing at all (dot entries aside)?
|
|
*/
|
|
private static function hasNoEntries(string $directory): bool
|
|
{
|
|
return ! (new \FilesystemIterator($directory))->valid();
|
|
}
|
|
|
|
/**
|
|
* Strip the installation root from an absolute path.
|
|
*/
|
|
private static function relativeToInstallation(string $absolutePath): string
|
|
{
|
|
return substr($absolutePath, strlen(base_path()) + 1);
|
|
}
|
|
|
|
/**
|
|
* Is this path itself protected, or does it live under a protected one?
|
|
*/
|
|
private static function isKeptPath(string $relativePath, array $protectedPaths): bool
|
|
{
|
|
foreach ($protectedPaths as $protected) {
|
|
if ($relativePath === $protected || str_starts_with($relativePath, $protected.'/')) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
}
|