From 0ca7a2cf1e28d4df70ad479d847196ffbcc5bcab Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Mon, 12 Oct 2026 15:24:46 +0100 Subject: [PATCH] move into sonardesk --- ...0_create_sonardesk_distribution_tables.php | 13 ++++++++++++ .../Admin/DistributionAdminController.php | 13 ++++++++++++ .../Controllers/DistributionController.php | 17 +++++++++++++++ Modules/Licensing/Models/DownloadProduct.php | 8 +++++++ Modules/Licensing/Models/SonarDeskRelease.php | 9 ++++++++ .../IntegratedLicensingServiceProvider.php | 1 + .../views/distribution/admin.blade.php | 9 ++++++++ .../views/downloads/appcast.blade.php | 2 ++ .../Resources/views/downloads/index.blade.php | 1 + .../views/downloads/product.blade.php | 1 + .../views/downloads/release.blade.php | 1 + Modules/Licensing/Routes/distribution.php | 19 +++++++++++++++++ integration/PHASE11.md | 21 +++++++++++++++++++ package-lock.json | 2 +- 14 files changed, 116 insertions(+), 1 deletion(-) create mode 100644 Modules/Licensing/Database/Migrations/2026_10_12_200000_create_sonardesk_distribution_tables.php create mode 100644 Modules/Licensing/Http/Controllers/Admin/DistributionAdminController.php create mode 100644 Modules/Licensing/Http/Controllers/DistributionController.php create mode 100644 Modules/Licensing/Models/DownloadProduct.php create mode 100644 Modules/Licensing/Models/SonarDeskRelease.php create mode 100644 Modules/Licensing/Resources/views/distribution/admin.blade.php create mode 100644 Modules/Licensing/Resources/views/downloads/appcast.blade.php create mode 100644 Modules/Licensing/Resources/views/downloads/index.blade.php create mode 100644 Modules/Licensing/Resources/views/downloads/product.blade.php create mode 100644 Modules/Licensing/Resources/views/downloads/release.blade.php create mode 100644 Modules/Licensing/Routes/distribution.php create mode 100644 integration/PHASE11.md diff --git a/Modules/Licensing/Database/Migrations/2026_10_12_200000_create_sonardesk_distribution_tables.php b/Modules/Licensing/Database/Migrations/2026_10_12_200000_create_sonardesk_distribution_tables.php new file mode 100644 index 0000000..b4112ae --- /dev/null +++ b/Modules/Licensing/Database/Migrations/2026_10_12_200000_create_sonardesk_distribution_tables.php @@ -0,0 +1,13 @@ +id(); $t->string('version',40); $t->unsignedInteger('build_number')->default(1); $t->string('channel',20)->default('stable'); $t->string('minimum_macos',30)->nullable(); $t->string('file_path')->nullable(); $t->string('original_filename')->nullable(); $t->unsignedBigInteger('file_size')->nullable(); $t->string('sha256',64)->nullable(); $t->text('sparkle_signature')->nullable(); $t->text('release_notes')->nullable(); $t->boolean('is_published')->default(false)->index(); $t->boolean('is_featured')->default(false)->index(); $t->timestamp('published_at')->nullable()->index(); $t->unsignedBigInteger('download_count')->default(0); $t->timestamps(); $t->unique(['version','build_number','channel']); + }); + if (!Schema::hasTable('download_products')) Schema::create('download_products',function(Blueprint $t){$t->id();$t->string('name');$t->text('description')->nullable();$t->string('version')->nullable();$t->string('platform')->nullable();$t->string('file_path')->nullable();$t->string('original_filename')->nullable();$t->unsignedBigInteger('file_size')->default(0);$t->unsignedBigInteger('download_count')->default(0);$t->boolean('is_published')->default(false);$t->timestamps();}); + } + public function down(): void { /* Preserve release and download data on rollback; use an explicit data migration to remove. */ } +}; diff --git a/Modules/Licensing/Http/Controllers/Admin/DistributionAdminController.php b/Modules/Licensing/Http/Controllers/Admin/DistributionAdminController.php new file mode 100644 index 0000000..c548b10 --- /dev/null +++ b/Modules/Licensing/Http/Controllers/Admin/DistributionAdminController.php @@ -0,0 +1,13 @@ +SonarDeskRelease::orderByDesc('created_at')->paginate(20),'products'=>DownloadProduct::orderBy('name')->get()]);} + public function release(Request $request){$data=$request->validate(['version'=>'required|string|max:40','build_number'=>'required|integer|min:1','channel'=>['required',Rule::in(['stable','beta'])],'minimum_macos'=>'nullable|string|max:30','release_notes'=>'nullable|string','sparkle_signature'=>'nullable|string|max:2048','is_published'=>'nullable|boolean','is_featured'=>'nullable|boolean','file'=>'required|file|max:512000|mimes:dmg,zip,pkg']);$file=$request->file('file');$path=$file->store('sonardesk-distribution/releases','local');$release=SonarDeskRelease::create(['version'=>$data['version'],'build_number'=>$data['build_number'],'channel'=>$data['channel'],'minimum_macos'=>$data['minimum_macos']??null,'release_notes'=>$data['release_notes']??null,'sparkle_signature'=>$data['sparkle_signature']??null,'file_path'=>$path,'original_filename'=>$file->getClientOriginalName(),'file_size'=>$file->getSize(),'sha256'=>hash_file('sha256',$file->getRealPath()),'is_published'=>(bool)($data['is_published']??false),'is_featured'=>(bool)($data['is_featured']??false),'published_at'=>!empty($data['is_published'])?now():null]);return redirect()->route('administration.sonardesk.distribution')->with('status','Release '.$release->version.' created');} + public function product(Request $request){$data=$request->validate(['name'=>'required|string|max:150','description'=>'nullable|string','version'=>'nullable|string|max:60','platform'=>'nullable|string|max:60','is_published'=>'nullable|boolean','file'=>'required|file|max:512000']);$file=$request->file('file');$path=$file->store('sonardesk-distribution/products','local');DownloadProduct::create(['name'=>$data['name'],'description'=>$data['description']??null,'version'=>$data['version']??null,'platform'=>$data['platform']??null,'file_path'=>$path,'original_filename'=>$file->getClientOriginalName(),'file_size'=>$file->getSize(),'is_published'=>(bool)($data['is_published']??false)]);return redirect()->route('administration.sonardesk.distribution')->with('status','Download created');} + public function publish(Request $request,SonarDeskRelease $release){$data=$request->validate(['is_published'=>'required|boolean']);$release->update(['is_published'=>(bool)$data['is_published'],'published_at'=>$data['is_published']?($release->published_at??now()):null]);return back()->with('status','Release status updated');} +} diff --git a/Modules/Licensing/Http/Controllers/DistributionController.php b/Modules/Licensing/Http/Controllers/DistributionController.php new file mode 100644 index 0000000..6c36343 --- /dev/null +++ b/Modules/Licensing/Http/Controllers/DistributionController.php @@ -0,0 +1,17 @@ +where('channel','stable')->orderByDesc('published_at')->first(); $releases=SonarDeskRelease::published()->orderByDesc('published_at')->paginate(12);$products=DownloadProduct::where('is_published',true)->whereNotNull('file_path')->orderBy('name')->get();return view('licensing::downloads.index',compact('latest','releases','products')); } + public function release(SonarDeskRelease $release){abort_unless($release->is_published && $release->published_at && $release->file_path,404);return view('licensing::downloads.release',compact('release'));} + public function product(DownloadProduct $product){abort_unless($product->is_published && $product->file_path,404);return view('licensing::downloads.product',compact('product'));} + public function latest():BinaryFileResponse{$r=SonarDeskRelease::published()->where('channel','stable')->orderByDesc('published_at')->firstOrFail();return $this->send($r);} + public function download(SonarDeskRelease $release):BinaryFileResponse{abort_unless($release->is_published && $release->published_at,404);return $this->send($release);} + public function productDownload(DownloadProduct $product):BinaryFileResponse{abort_unless($product->is_published,404);$path=$this->safePath($product->file_path);$product->increment('download_count');return response()->download($path,$product->original_filename ?: basename($path));} + private function safePath(?string $relative):string {abort_unless($relative && preg_match('~^sonardesk-distribution/[a-zA-Z0-9_./-]+$~D',$relative) && !str_contains($relative,'..'),404);$disk=Storage::disk('local');abort_unless($disk->exists($relative),404);$base=realpath($disk->path('sonardesk-distribution'));$file=realpath($disk->path($relative));abort_unless($base && $file && str_starts_with($file,$base.DIRECTORY_SEPARATOR),404);return $file;} + private function send(SonarDeskRelease $r):BinaryFileResponse{$path=$this->safePath($r->file_path);$r->increment('download_count');return response()->download($path,$r->original_filename ?: 'SonarDesk-'.$r->version.'.dmg');} + public function appcast(){ $releases=SonarDeskRelease::published()->where('channel','stable')->whereNotNull('sparkle_signature')->orderByDesc('published_at')->limit(20)->get();return response()->view('licensing::downloads.appcast',compact('releases'),200,['Content-Type'=>'application/rss+xml; charset=UTF-8','Cache-Control'=>'public, max-age=300']); } +} diff --git a/Modules/Licensing/Models/DownloadProduct.php b/Modules/Licensing/Models/DownloadProduct.php new file mode 100644 index 0000000..c8279ba --- /dev/null +++ b/Modules/Licensing/Models/DownloadProduct.php @@ -0,0 +1,8 @@ +'boolean','file_size'=>'integer'];} +} diff --git a/Modules/Licensing/Models/SonarDeskRelease.php b/Modules/Licensing/Models/SonarDeskRelease.php new file mode 100644 index 0000000..ead77f3 --- /dev/null +++ b/Modules/Licensing/Models/SonarDeskRelease.php @@ -0,0 +1,9 @@ +'boolean','is_featured'=>'boolean','published_at'=>'datetime','file_size'=>'integer','build_number'=>'integer'];} + public function scopePublished($query){return $query->where('is_published',true)->whereNotNull('file_path')->whereNotNull('published_at');} +} diff --git a/Modules/Licensing/Providers/IntegratedLicensingServiceProvider.php b/Modules/Licensing/Providers/IntegratedLicensingServiceProvider.php index 98f7054..3c8c37d 100644 --- a/Modules/Licensing/Providers/IntegratedLicensingServiceProvider.php +++ b/Modules/Licensing/Providers/IntegratedLicensingServiceProvider.php @@ -33,5 +33,6 @@ final class IntegratedLicensingServiceProvider extends ServiceProvider Route::prefix('api')->middleware('api')->group(__DIR__.'/../Routes/api.php'); Route::group([], __DIR__.'/../Routes/admin.php'); Route::group([], __DIR__.'/../Routes/customer.php'); + Route::group([], __DIR__.'/../Routes/distribution.php'); } } diff --git a/Modules/Licensing/Resources/views/distribution/admin.blade.php b/Modules/Licensing/Resources/views/distribution/admin.blade.php new file mode 100644 index 0000000..080b5b2 --- /dev/null +++ b/Modules/Licensing/Resources/views/distribution/admin.blade.php @@ -0,0 +1,9 @@ +@extends('licensing::admin.layout') +@section('content') +

