File: /home/xedaptot/ai.naniguide.com/app/Http/Controllers/Api/UpgradeController.php
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Library\UpgradeManager;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Artisan;
/**
* Remote core upgrade via admin api_token.
*
* Split into TWO separate endpoints — MUST be called as two separate HTTP requests:
*
* Step 1: POST /api/v1/upgrade/run
* Download patch → load → test → replace files on disk.
* Process ends here. VERSION file now points to new version, but THIS
* PHP worker still holds stale opcache / loaded classes from the OLD code.
*
* Step 2: POST /api/v1/upgrade/finalize
* Called as a FRESH HTTP request → fresh PHP worker bootstraps from the
* newly-written files → safely runs `artisan migrate --force` +
* `artisan upgrade:finalize` against the new code.
*
* Why split: you cannot reliably run migrate/finalize inline in the same process
* that just replaced the PHP files — the parent process's class loader is stale.
* Web UI flow works around this by using meta-refresh hops (each hop = new HTTP
* request = fresh worker). The API replicates that boundary explicitly.
*
* Auth: admin api_token + setting_upgrade_manager permission == 'yes'.
* Concurrency: file lock at storage/tmp/upgrade.lock (non-blocking, returns 409).
*/
class UpgradeController extends Controller
{
/**
* Step 1 — Download + replace files on disk.
* After this returns 200, the caller MUST call POST /api/v1/upgrade/finalize
* as a separate HTTP request to complete the upgrade (migrate + finalize).
*/
public function run(Request $request)
{
if (($guard = $this->authorize($request)) !== true) {
return $guard;
}
$url = $request->input('url');
if (empty($url) || !filter_var($url, FILTER_VALIDATE_URL)) {
return response()->json([
'status' => 'error',
'message' => "'url' parameter is required and must be a valid URL",
], 422);
}
$scheme = parse_url($url, PHP_URL_SCHEME);
if (!in_array($scheme, ['http', 'https'], true)) {
return response()->json([
'status' => 'error',
'message' => 'URL must use http or https scheme',
], 422);
}
$timeout = (int)$request->input('timeout', 600);
if ($timeout < 60 || $timeout > 1800) {
return response()->json([
'status' => 'error',
'message' => 'timeout out of range (60-1800 seconds)',
], 422);
}
$dryRun = filter_var($request->input('dry_run', false), FILTER_VALIDATE_BOOLEAN);
$lock = $this->acquireLock();
if (!is_resource($lock)) {
return $lock;
}
try {
@set_time_limit(max($timeout, 600));
$mgr = new UpgradeManager();
$summary = $mgr->applyPatchFromUrl($url, [
'timeout' => $timeout,
'user_agent' => 'acelle-upgrade-api',
'dry_run' => $dryRun,
]);
if (empty($summary['dry_run'])) {
$summary['next_step'] = 'POST /api/v1/upgrade/finalize';
}
return response()->json(['status' => 'success'] + $summary);
} catch (\Throwable $e) {
return response()->json([
'status' => 'error',
'message' => $e->getMessage(),
'at' => basename($e->getFile()) . ':' . $e->getLine(),
], 400);
} finally {
$this->releaseLock($lock);
}
}
/**
* Step 1 alternative — Apply a patch from a multipart-uploaded file.
*
* Same boundary as run(): replaces files on disk; caller MUST then call
* POST /api/v1/upgrade/finalize as a separate HTTP request.
*
* Use case: customer's outbound network can't reach the patch server, OR
* a one-off custom patch needs to be applied. PHP `upload_max_filesize` /
* `post_max_size` MUST be ≥ patch size (Acelle full zip ≈ 270 MB);
* otherwise the request never reaches Laravel.
*
* Multipart form fields:
* patch — the .zip / .bin patch file (required)
* dry_run — "true" to preview without applying (optional, default false)
*/
public function runFile(Request $request)
{
if (($guard = $this->authorize($request)) !== true) {
return $guard;
}
$file = $request->file('patch');
if (!$file || !$file->isValid()) {
return response()->json([
'status' => 'error',
'message' => "'patch' multipart file field is required and must be a valid upload",
], 422);
}
$dryRun = filter_var($request->input('dry_run', false), FILTER_VALIDATE_BOOLEAN);
// Move uploaded file to storage/tmp BEFORE acquiring the lock so a slow disk
// write doesn't hold the lock unnecessarily.
$tmpDir = storage_path('tmp');
if (!is_dir($tmpDir)) {
@mkdir($tmpDir, 0755, true);
}
$tmpName = 'api-upload-' . uniqid() . '.zip';
try {
$file->move($tmpDir, $tmpName);
} catch (\Throwable $e) {
return response()->json([
'status' => 'error',
'message' => 'Cannot stage upload: ' . $e->getMessage(),
], 500);
}
$tmpPath = $tmpDir . '/' . $tmpName;
$lock = $this->acquireLock();
if (!is_resource($lock)) {
@unlink($tmpPath);
return $lock;
}
try {
@set_time_limit(900);
$mgr = new UpgradeManager();
$summary = $mgr->applyPatchFromFile($tmpPath, ['dry_run' => $dryRun]);
if (empty($summary['dry_run'])) {
$summary['next_step'] = 'POST /api/v1/upgrade/finalize';
}
return response()->json(['status' => 'success'] + $summary);
} catch (\Throwable $e) {
return response()->json([
'status' => 'error',
'message' => $e->getMessage(),
'at' => basename($e->getFile()) . ':' . $e->getLine(),
], 400);
} finally {
if (is_file($tmpPath)) {
@unlink($tmpPath);
}
$this->releaseLock($lock);
}
}
/**
* Step 2 — Run `artisan migrate --force` + `artisan upgrade:finalize`.
*
* MUST be a separate HTTP request from /upgrade/run so this worker loads the
* newly-replaced PHP files. Calling this in the same process as /upgrade/run
* would execute stale code from opcache and produce incorrect results.
*/
public function finalize(Request $request)
{
if (($guard = $this->authorize($request)) !== true) {
return $guard;
}
$lock = $this->acquireLock();
if (!is_resource($lock)) {
return $lock;
}
$start = microtime(true);
try {
@set_time_limit(0);
Artisan::call('migrate', ['--force' => true]);
$migrateOut = Artisan::output();
preg_match_all('/Migrated:\s+(\S+)/', $migrateOut, $m);
$migrationsRan = $m[1] ?? [];
@set_time_limit(0);
$mgr = new UpgradeManager();
$result = $mgr->finalize();
$result['migrations_ran'] = $migrationsRan;
$result['migrate_output_tail'] = substr($migrateOut, -2048);
$result['total_duration_ms'] = (int)((microtime(true) - $start) * 1000);
return response()->json(['status' => 'success'] + $result);
} catch (\Throwable $e) {
return response()->json([
'status' => 'error',
'message' => $e->getMessage(),
'at' => basename($e->getFile()) . ':' . $e->getLine(),
], 500);
} finally {
$this->releaseLock($lock);
}
}
/**
* @return true|\Illuminate\Http\JsonResponse true if authorized, JsonResponse(401) otherwise.
*/
private function authorize(Request $request)
{
$user = \Auth::guard('api')->user();
if (!$user || !$user->admin
|| $user->admin->getPermission('setting_upgrade_manager') !== 'yes') {
return response()->json(['status' => 'error', 'message' => 'Unauthorized'], 401);
}
return true;
}
/**
* @return resource|\Illuminate\Http\JsonResponse fopen handle with LOCK_EX held,
* or 409/500 JsonResponse on failure.
*/
private function acquireLock()
{
$tmpDir = storage_path('tmp');
if (!is_dir($tmpDir)) {
@mkdir($tmpDir, 0755, true);
}
$lockPath = $tmpDir . '/upgrade.lock';
$lock = fopen($lockPath, 'c');
if ($lock === false) {
return response()->json([
'status' => 'error',
'message' => 'Cannot acquire upgrade lock (storage/tmp not writable)',
], 500);
}
if (!flock($lock, LOCK_EX | LOCK_NB)) {
fclose($lock);
return response()->json([
'status' => 'error',
'message' => 'Another upgrade is already running',
], 409);
}
return $lock;
}
/** @param resource $lock */
private function releaseLock($lock): void
{
if (is_resource($lock)) {
flock($lock, LOCK_UN);
fclose($lock);
}
}
}