Files
InvoiceShelf/app/Platform/Operations/Http/Admin/UpdateController.php
T
Darko Gjorgjijoski 7db2a8d094 feat(operations): fresh platform-operations implementation
Full rewrite of the installer (incl. the finish step), self-update pipeline,
settings store, cron webhook, dashboard/bootstrap surface, environment
manager, wizard login, config endpoint and shared helpers. Byte-compatible
public interfaces and observable behavior, including documented legacy
quirks; verified by the pilot behavioral suite and the full test suite.
2026-08-20 18:30:12 +02:00

145 lines
4.0 KiB
PHP

<?php
namespace App\Platform\Operations\Http\Admin;
use App\Platform\Http\Controller;
use App\Platform\Operations\Update\Updater;
use Exception;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\File;
/**
* Drives the in-app upgrade one hop at a time.
*
* The browser walks the pipeline itself — check, download, unzip, copy, clean,
* migrate, finish — so no single request has to survive a whole release swap.
* Refusing the pipeline on containerized installs is the route group's job
* ("not-containerized" middleware), not this controller's.
*/
class UpdateController extends Controller
{
/**
* Head room, in seconds, for the release-server round trip.
*/
private const CHECK_TIME_BUDGET = 600;
public function checkVersion(Request $request): JsonResponse
{
$this->authorizeUpdates();
set_time_limit(self::CHECK_TIME_BUDGET);
$channel = $request->get('channel', 'stable');
return response()->json(
Updater::checkForUpdate($this->versionOnDisk(), $channel)
);
}
public function download(Request $request): JsonResponse
{
$this->authorizeUpdates();
$request->validate(['version' => 'required']);
return $this->completed(Updater::download($request->input('version')));
}
public function unzip(Request $request): JsonResponse
{
$this->authorizeUpdates();
$request->validate(['path' => 'required']);
try {
return $this->completed(Updater::unzip($request->input('path')));
} catch (Exception $failure) {
return response()->json([
'success' => false,
'error' => $failure->getMessage(),
], 500);
}
}
public function copy(Request $request): JsonResponse
{
$this->authorizeUpdates();
$request->validate(['path' => 'required']);
// The copy step answers with a boolean, which has always travelled back
// to the client under the "path" key. Left alone on purpose.
return $this->completed(Updater::copyFiles($request->input('path')));
}
public function delete(Request $request): JsonResponse
{
return $this->clean($request);
}
public function clean(Request $request): JsonResponse
{
$this->authorizeUpdates();
$legacyList = $request->input('deleted_files');
// Releases from before the manifest era shipped an explicit removal
// list instead of a manifest; honour it only while no manifest exists.
if (! empty($legacyList) && ! File::exists(base_path('manifest.json'))) {
Updater::deleteFiles($legacyList);
return response()->json(['success' => true, 'cleaned' => 0]);
}
return response()->json(Updater::cleanStaleFiles());
}
public function migrate(Request $request): JsonResponse
{
$this->authorizeUpdates();
Updater::migrateUpdate();
return response()->json(['success' => true]);
}
public function finish(Request $request): JsonResponse
{
$this->authorizeUpdates();
$request->validate([
'installed' => 'required',
'version' => 'required',
]);
return response()->json(
Updater::finishUpdate($request->input('installed'), $request->input('version'))
);
}
/**
* Only the platform administrator may touch any part of the pipeline.
*/
private function authorizeUpdates(): void
{
$this->authorize('manage update app');
}
/**
* The shared "this step worked, here is what it produced" envelope.
*/
private function completed(mixed $outcome): JsonResponse
{
return response()->json([
'success' => true,
'path' => $outcome,
]);
}
private function versionOnDisk(): string
{
return preg_replace('~[\r\n]+~', '', File::get(base_path('version.md')));
}
}