Downloads & releases

@if(session('status'))

{{ session('status') }}

@endif +

Publish SonarDesk release

@csrf +

+

+

Releases

@foreach($releases as $r)@endforeach
VersionChannelStatusDownloadsAction
{{ $r->version }} ({{ $r->build_number }}){{ $r->channel }}{{ $r->is_published ? 'Published' : 'Draft' }}{{ $r->download_count }}
@csrf @method('PATCH')
{{ $releases->links() }} +

Add other download (e.g. SonarDisk)

@csrf

Other downloads

+@endsection \ No newline at end of file diff --git a/Modules/Licensing/Resources/views/downloads/appcast.blade.php b/Modules/Licensing/Resources/views/downloads/appcast.blade.php new file mode 100644 index 0000000..439f0d6 --- /dev/null +++ b/Modules/Licensing/Resources/views/downloads/appcast.blade.php @@ -0,0 +1,2 @@ +@php echo ''; @endphp +SonarDesk Updates{{ route('sonardesk.updates.appcast') }}SonarDesk stable release feed@foreach($releases as $release)SonarDesk {{ $release->version }}{{ $release->published_at?->toRfc2822String() }}{{ $release->build_number }}{{ $release->version }}@if($release->minimum_macos){{ $release->minimum_macos }}@endifrelease_notes ?: 'SonarDesk update')) !!}]]>@endforeach \ No newline at end of file diff --git a/Modules/Licensing/Resources/views/downloads/index.blade.php b/Modules/Licensing/Resources/views/downloads/index.blade.php new file mode 100644 index 0000000..c4530ba --- /dev/null +++ b/Modules/Licensing/Resources/views/downloads/index.blade.php @@ -0,0 +1 @@ +SonarDesk Downloads

