# OTA Client Implementation Guide

Panduan lengkap untuk mengimplementasikan OTA (Over-The-Air) patch update di project client Laravel.

---

## Prasyarat

- Project Laravel 10+ / 11+ / 12+
- PHP 8.1+
- `ext-zip` enabled
- Akses internet ke OTA Server (`https://chaospay.pro`)
- Credentials dari admin: `OTA_AGENT_CODE` dan `OTA_LICENSE_KEY`

---

## 1. Environment Configuration

Tambahkan ke `.env` client:

```env
# OTA Configuration
OTA_SERVER_URL=https://apis.chaospay.pro
OTA_AGENT_CODE=YOUR_AGENT_CODE
OTA_LICENSE_KEY=YOUR_LICENSE_KEY_FROM_ADMIN
```

---

## 2. Config File

Buat `config/ota.php`:

```php
<?php

return [
    'server_url' => env('OTA_SERVER_URL', ''),
    'agent_code' => env('OTA_AGENT_CODE', ''),
    'license_key' => env('OTA_LICENSE_KEY', ''),
];
```

---

## 3. Version File

Buat file `.version` di root project:

```
1.0.0
```

File ini akan di-update otomatis setiap kali patch berhasil diterapkan.

---

## 4. OTA Service

Buat `app/Services/OtaPatchService.php`:

