From 525f432ee0b79fb96d3515cd3640bd293a56143e Mon Sep 17 00:00:00 2001 From: Darko Gjorgjijoski Date: Mon, 6 Apr 2026 19:27:33 +0200 Subject: [PATCH] Replace deleted_files with manifest-based updater cleanup, add release workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add manifest.json generation script (scripts/generate-manifest.php) - Add Updater::cleanStaleFiles() that removes files not in manifest - Add /api/v1/update/clean endpoint with backward compatibility - Add configurable update_protected_paths in config/invoiceshelf.php - Update frontend to use clean step instead of delete step - Add GitHub Actions release workflow triggered on version tags - Add .github/release.yml for auto-generated changelog categories - Update Makefile to include manifest generation and scripts directory Backport from v3.0 (e6452946). Adapted for master's existing structure: master uses one-controller-per-action under app/Http/Controllers/V1/Admin/Update/, so the new endpoint is implemented as a dedicated CleanFilesController matching the existing DeleteFilesController/CopyFilesController/etc. pattern instead of v3.0's unified UpdateController. The legacy /update/delete route and DeleteFilesController are retained for compatibility — only the frontend (resources/scripts/admin/views/settings/UpdateAppSetting.vue) is updated to call /update/clean. Updater service lives at app/Space/Updater.php on master (not yet refactored to app/Services/Update/Updater.php like v3.0). --- .github/release.yml | 18 ++++ .github/workflows/release.yaml | 96 +++++++++++++++++++ Makefile | 2 + .../V1/Admin/Update/CleanFilesController.php | 42 ++++++++ app/Space/Updater.php | 74 ++++++++++++++ config/invoiceshelf.php | 19 ++++ lang/en.json | 1 + .../admin/views/settings/UpdateAppSetting.vue | 4 +- routes/api.php | 3 + scripts/generate-manifest.php | 61 ++++++++++++ 10 files changed, 318 insertions(+), 2 deletions(-) create mode 100644 .github/release.yml create mode 100644 .github/workflows/release.yaml create mode 100644 app/Http/Controllers/V1/Admin/Update/CleanFilesController.php create mode 100644 scripts/generate-manifest.php diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 00000000..0f3f6b47 --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,18 @@ +changelog: + categories: + - title: New Features + labels: + - enhancement + - feature + - title: Bug Fixes + labels: + - bug + - fix + - title: Maintenance + labels: + - chore + - dependencies + - ci + - title: Other Changes + labels: + - "*" diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 00000000..b9f359f2 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,96 @@ +name: Release + +on: + push: + tags: + - "v*" + +permissions: + contents: write + +jobs: + release: + name: Build & Release + runs-on: ubuntu-latest + env: + extensions: bcmath, curl, dom, gd, imagick, json, libxml, mbstring, pcntl, pdo, pdo_mysql, zip + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 8.4 + extensions: ${{ env.extensions }} + tools: composer + + - name: Install Composer dependencies + run: composer install --no-dev --optimize-autoloader --no-interaction + + - name: Use Node.js 24 + uses: actions/setup-node@v4 + with: + node-version: 24 + + - name: Install npm dependencies + run: npm ci + + - name: Build frontend + run: npm run build + + - name: Prepare release directory + run: | + mkdir -p /tmp/InvoiceShelf/public + + cp -r app /tmp/InvoiceShelf/ + cp -r bootstrap /tmp/InvoiceShelf/ + cp -r config /tmp/InvoiceShelf/ + cp -r database /tmp/InvoiceShelf/ + cp -r public/build /tmp/InvoiceShelf/public/ + cp -r public/favicons /tmp/InvoiceShelf/public/ + cp public/.htaccess /tmp/InvoiceShelf/public/ + cp public/index.php /tmp/InvoiceShelf/public/ + cp public/robots.txt /tmp/InvoiceShelf/public/ + cp public/web.config /tmp/InvoiceShelf/public/ + cp -r resources /tmp/InvoiceShelf/ + cp -r lang /tmp/InvoiceShelf/ + cp -r routes /tmp/InvoiceShelf/ + cp -r storage /tmp/InvoiceShelf/ + cp -r vendor /tmp/InvoiceShelf/ 2>/dev/null || true + cp -r scripts /tmp/InvoiceShelf/ + cp version.md /tmp/InvoiceShelf/ + cp .env.example /tmp/InvoiceShelf/ + cp artisan /tmp/InvoiceShelf/ + cp composer.json /tmp/InvoiceShelf/ + cp composer.lock /tmp/InvoiceShelf/ + cp LICENSE /tmp/InvoiceShelf/ + cp readme.md /tmp/InvoiceShelf/ + cp SECURITY.md /tmp/InvoiceShelf/ + cp server.php /tmp/InvoiceShelf/ + + # Clean up runtime artifacts + find /tmp/InvoiceShelf -wholename '*/[Tt]ests/*' -delete + find /tmp/InvoiceShelf -wholename '*/[Tt]est/*' -delete + rm -rf /tmp/InvoiceShelf/storage/framework/cache/data/* 2>/dev/null || true + rm -f /tmp/InvoiceShelf/storage/framework/sessions/* 2>/dev/null || true + rm -f /tmp/InvoiceShelf/storage/framework/views/* 2>/dev/null || true + rm -f /tmp/InvoiceShelf/storage/logs/* 2>/dev/null || true + touch /tmp/InvoiceShelf/storage/logs/laravel.log + + - name: Generate manifest + run: php scripts/generate-manifest.php /tmp/InvoiceShelf + + - name: Create zip + working-directory: /tmp + run: zip -r InvoiceShelf.zip InvoiceShelf/ + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + files: /tmp/InvoiceShelf.zip + generate_release_notes: true + make_latest: true diff --git a/Makefile b/Makefile index de45ca6c..04fbe696 100644 --- a/Makefile +++ b/Makefile @@ -29,6 +29,7 @@ dist-gen: clean composer npm-build @cp -r routes InvoiceShelf @cp -r storage InvoiceShelf @cp -r vendor InvoiceShelf 2> /dev/null || true + @cp -r scripts InvoiceShelf @cp -r version.md InvoiceShelf @cp -r .env.example InvoiceShelf @cp -r artisan InvoiceShelf @@ -47,6 +48,7 @@ dist-clean: dist-gen @rm InvoiceShelf/storage/framework/sessions/* 2> /dev/null || true @rm InvoiceShelf/storage/framework/views/* 2> /dev/null || true @rm InvoiceShelf/storage/logs/* 2> /dev/null || true + @php scripts/generate-manifest.php InvoiceShelf dist: dist-clean @zip -r InvoiceShelf.zip InvoiceShelf diff --git a/app/Http/Controllers/V1/Admin/Update/CleanFilesController.php b/app/Http/Controllers/V1/Admin/Update/CleanFilesController.php new file mode 100644 index 00000000..9bdeb1c2 --- /dev/null +++ b/app/Http/Controllers/V1/Admin/Update/CleanFilesController.php @@ -0,0 +1,42 @@ +user()) || (! $request->user()->isOwner())) { + return response()->json([ + 'success' => false, + 'message' => 'You are not allowed to update this app.', + ], 401); + } + + // Backward compatibility: a release package built before the manifest + // was introduced may still ship a deleted_files list. Honour it. + if (isset($request->deleted_files) && ! empty($request->deleted_files)) { + Updater::deleteFiles($request->deleted_files); + } + + $result = Updater::cleanStaleFiles(); + + return response()->json($result); + } +} diff --git a/app/Space/Updater.php b/app/Space/Updater.php index 28f5774b..407c9413 100644 --- a/app/Space/Updater.php +++ b/app/Space/Updater.php @@ -130,6 +130,80 @@ class Updater return true; } + public static function cleanStaleFiles(): array + { + $manifestPath = base_path('manifest.json'); + + if (! File::exists($manifestPath)) { + return ['success' => true, 'cleaned' => 0]; + } + + $manifest = json_decode(File::get($manifestPath), true); + + if (! is_array($manifest)) { + return ['success' => false, 'error' => 'Invalid manifest']; + } + + $manifestLookup = array_flip($manifest); + $protectedPaths = config('invoiceshelf.update_protected_paths', []); + $cleaned = 0; + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator(base_path(), \RecursiveDirectoryIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ($iterator as $file) { + $relativePath = substr($file->getPathname(), strlen(base_path()) + 1); + + if (static::isProtectedPath($relativePath, $protectedPaths)) { + continue; + } + + if ($file->isFile() && ! isset($manifestLookup[$relativePath])) { + File::delete($file->getPathname()); + $cleaned++; + } + } + + // Second pass: remove empty directories + $dirIterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator(base_path(), \RecursiveDirectoryIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ($dirIterator as $item) { + if (! $item->isDir()) { + continue; + } + + $relativePath = substr($item->getPathname(), strlen(base_path()) + 1); + + if (static::isProtectedPath($relativePath, $protectedPaths)) { + continue; + } + + $entries = scandir($item->getPathname()); + + if (count($entries) <= 2) { + @rmdir($item->getPathname()); + } + } + + return ['success' => true, 'cleaned' => $cleaned]; + } + + private static function isProtectedPath(string $relativePath, array $protectedPaths): bool + { + foreach ($protectedPaths as $protected) { + if ($relativePath === $protected || str_starts_with($relativePath, $protected.'/')) { + return true; + } + } + + return false; + } + public static function migrateUpdate() { Artisan::call('migrate --force'); diff --git a/config/invoiceshelf.php b/config/invoiceshelf.php index 0fe83bc2..494cd26a 100644 --- a/config/invoiceshelf.php +++ b/config/invoiceshelf.php @@ -47,6 +47,25 @@ return [ */ 'base_url' => 'https://invoiceshelf.com', + /* + * Paths protected from cleanup during updates. + * The updater will never delete files under these prefixes. + */ + 'update_protected_paths' => [ + '.env', + 'storage', + 'vendor', + 'node_modules', + 'Modules', + 'public/storage', + '.git', + 'bootstrap/cache', + 'manifest.json', + 'android', + 'ios', + 'mobile', + ], + /* * List of languages supported by InvoiceShelf. */ diff --git a/lang/en.json b/lang/en.json index 81e852c1..c744278a 100644 --- a/lang/en.json +++ b/lang/en.json @@ -1323,6 +1323,7 @@ "unzipping_package": "Unzipping Package", "copying_files": "Copying Files", "deleting_files": "Deleting Unused files", + "cleaning_stale_files": "Cleaning stale files", "running_migrations": "Running Migrations", "finishing_update": "Finishing Update", "update_failed": "Update Failed", diff --git a/resources/scripts/admin/views/settings/UpdateAppSetting.vue b/resources/scripts/admin/views/settings/UpdateAppSetting.vue index 2723ae7f..8ee872c0 100644 --- a/resources/scripts/admin/views/settings/UpdateAppSetting.vue +++ b/resources/scripts/admin/views/settings/UpdateAppSetting.vue @@ -263,8 +263,8 @@ const updateSteps = reactive([ completed: false, }, { - translationKey: 'settings.update_app.deleting_files', - stepUrl: '/api/v1/update/delete', + translationKey: 'settings.update_app.cleaning_stale_files', + stepUrl: '/api/v1/update/clean', time: null, started: false, completed: false, diff --git a/routes/api.php b/routes/api.php index da639a33..52ac1024 100644 --- a/routes/api.php +++ b/routes/api.php @@ -82,6 +82,7 @@ use App\Http\Controllers\V1\Admin\Settings\UpdateCompanySettingsController; use App\Http\Controllers\V1\Admin\Settings\UpdateSettingsController; use App\Http\Controllers\V1\Admin\Settings\UpdateUserSettingsController; use App\Http\Controllers\V1\Admin\Update\CheckVersionController; +use App\Http\Controllers\V1\Admin\Update\CleanFilesController; use App\Http\Controllers\V1\Admin\Update\CopyFilesController; use App\Http\Controllers\V1\Admin\Update\DeleteFilesController; use App\Http\Controllers\V1\Admin\Update\DownloadUpdateController; @@ -435,6 +436,8 @@ Route::prefix('/v1')->group(function () { Route::post('/update/delete', DeleteFilesController::class); + Route::post('/update/clean', CleanFilesController::class); + Route::post('/update/migrate', MigrateUpdateController::class); Route::post('/update/finish', FinishUpdateController::class); diff --git a/scripts/generate-manifest.php b/scripts/generate-manifest.php new file mode 100644 index 00000000..ad8c49eb --- /dev/null +++ b/scripts/generate-manifest.php @@ -0,0 +1,61 @@ +isFile()) { + continue; + } + + $relativePath = substr($file->getPathname(), strlen($basePath) + 1); + + foreach ($excludedPrefixes as $prefix) { + if (str_starts_with($relativePath, $prefix)) { + continue 2; + } + } + + $files[] = $relativePath; +} + +sort($files); + +$json = json_encode($files, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + +file_put_contents($basePath.'/manifest.json', $json."\n"); + +$count = count($files); +fwrite(STDOUT, "manifest.json written with {$count} files.\n");