SonarDesk Downloads

Current applications and release history.

@if($latest)

SonarDesk {{ $latest->version }}

{{ \Illuminate\Support\Str::limit(strip_tags($latest->release_notes ?? 'Native SonarQube dashboard for macOS.'),190) }}

Download for macOS  Release notes & requirements
@endif @if($products->isNotEmpty())

Other downloads

    @foreach($products as $product)
  • {{ $product->name }} {{ $product->version }} ({{ $product->platform }}) — Download · Details
  • @endforeach
@endif

Previous releases

    @forelse($releases as $release)
  • {{ $release->version }} — {{ $release->channel }} · Notes · Download
  • @empty
  • No releases published.
  • @endforelse
{{ $releases->links() }}
\ No newline at end of file diff --git a/Modules/Licensing/Resources/views/downloads/product.blade.php b/Modules/Licensing/Resources/views/downloads/product.blade.php new file mode 100644 index 0000000..ee0080a --- /dev/null +++ b/Modules/Licensing/Resources/views/downloads/product.blade.php @@ -0,0 +1 @@ +{{ $product->name }}← All downloads

{{ $product->name }}

Version {{ $product->version }} · {{ $product->platform }}

{{ $product->description }}

Download {{ $product->original_filename }}

\ No newline at end of file diff --git a/Modules/Licensing/Resources/views/downloads/release.blade.php b/Modules/Licensing/Resources/views/downloads/release.blade.php new file mode 100644 index 0000000..fa5a415 --- /dev/null +++ b/Modules/Licensing/Resources/views/downloads/release.blade.php @@ -0,0 +1 @@ +SonarDesk {{ $release->version }}← All downloads