```php
<?php

namespace App\Services;

use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;

class OtaPatchService
{
    /**
     * Get current application version.
     */
    public function getCurrentVersion(): string
    {
        $versionFile = base_path('.version');

        if (File::exists($versionFile)) {
            return trim(File::get($versionFile));
        }

        return '1.0.0';
    }

    /**
     * Check for available updates from OTA server.
     */
    public function checkForUpdates(): array
    {
        $serverUrl = config('ota.server_url');
        $agentCode = config('ota.agent_code');
        $licenseKey = config('ota.license_key');

        if (! $serverUrl || ! $agentCode || ! $licenseKey) {
            throw new \RuntimeException(
                'OTA not configured. Set OTA_SERVER_URL, OTA_AGENT_CODE, OTA_LICENSE_KEY in .env'
            );
        }

        $response = Http::timeout(30)->post(
            rtrim($serverUrl, '/') . '/ota/check',
            [
                'agent_code' => $agentCode,
                'license_key' => $licenseKey,
                'current_version' => $this->getCurrentVersion(),
            ]
        );

        if ($response->status() === 401) {
            throw new \RuntimeException('OTA: Invalid credentials.');
        }

        if ($response->status() === 403) {
            $data = $response->json();
            throw new \RuntimeException(
                'OTA: ' . ($data['server_message'] ?? 'Client disabled/suspended.')
            );
        }

        if (! $response->successful()) {
            throw new \RuntimeException('OTA: Server error HTTP ' . $response->status());
        }

        return $response->json();
    }

    /**
     * Download and apply a patch.
     */
    public function applyPatch(array $patchInfo): array
    {
        $version = $patchInfo['version'];
        $downloadUrl = $patchInfo['download_url'];
        $expectedChecksum = $patchInfo['checksum'] ?? null;
        $hasMigrations = $patchInfo['has_migrations'] ?? false;
        $files = $patchInfo['files'] ?? [];

        try {
            // Step 1: Download
            $zipPath = $this->downloadPatch($downloadUrl, $version);

            // Step 2: Verify checksum
            if ($expectedChecksum) {
                $actual = hash_file('sha256', $zipPath);
                if ($actual !== $expectedChecksum) {
                    throw new \RuntimeException(
                        "Checksum mismatch. Expected: {$expectedChecksum}, Got: {$actual}"
                    );
                }
            }

            // Step 3: Backup
            $backupPath = $this->backupFiles($files, $version);

            // Step 4: Apply
            $this->extractAndApply($zipPath, $version);

            // Step 5: Migrations
            if ($hasMigrations) {
                Artisan::call('migrate', ['--force' => true]);
            }

            // Step 6: Clear caches
            $this->clearCaches();

            // Step 7: Update version
            File::put(base_path('.version'), $version);

            // Step 8: Cleanup
            File::delete($zipPath);

            // Step 9: Report success
            $this->reportToServer($version, 'success');

            return [
                'success' => true,
                'version' => $version,
                'backup_path' => $backupPath,
            ];

        } catch (\Throwable $e) {
            Log::error('OTA patch failed', [
                'version' => $version,
                'error' => $e->getMessage(),
            ]);

            // Rollback if backup exists
            if (isset($backupPath) && File::exists($backupPath)) {
                $this->rollback($backupPath);
            }

            // Report failure
            $this->reportToServer($version, 'failed', $e->getMessage());

            throw $e;
        }
    }

    /**
     * Rollback from a backup zip.
     */
    public function rollbackFromBackup(string $backupPath): void
    {
        if (! File::exists($backupPath)) {
            throw new \RuntimeException('Backup file not found.');
        }

        $zip = new \ZipArchive();
        if ($zip->open($backupPath) !== true) {
            throw new \RuntimeException('Cannot open backup zip.');
        }

        $zip->extractTo(base_path());
        $zip->close();

        $this->clearCaches();
    }

    /**
     * Send heartbeat to OTA server.
     */
    public function sendHeartbeat(): array
    {
        $response = Http::timeout(15)->post(
            rtrim(config('ota.server_url'), '/') . '/ota/heartbeat',
            [
                'agent_code' => config('ota.agent_code'),
                'license_key' => config('ota.license_key'),
                'current_version' => $this->getCurrentVersion(),
                'client_info' => [
                    'php' => PHP_VERSION,
                    'laravel' => app()->version(),
                    'os' => PHP_OS,
                    'server' => $_SERVER['SERVER_SOFTWARE'] ?? 'cli',
                ],
            ]
        );

        if (! $response->successful()) {
            return ['status' => $response->status(), 'message' => 'Heartbeat failed'];
        }

        return $response->json();
    }

    // ─── Private Helpers ─────────────────────────────────────────────

    private function downloadPatch(string $url, string $version): string
    {
        $dir = storage_path('app/ota-patches');
        File::ensureDirectoryExists($dir);

        $zipPath = "{$dir}/patch-{$version}.zip";

        $response = Http::timeout(120)
            ->withHeaders([
                'X-Agent-Code' => config('ota.agent_code'),
                'X-License-Key' => config('ota.license_key'),
            ])
            ->get($url);

        if (! $response->successful()) {
            throw new \RuntimeException('Download failed: HTTP ' . $response->status());
        }

        File::put($zipPath, $response->body());

        return $zipPath;
    }

    private function backupFiles(array $files, string $version): string
    {
        $backupDir = storage_path("app/ota-backups/{$version}");
        File::ensureDirectoryExists($backupDir);

        $backupZip = "{$backupDir}/backup.zip";
        $zip = new \ZipArchive();

        if ($zip->open($backupZip, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
            throw new \RuntimeException('Cannot create backup zip.');
        }

        foreach ($files as $file) {
            $path = $file['path'] ?? $file;
            $fullPath = base_path($path);

            if (File::exists($fullPath)) {
                $zip->addFile($fullPath, $path);
            }
        }

        $zip->close();

        return $backupZip;
    }

    private function extractAndApply(string $zipPath, string $version): void
    {
        $extractDir = storage_path("app/ota-patches/extracted-{$version}");
        File::ensureDirectoryExists($extractDir);

        $zip = new \ZipArchive();
        if ($zip->open($zipPath) !== true) {
            throw new \RuntimeException('Cannot open patch zip.');
        }

        $zip->extractTo($extractDir);
        $zip->close();

        // Copy files to project root
        $files = File::allFiles($extractDir);
        foreach ($files as $file) {
            $dest = base_path($file->getRelativePathname());
            File::ensureDirectoryExists(dirname($dest));
            File::copy($file->getPathname(), $dest);
        }

        File::deleteDirectory($extractDir);
    }

    private function rollback(string $backupZip): void
    {
        $zip = new \ZipArchive();
        if ($zip->open($backupZip) === true) {
            $zip->extractTo(base_path());
            $zip->close();
        }
    }

    private function clearCaches(): void
    {
        Artisan::call('config:clear');
        Artisan::call('route:clear');
        Artisan::call('view:clear');
        Artisan::call('cache:clear');
    }

    private function reportToServer(
        string $version,
        string $status,
        ?string $error = null
    ): void {
        try {
            Http::timeout(10)->post(
                rtrim(config('ota.server_url'), '/') . '/ota/report',
                [
                    'agent_code' => config('ota.agent_code'),
                    'license_key' => config('ota.license_key'),
                    'version' => $version,
                    'status' => $status,
                    'error' => $error,
                ]
            );
        } catch (\Throwable $e) {
            Log::warning('OTA report failed: ' . $e->getMessage());
        }
    }
}
```

