diff --git a/app/Console/Commands/UpdateCommand.php b/app/Console/Commands/UpdateCommand.php index 13e897a7..bbe0e6e7 100644 --- a/app/Console/Commands/UpdateCommand.php +++ b/app/Console/Commands/UpdateCommand.php @@ -46,6 +46,12 @@ class UpdateCommand extends Command { set_time_limit(3600); // 1 hour + if (config('invoiceshelf.containerized')) { + $this->error('The in-app updater is disabled in containerized installs. Upgrade with `docker compose pull`.'); + + return; + } + $this->installed = $this->getInstalledVersion(); $this->response = $this->getLatestVersionResponse(); $this->version = ($this->response) ? $this->response->version : false; diff --git a/app/Http/Controllers/AppVersionController.php b/app/Http/Controllers/AppVersionController.php index 66b60a24..02e4d511 100644 --- a/app/Http/Controllers/AppVersionController.php +++ b/app/Http/Controllers/AppVersionController.php @@ -27,6 +27,7 @@ class AppVersionController extends Controller return response()->json([ 'version' => $version, 'channel' => $channel, + 'containerized' => (bool) config('invoiceshelf.containerized'), ]); } } diff --git a/app/Http/Middleware/EnsureNotContainerized.php b/app/Http/Middleware/EnsureNotContainerized.php new file mode 100644 index 00000000..c3fe2260 --- /dev/null +++ b/app/Http/Middleware/EnsureNotContainerized.php @@ -0,0 +1,28 @@ + CustomerPortalMiddleware::class, 'guest' => RedirectIfAuthenticated::class, 'install' => InstallationMiddleware::class, + 'not-containerized' => EnsureNotContainerized::class, 'pdf-auth' => PdfMiddleware::class, 'redirect-if-installed' => RedirectIfInstalled::class, 'redirect-if-unauthenticated' => RedirectIfUnauthorized::class, diff --git a/config/invoiceshelf.php b/config/invoiceshelf.php index 944c06d4..7ec9fcdc 100644 --- a/config/invoiceshelf.php +++ b/config/invoiceshelf.php @@ -53,6 +53,15 @@ return [ */ 'base_url' => env('INVOICESHELF_BASE_URL', 'https://invoiceshelf.com'), + /* + * Whether the app runs inside the official Docker image. The image's + * docker/production/inject.sh sets CONTAINERIZED=true in .env at startup. + * When true, the in-app updater is disabled (the API refuses and the UI hides + * it) because containers upgrade via `docker compose pull`, not by copying + * release files over the read-only/ephemeral image filesystem. + */ + 'containerized' => env('CONTAINERIZED', false), + /* * Paths protected from cleanup during updates. * The updater will never delete files under these prefixes. diff --git a/lang/en.json b/lang/en.json index b051ad13..bc858e39 100644 --- a/lang/en.json +++ b/lang/en.json @@ -26,6 +26,7 @@ "copy_pdf_url": "Copy PDF Url", "download_pdf": "Download PDF", "save": "Save", + "skip": "Skip", "create": "Create", "cancel": "Cancel", "accept": "Accept", @@ -1483,7 +1484,9 @@ "update_failed": "Update Failed", "update_failed_text": "Sorry! Your update failed on : {step} step", "update_warning": "All of the application files and default template files will be overwritten when you update the application using this utility. Please take a backup of your templates & database before updating.", - "requirements_not_met": "Update cannot continue because some system requirements are not met (including minimum PHP version). Please fix the failed requirements and try again." + "requirements_not_met": "Update cannot continue because some system requirements are not met (including minimum PHP version). Please fix the failed requirements and try again.", + "containerized_title": "Running in Docker", + "containerized_message": "The in-app updater is disabled for Docker installs. To upgrade, pull the latest image and recreate the container:" }, "backup": { "title": "Backups | Backups", diff --git a/resources/scripts/api/services/setting.service.ts b/resources/scripts/api/services/setting.service.ts index f193aa25..85283a33 100644 --- a/resources/scripts/api/services/setting.service.ts +++ b/resources/scripts/api/services/setting.service.ts @@ -102,7 +102,11 @@ export const settingService = { }, // App Version - async getAppVersion(): Promise<{ version: string; channel: string }> { + async getAppVersion(): Promise<{ + version: string + channel: string + containerized: boolean + }> { const { data } = await client.get(API.APP_VERSION) return data }, diff --git a/resources/scripts/features/admin/views/settings/AdminUpdateAppView.vue b/resources/scripts/features/admin/views/settings/AdminUpdateAppView.vue index fad95c9d..a3f3da8f 100644 --- a/resources/scripts/features/admin/views/settings/AdminUpdateAppView.vue +++ b/resources/scripts/features/admin/views/settings/AdminUpdateAppView.vue @@ -34,6 +34,7 @@ const { t } = useI18n() const isCheckingForUpdate = ref(false) const isUpdating = ref(false) const insiderChannel = ref(false) +const isContainerized = ref(false) const currentVersion = ref('') const updateRelease = ref(null) const isMinorUpdate = ref(false) @@ -103,6 +104,7 @@ async function loadCurrentVersion(): Promise { const response = await settingService.getAppVersion() currentVersion.value = response.version insiderChannel.value = response.channel === 'insider' + isContainerized.value = Boolean(response.containerized) } catch (error: unknown) { showApiError(error) } @@ -316,13 +318,38 @@ function showApiError(error: unknown): void { -
- + +
+
+
+ +
+
+

+ {{ $t('settings.update_app.containerized_title') }} +

+
+

{{ $t('settings.update_app.containerized_message') }}

+
docker compose pull
+docker compose up --force-recreate --build -d
+
+
+
+
diff --git a/routes/api.php b/routes/api.php index dbd742ac..c4a8f02b 100644 --- a/routes/api.php +++ b/routes/api.php @@ -473,15 +473,19 @@ Route::prefix('/v1')->group(function () { // Self Update // ---------------------------------- + // Disabled inside the official Docker image — containers upgrade via + // `docker compose pull`, not the in-app updater (see EnsureNotContainerized). - Route::get('/check/update', [UpdateController::class, 'checkVersion']); - Route::post('/update/download', [UpdateController::class, 'download']); - Route::post('/update/unzip', [UpdateController::class, 'unzip']); - Route::post('/update/copy', [UpdateController::class, 'copy']); - Route::post('/update/delete', [UpdateController::class, 'delete']); - Route::post('/update/clean', [UpdateController::class, 'clean']); - Route::post('/update/migrate', [UpdateController::class, 'migrate']); - Route::post('/update/finish', [UpdateController::class, 'finish']); + Route::middleware('not-containerized')->group(function () { + Route::get('/check/update', [UpdateController::class, 'checkVersion']); + Route::post('/update/download', [UpdateController::class, 'download']); + Route::post('/update/unzip', [UpdateController::class, 'unzip']); + Route::post('/update/copy', [UpdateController::class, 'copy']); + Route::post('/update/delete', [UpdateController::class, 'delete']); + Route::post('/update/clean', [UpdateController::class, 'clean']); + Route::post('/update/migrate', [UpdateController::class, 'migrate']); + Route::post('/update/finish', [UpdateController::class, 'finish']); + }); // Companies // ------------------------------------------------- diff --git a/tests/Feature/Admin/UpdateContainerizedTest.php b/tests/Feature/Admin/UpdateContainerizedTest.php new file mode 100644 index 00000000..864bf25b --- /dev/null +++ b/tests/Feature/Admin/UpdateContainerizedTest.php @@ -0,0 +1,46 @@ + 'DatabaseSeeder', '--force' => true]); + Artisan::call('db:seed', ['--class' => 'DemoSeeder', '--force' => true]); + + $user = User::where('role', 'super admin')->first(); + + $this->withHeaders([ + 'company' => $user->companies()->first()->id, + ]); + + Sanctum::actingAs($user, ['*']); +}); + +it('exposes the containerized flag from the app version endpoint', function () { + config(['invoiceshelf.containerized' => true]); + + getJson('/api/v1/app/version') + ->assertOk() + ->assertJson(['containerized' => true]); + + config(['invoiceshelf.containerized' => false]); + + getJson('/api/v1/app/version') + ->assertOk() + ->assertJson(['containerized' => false]); +}); + +it('blocks the in-app updater endpoints when containerized', function () { + config(['invoiceshelf.containerized' => true]); + + getJson('/api/v1/check/update')->assertForbidden(); + postJson('/api/v1/update/download', ['version' => '2.4.0'])->assertForbidden(); + postJson('/api/v1/update/unzip', ['path' => '/tmp/x.zip'])->assertForbidden(); + postJson('/api/v1/update/copy', ['path' => '/tmp/x'])->assertForbidden(); + postJson('/api/v1/update/clean')->assertForbidden(); + postJson('/api/v1/update/migrate')->assertForbidden(); + postJson('/api/v1/update/finish', ['installed' => '2.4.0', 'version' => '2.4.1'])->assertForbidden(); +});