SonarDesk {{ $release->version }}

Build {{ $release->build_number }} · {{ $release->channel }}

Release notes

{{ $release->release_notes ?: 'No release notes provided.' }}

System requirements

macOS {{ $release->minimum_macos ?: 'See application documentation' }} or later.

SHA-256: {{ $release->sha256 }}

Download {{ $release->original_filename }} \ No newline at end of file diff --git a/Modules/Licensing/Routes/distribution.php b/Modules/Licensing/Routes/distribution.php new file mode 100644 index 0000000..c3a094d --- /dev/null +++ b/Modules/Licensing/Routes/distribution.php @@ -0,0 +1,19 @@ +prefix('sonardesk')->name('sonardesk.')->group(function(){ + Route::get('/downloads',[PublicDownloads::class,'index'])->name('downloads'); + Route::get('/downloads/releases/{release}',[PublicDownloads::class,'release'])->name('releases.show'); + Route::get('/downloads/products/{product}',[PublicDownloads::class,'product'])->name('products.show'); + Route::get('/downloads/latest',[PublicDownloads::class,'latest'])->name('latest'); + Route::get('/downloads/releases/{release}/file',[PublicDownloads::class,'download'])->name('releases.download'); + Route::get('/downloads/products/{product}/file',[PublicDownloads::class,'productDownload'])->name('products.download'); + Route::get('/updates/appcast.xml',[PublicDownloads::class,'appcast'])->name('updates.appcast'); +}); +Route::middleware(['web','auth','verified','can:manage-sonardesk-licensing'])->prefix('administration/sonardesk')->name('administration.sonardesk.')->group(function(){ + Route::get('/distribution',[AdminDownloads::class,'index'])->name('distribution'); + Route::post('/distribution/releases',[AdminDownloads::class,'release'])->name('distribution.releases.store'); + Route::post('/distribution/products',[AdminDownloads::class,'product'])->name('distribution.products.store'); + Route::patch('/distribution/releases/{release}/publish',[AdminDownloads::class,'publish'])->name('distribution.releases.publish'); +}); diff --git a/integration/PHASE11.md b/integration/PHASE11.md new file mode 100644 index 0000000..49a504b --- /dev/null +++ b/integration/PHASE11.md @@ -0,0 +1,21 @@ +# Phase 11 — SonarDesk distribution +Install patch at ABSDEV root; all paths are relative to the project root. No root-level module duplicates are included. + +```sh +php artisan migrate +composer dump-autoload +php artisan optimize:clear +php artisan route:list --path=sonardesk +php artisan route:list --path=administration/sonardesk/distribution +``` +Admin: `/administration/sonardesk/distribution`; public: `/sonardesk/downloads`; feed: `/sonardesk/updates/appcast.xml`. + +Release and download binaries are stored on the private `local` disk in `sonardesk-distribution/`. Ensure the storage directory is persistent across deployments, backed up, and writable by PHP. Do not put it in public webroot. Public download responses use the Laravel controller. + +The database schema preserves the existing legacy `sonardesk_releases` and `download_products` column names. If those tables already exist, the migration does not recreate them. Legacy files and records are **not** automatically imported; migrate their storage paths into `sonardesk-distribution/` before enabling public downloads. + +Sparkle appcast includes only stable published releases with a non-empty Sparkle signature. Generate signatures with the official Sparkle signing tool; SHA-256 is not a substitute. Configure the macOS client to point to this feed only after validating compatibility. Existing live appcast and checkout remain unchanged. + +The patch includes release creation, publication toggle, separate product upload and read-only public details. Editing existing releases, deletion, detailed per-platform requirements, and automatic client-side updates are not implemented here. Uploaded files are limited to 500 MiB by Laravel validation and additionally by PHP/nginx upload limits. Review your server upload limits. + +Do not apply to production before testing staging, storage persistence, URLs, signatures and permissions. No database data is deleted on rollback. diff --git a/package-lock.json b/package-lock.json index a0a27f5..4bd8d28 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "Content_Manager", + "name": "ABSDEV", "lockfileVersion": 3, "requires": true, "packages": {