---

## 5. Artisan Commands

### 5a. Check Update Command

Buat `app/Console/Commands/OtaCheckCommand.php`:

```php
<?php

namespace App\Console\Commands;

use App\Services\OtaPatchService;
use Illuminate\Console\Command;

class OtaCheckCommand extends Command
{
    protected $signature = 'ota:check';
    protected $description = 'Check for available OTA updates';

    public function handle(OtaPatchService $service): int
    {
        $this->info("Current version: v{$service->getCurrentVersion()}");
        $this->info('Checking for updates...');

        try {
            $result = $service->checkForUpdates();

            if (! ($result['has_update'] ?? false)) {
                $this->info('✓ You are up to date.');

                if ($result['server_message'] ?? null) {
                    $this->warn("Server message: {$result['server_message']}");
                }

                return self::SUCCESS;
            }

            $this->newLine();
            $this->warn("Update available: v{$result['version']}");
            $this->line("  Changelog: {$result['changelog']}");
            $this->line("  Files: " . count($result['files'] ?? []));
            $this->line("  Size: " . number_format(($result['zip_size'] ?? 0) / 1024, 1) . " KB");
            $this->line("  Migrations: " . ($result['has_migrations'] ? 'Yes' : 'No'));

            if ($result['force_update'] ?? false) {
                $this->error('⚠ This is a FORCED update. System restricted until applied.');
            }

            if ($result['server_message'] ?? null) {
                $this->warn("Server message: {$result['server_message']}");
            }

            return self::SUCCESS;

        } catch (\Throwable $e) {
            $this->error($e->getMessage());
            return self::FAILURE;
        }
    }
}
```

### 5b. Apply Update Command

Buat `app/Console/Commands/OtaUpdateCommand.php`:

```php
<?php

namespace App\Console\Commands;

use App\Services\OtaPatchService;
use Illuminate\Console\Command;

class OtaUpdateCommand extends Command
{
    protected $signature = 'ota:update {--force : Skip confirmation}';
    protected $description = 'Download and apply the latest OTA patch';

    public function handle(OtaPatchService $service): int
    {
        $this->info("Current version: v{$service->getCurrentVersion()}");

        try {
            $result = $service->checkForUpdates();

            if (! ($result['has_update'] ?? false)) {
                $this->info('✓ Already up to date.');
                return self::SUCCESS;
            }

            $version = $result['version'];
            $this->warn("Update available: v{$version}");
            $this->line("  Changelog: {$result['changelog']}");

            if (! $this->option('force')) {
                if (! $this->confirm("Apply update v{$version}?")) {
                    return self::SUCCESS;
                }
            }

            $this->info('Downloading and applying...');
            $patchResult = $service->applyPatch($result);

            $this->newLine();
            $this->info("✓ Successfully updated to v{$patchResult['version']}");
            $this->line("  Backup: {$patchResult['backup_path']}");

            return self::SUCCESS;

        } catch (\Throwable $e) {
            $this->error("Update failed: {$e->getMessage()}");
            $this->warn('Rollback was attempted automatically.');
            return self::FAILURE;
        }
    }
}
```

### 5c. Heartbeat Command

Buat `app/Console/Commands/OtaHeartbeatCommand.php`:

```php
<?php

namespace App\Console\Commands;

use App\Services\OtaPatchService;
use Illuminate\Console\Command;

class OtaHeartbeatCommand extends Command
{
    protected $signature = 'ota:heartbeat';
    protected $description = 'Send heartbeat to OTA server';

    public function handle(OtaPatchService $service): int
    {
        try {
            $result = $service->sendHeartbeat();

            $directives = $result['directives'] ?? [];

            if ($directives['disabled'] ?? false) {
                $this->error('WARNING: This client has been DISABLED by the server.');
            }

            if ($directives['force_update'] ?? false) {
                $this->warn("FORCE UPDATE required to v{$directives['force_update_version']}");
            }

            if ($directives['server_message'] ?? null) {
                $this->line("Server: {$directives['server_message']}");
            }

            return self::SUCCESS;

        } catch (\Throwable $e) {
            // Heartbeat failure is non-critical
            return self::SUCCESS;
        }
    }
}
```

---

## 6. Scheduler (Heartbeat Otomatis)

