move into sonardesk

This commit is contained in:
Mark Gregory
2026-10-12 15:24:46 +01:00
parent 729b78ddb9
commit 0ca7a2cf1e
14 changed files with 116 additions and 1 deletions
@@ -0,0 +1,13 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void {
if (!Schema::hasTable('sonardesk_releases')) Schema::create('sonardesk_releases', function(Blueprint $t) {
$t->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. */ }
};
@@ -0,0 +1,13 @@
<?php
namespace Modules\Licensing\Http\Controllers\Admin;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\Rule;
use Modules\Licensing\Models\{SonarDeskRelease,DownloadProduct};
final class DistributionAdminController extends Controller {
public function index(){return view('licensing::distribution.admin', ['releases'=>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');}
}
@@ -0,0 +1,17 @@
<?php
namespace Modules\Licensing\Http\Controllers;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\Storage;
use Modules\Licensing\Models\{SonarDeskRelease,DownloadProduct};
use Symfony\Component\HttpFoundation\BinaryFileResponse;
final class DistributionController extends Controller {
public function index(){ $latest=SonarDeskRelease::published()->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']); }
}
@@ -0,0 +1,8 @@
<?php
namespace Modules\Licensing\Models;
use Illuminate\Database\Eloquent\Model;
final class DownloadProduct extends Model {
protected $table='download_products';
protected $fillable=['name','description','version','platform','file_path','original_filename','file_size','is_published'];
protected function casts():array{return ['is_published'=>'boolean','file_size'=>'integer'];}
}
@@ -0,0 +1,9 @@
<?php
namespace Modules\Licensing\Models;
use Illuminate\Database\Eloquent\Model;
final class SonarDeskRelease extends Model {
protected $table='sonardesk_releases';
protected $fillable=['version','build_number','channel','minimum_macos','file_path','original_filename','file_size','sha256','sparkle_signature','release_notes','is_published','is_featured','published_at'];
protected function casts():array{return ['is_published'=>'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');}
}
@@ -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');
}
}
@@ -0,0 +1,9 @@
@extends('licensing::admin.layout')
@section('content')
<h1>Downloads &amp; releases</h1>@if(session('status'))<p>{{ session('status') }}</p>@endif
<h2>Publish SonarDesk release</h2><form method="POST" enctype="multipart/form-data" action="{{ route('administration.sonardesk.distribution.releases.store') }}">@csrf
<p><input name="version" placeholder="Version (e.g. 1.2.0)" required> <input name="build_number" type="number" min="1" placeholder="Build" required> <select name="channel"><option value="stable">Stable</option><option value="beta">Beta</option></select></p>
<p><input name="minimum_macos" placeholder="Minimum macOS version"><input type="file" name="file" accept=".dmg,.zip,.pkg" required></p><p><textarea name="release_notes" placeholder="Full release notes" rows="5" cols="70"></textarea></p><p><input name="sparkle_signature" placeholder="Sparkle EdDSA signature (optional)" size="65"></p><label><input type="checkbox" name="is_published" value="1"> Publish</label> <button type="submit">Create release</button></form>
<h2>Releases</h2><table><thead><tr><th>Version</th><th>Channel</th><th>Status</th><th>Downloads</th><th>Action</th></tr></thead><tbody>@foreach($releases as $r)<tr><td>{{ $r->version }} ({{ $r->build_number }})</td><td>{{ $r->channel }}</td><td>{{ $r->is_published ? 'Published' : 'Draft' }}</td><td>{{ $r->download_count }}</td><td><form method="POST" action="{{ route('administration.sonardesk.distribution.releases.publish',$r) }}">@csrf @method('PATCH')<input type="hidden" name="is_published" value="{{ $r->is_published ? 0 : 1 }}"><button>{{ $r->is_published ? 'Unpublish' : 'Publish' }}</button></form></td></tr>@endforeach</tbody></table>{{ $releases->links() }}
<h2>Add other download (e.g. SonarDisk)</h2><form method="POST" enctype="multipart/form-data" action="{{ route('administration.sonardesk.distribution.products.store') }}">@csrf<p><input name="name" placeholder="Product name" required><input name="version" placeholder="Version"><input name="platform" placeholder="Platform"></p><p><textarea name="description" placeholder="Description"></textarea></p><p><input name="file" type="file" required></p><label><input type="checkbox" name="is_published" value="1"> Publish</label><button>Add download</button></form><h2>Other downloads</h2><ul>@foreach($products as $p)<li>{{ $p->name }} {{ $p->version }} — {{ $p->download_count }} downloads ({{ $p->is_published ? 'published' : 'draft' }})</li>@endforeach</ul>
@endsection
@@ -0,0 +1,2 @@
@php echo '<?xml version="1.0" encoding="utf-8"?>'; @endphp
<rss version="2.0" xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle"><channel><title>SonarDesk Updates</title><link>{{ route('sonardesk.updates.appcast') }}</link><description>SonarDesk stable release feed</description>@foreach($releases as $release)<item><title>SonarDesk {{ $release->version }}</title><pubDate>{{ $release->published_at?->toRfc2822String() }}</pubDate><sparkle:version>{{ $release->build_number }}</sparkle:version><sparkle:shortVersionString>{{ $release->version }}</sparkle:shortVersionString>@if($release->minimum_macos)<sparkle:minimumSystemVersion>{{ $release->minimum_macos }}</sparkle:minimumSystemVersion>@endif<description><![CDATA[{!! nl2br(e($release->release_notes ?: 'SonarDesk update')) !!}]]></description><enclosure url="{{ route('sonardesk.releases.download',$release) }}" length="{{ $release->file_size }}" type="application/octet-stream" sparkle:edSignature="{{ $release->sparkle_signature }}" /></item>@endforeach</channel></rss>
@@ -0,0 +1 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>SonarDesk Downloads</title><style>body{font:16px system-ui;background:#f6f8fc;color:#17243c;margin:0}.wrap{max-width:1120px;margin:48px auto;padding:0 24px}h1{font-size:42px;margin-bottom:6px}.card{background:white;border:1px solid #dce3ee;border-radius:14px;padding:22px;margin:18px 0}a.button{display:inline-block;background:#183b73;color:white;padding:10px 18px;border-radius:8px;text-decoration:none}small{color:#58657a}ul{line-height:2}</style></head><body><main class="wrap"><h1>SonarDesk Downloads</h1><p>Current applications and release history.</p>@if($latest)<section class="card"><h2>SonarDesk <small>{{ $latest->version }}</small></h2><p>{{ \Illuminate\Support\Str::limit(strip_tags($latest->release_notes ?? 'Native SonarQube dashboard for macOS.'),190) }}</p><a class="button" href="{{ route('sonardesk.releases.download',$latest) }}">Download for macOS</a> &nbsp;<a href="{{ route('sonardesk.releases.show',$latest) }}">Release notes &amp; requirements</a></section>@endif @if($products->isNotEmpty())<section class="card"><h2>Other downloads</h2><ul>@foreach($products as $product)<li><strong>{{ $product->name }}</strong> {{ $product->version }} ({{ $product->platform }}) — <a href="{{ route('sonardesk.products.download',$product) }}">Download</a> · <a href="{{ route('sonardesk.products.show',$product) }}">Details</a></li>@endforeach</ul></section>@endif<section class="card"><h2>Previous releases</h2><ul>@forelse($releases as $release)<li><strong>{{ $release->version }}</strong> — {{ $release->channel }} · <a href="{{ route('sonardesk.releases.show',$release) }}">Notes</a> · <a href="{{ route('sonardesk.releases.download',$release) }}">Download</a></li>@empty<li>No releases published.</li>@endforelse</ul>{{ $releases->links() }}</section></main></body></html>
@@ -0,0 +1 @@
<!doctype html><html><head><meta name="viewport" content="width=device-width,initial-scale=1"><title>{{ $product->name }}</title></head><body style="font:16px system-ui;max-width:900px;margin:50px auto;padding:20px"><a href="{{ route('sonardesk.downloads') }}">← All downloads</a><h1>{{ $product->name }}</h1><p>Version {{ $product->version }} · {{ $product->platform }}</p><div style="white-space:pre-wrap">{{ $product->description }}</div><p><a href="{{ route('sonardesk.products.download',$product) }}">Download {{ $product->original_filename }}</a></p></body></html>
@@ -0,0 +1 @@
<!doctype html><html><head><meta name="viewport" content="width=device-width,initial-scale=1"><title>SonarDesk {{ $release->version }}</title></head><body style="font:16px system-ui;max-width:900px;margin:50px auto;padding:20px"><a href="{{ route('sonardesk.downloads') }}">← All downloads</a><h1>SonarDesk {{ $release->version }}</h1><p>Build {{ $release->build_number }} · {{ $release->channel }}</p><h2>Release notes</h2><div style="white-space:pre-wrap">{{ $release->release_notes ?: 'No release notes provided.' }}</div><h2>System requirements</h2><p>macOS {{ $release->minimum_macos ?: 'See application documentation' }} or later.</p><p>SHA-256: <code>{{ $release->sha256 }}</code></p><a href="{{ route('sonardesk.releases.download',$release) }}">Download {{ $release->original_filename }}</a></body></html>
+19
View File
@@ -0,0 +1,19 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\Licensing\Http\Controllers\DistributionController as PublicDownloads;
use Modules\Licensing\Http\Controllers\Admin\DistributionAdminController as AdminDownloads;
Route::middleware(['web','throttle:60,1'])->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');
});
+21
View File
@@ -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.
+1 -1
View File
@@ -1,5 +1,5 @@
{
"name": "Content_Manager",
"name": "ABSDEV",
"lockfileVersion": 3,
"requires": true,
"packages": {