HEX
Server: LiteSpeed
System: Linux s1049.use1.mysecurecloudhost.com 4.18.0-477.27.2.lve.el8.x86_64 #1 SMP Wed Oct 11 12:32:56 UTC 2023 x86_64
User: xedaptot (3356)
PHP: 8.3.31
Disabled: NONE
Upload Files
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);
        }
    }
}