Tambahkan di `routes/console.php` atau `app/Console/Kernel.php`:

```php
// routes/console.php (Laravel 11+)
use Illuminate\Support\Facades\Schedule;

Schedule::command('ota:heartbeat')->everyFiveMinutes();
```

Pastikan cron job aktif di server:
```bash
* * * * * cd /path-to-project && php artisan schedule:run >> /dev/null 2>&1
```

---

## 7. Admin UI (Optional)

Jika ingin tampilkan status OTA di dashboard admin client, buat controller sederhana:

```php
<?php

namespace App\Http\Controllers\Web;

use App\Http\Controllers\Controller;
use App\Services\OtaPatchService;
use Illuminate\Http\RedirectResponse;
use Illuminate\View\View;

class OtaController extends Controller
{
    public function index(OtaPatchService $service): View
    {
        $currentVersion = $service->getCurrentVersion();

        return view('admin.ota', compact('currentVersion'));
    }

    public function check(OtaPatchService $service): RedirectResponse
    {
        try {
            $result = $service->checkForUpdates();

            if (! ($result['has_update'] ?? false)) {
                return back()->with('status', 'You are on the latest version.');
            }

            session(['ota_update' => $result]);
            return back()->with('ota_update', $result);

        } catch (\Throwable $e) {
            return back()->with('error', $e->getMessage());
        }
    }

    public function apply(OtaPatchService $service): RedirectResponse
    {
        $update = session('ota_update');

        if (! $update) {
            return back()->with('error', 'No pending update. Check first.');
        }

        try {
            $service->applyPatch($update);
            session()->forget('ota_update');
            return back()->with('status', "Updated to v{$update['version']} successfully!");
        } catch (\Throwable $e) {
            session()->forget('ota_update');
            return back()->with('error', "Update failed: {$e->getMessage()}");
        }
    }
}
```

---

## 8. API Reference

### POST `/ota/check`

**Request Body:**
```json
{
    "agent_code": "YOUR_AGENT_CODE",
    "license_key": "YOUR_LICENSE_KEY",
    "current_version": "1.0.0"
}
```

**Response — Up to date:**
```json
{
    "status": 200,
    "message": "UP_TO_DATE",
    "has_update": false,
    "server_message": null
}
```

**Response — Update available:**
```json
{
    "status": 200,
    "message": "UPDATE_AVAILABLE",
    "has_update": true,
    "force_update": false,
    "version": "1.1.0",
    "min_version": "1.0.0",
    "changelog": "Fix member search & date filter",
    "files": [
        {"action": "update", "path": "app/Http/Controllers/Web/MemberController.php"},
        {"action": "create", "path": "app/Services/NewService.php"},
        {"action": "delete", "path": "app/Old/Deprecated.php"}
    ],
    "has_migrations": false,
    "checksum": "sha256hash...",
    "zip_size": 8750,
    "download_url": "https://apis.chaospay.pro/ota/download/1.1.0",
    "server_message": null,
    "published_at": "2026-05-29T10:00:00+00:00"
}
```

**Response — Force update:**
```json
{
    "status": 200,
    "message": "FORCE_UPDATE_REQUIRED",
    "has_update": true,
    "force_update": true,
    "version": "1.2.0",
    "server_message": "Critical security patch. System restricted until updated.",
    ...
}
```

**Response — Client disabled (HTTP 403):**
```json
{
    "status": 403,
    "message": "CLIENT_DISABLED",
    "has_update": false,
    "disabled": true,
    "server_message": "Your license has been disabled. Contact support."
}
```

**Response — Client suspended:**
```json
{
    "status": 200,
    "message": "CLIENT_SUSPENDED",
    "has_update": false,
    "suspended": true,
    "server_message": "Updates paused temporarily."
}
```

---

### GET `/ota/download/{version}`

**Headers (required):**
```
X-Agent-Code: YOUR_AGENT_CODE
X-License-Key: YOUR_LICENSE_KEY
```

**Response:** Binary ZIP file download.

**Response Header:**
```
X-Checksum: sha256hash...
```

---

### POST `/ota/report`

**Request Body:**
```json
{
    "agent_code": "YOUR_AGENT_CODE",
    "license_key": "YOUR_LICENSE_KEY",
    "version": "1.1.0",
    "status": "success",
    "error": null
}
```

`status` harus salah satu: `"success"` atau `"failed"`.
`error` hanya diisi jika status = `"failed"`.

**Response:**
```json
{
    "status": 200,
    "message": "REPORT_RECEIVED"
}
```

---

### POST `/ota/heartbeat`

**Request Body:**
```json
{
    "agent_code": "YOUR_AGENT_CODE",
    "license_key": "YOUR_LICENSE_KEY",
    "current_version": "1.0.0",
    "client_info": {
        "php": "8.2.20",
        "laravel": "12.0.1",
        "os": "Linux",
        "server": "nginx/1.24"
    }
}
```

**Response:**
```json
{
    "status": 200,
    "message": "OK",
    "directives": {
        "disabled": false,
        "force_update": false,
        "force_update_version": null,
        "server_message": null
    }
}
```

`directives` memberi tahu client apa yang harus dilakukan:
- `disabled: true` → Tampilkan maintenance mode
- `force_update: true` → Block operasi sampai update diterapkan
- `server_message` → Tampilkan notifikasi di dashboard

---

## 9. Flowchart

```
┌─────────────────────────────────────────────────┐
│                   CLIENT                         │
├─────────────────────────────────────────────────┤
│                                                 │
│  [Cron: setiap 5 menit]                        │
│       │                                         │
│       ▼                                         │
│  php artisan ota:heartbeat                      │
│       │                                         │
│       ├── directives.disabled = true?           │
│       │       └── Tampilkan maintenance         │
│       │                                         │
│       ├── directives.force_update = true?       │
│       │       └── Auto-trigger ota:update       │
│       │                                         │
│       └── OK, lanjut normal                     │
│                                                 │
│                                                 │
│  [Manual: admin klik "Check Update"]            │
│       │                                         │
│       ▼                                         │
│  php artisan ota:check                          │
│       │                                         │
│       ├── has_update = false → "Up to date"     │
│       │                                         │
│       └── has_update = true                     │
│               │                                 │
│               ▼                                 │
│       php artisan ota:update                    │
│               │                                 │
│               ├── Download ZIP                  │
│               ├── Verify checksum               │
│               ├── Backup existing files         │
│               ├── Extract & overwrite           │
│               ├── Run migrations (if any)       │
│               ├── Clear caches                  │
│               ├── Update .version               │
│               ├── Report success to server      │
│               │                                 │
│               └── If FAILED:                    │
│                       ├── Rollback from backup  │
│                       └── Report failure        │
│                                                 │
└─────────────────────────────────────────────────┘
```

---

## 10. File Structure (Client)

```
project/
├── .version                          ← Versi saat ini (e.g. "1.0.0")
├── config/ota.php                    ← Config OTA
├── app/
│   ├── Services/
│   │   └── OtaPatchService.php       ← Core service
│   └── Console/Commands/
│       ├── OtaCheckCommand.php       ← php artisan ota:check
│       ├── OtaUpdateCommand.php      ← php artisan ota:update
│       └── OtaHeartbeatCommand.php   ← php artisan ota:heartbeat
├── storage/app/
│   ├── ota-patches/                  ← Downloaded zips (temp)
│   └── ota-backups/                  ← Backup sebelum patch
│       └── 1.1.0/backup.zip
└── routes/console.php                ← Schedule heartbeat
```

---

## 11. Perintah CLI

| Command | Fungsi |
|---------|--------|
| `php artisan ota:check` | Cek apakah ada update |
| `php artisan ota:update` | Download & apply update |
| `php artisan ota:update --force` | Apply tanpa konfirmasi |
| `php artisan ota:heartbeat` | Kirim heartbeat ke server |

---

## 12. Security Notes

- `license_key` jangan pernah di-commit ke git. Gunakan `.env`.
- Download selalu verify SHA-256 checksum sebelum extract.
- Backup otomatis sebelum apply — rollback jika gagal.
- Server bisa kill-switch client kapan saja via `disabled` directive.
- Heartbeat setiap 5 menit memastikan server tahu client masih aktif.

---

## 13. Testing

Test dari terminal client:

```bash
# Check update
php artisan ota:check

# Apply update
php artisan ota:update

# Send heartbeat manually
php artisan ota:heartbeat

# Test API langsung
curl -X POST https://apis.chaospay.pro/ota/check \
  -H "Content-Type: application/json" \
  -d '{"agent_code":"YOUR_CODE","license_key":"YOUR_KEY","current_version":"1.0.0"}'
```
