diff --git a/Modules/Licensing/Database/Migrations/2026_10_12_160000_extend_download_products.php b/Modules/Licensing/Database/Migrations/2026_10_12_160000_extend_download_products.php
new file mode 100644
index 0000000..420dd74
--- /dev/null
+++ b/Modules/Licensing/Database/Migrations/2026_10_12_160000_extend_download_products.php
@@ -0,0 +1,8 @@
+longText('release_notes')->nullable();$table->text('minimum_system')->nullable();$table->integer('sort_order')->default(0);}); }
+ public function down(): void {Schema::table('download_products',function(Blueprint $table){$table->dropColumn(['release_notes','minimum_system','sort_order']);});}
+};
diff --git a/Modules/Licensing/Http/Controllers/Admin/DistributionAdminController.php b/Modules/Licensing/Http/Controllers/Admin/DistributionAdminController.php
index c548b10..73a9d5c 100644
--- a/Modules/Licensing/Http/Controllers/Admin/DistributionAdminController.php
+++ b/Modules/Licensing/Http/Controllers/Admin/DistributionAdminController.php
@@ -6,8 +6,16 @@ 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 index(){return view('licensing::distribution.admin',['releases'=>SonarDeskRelease::orderByDesc('created_at')->paginate(20),'products'=>DownloadProduct::orderBy('name')->orderByDesc('created_at')->get()]);}
+ private function upload($file,string $folder):array{$path=$file->store('sonardesk-distribution/'.$folder,'local');return ['file_path'=>$path,'original_filename'=>$file->getClientOriginalName(),'file_size'=>$file->getSize()];}
+ private function remove(?string $path):void {if($path && str_starts_with($path,'sonardesk-distribution/') && !str_contains($path,'..'))Storage::disk('local')->delete($path);}
+ private function productRules(bool $new):array{return ['name'=>'required|string|max:150','description'=>'nullable|string','version'=>'nullable|string|max:60','platform'=>'nullable|string|max:60','release_notes'=>'nullable|string','minimum_system'=>'nullable|string|max:2000','sort_order'=>'nullable|integer|between:-10000,10000','is_published'=>'nullable|boolean','file'=>[$new?'required':'nullable','file','max:512000']];}
+ private function releaseRules(bool $new):array{return ['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'=>[$new?'required':'nullable','file','max:512000','mimes:dmg,zip,pkg']];}
+ public function product(Request $request){$data=$request->validate($this->productRules(true));$file=$request->file('file');$attributes=$this->upload($file,'products');unset($data['file']);DownloadProduct::create(array_merge($data,$attributes,['is_published'=>$request->boolean('is_published'),'sort_order'=>(int)($data['sort_order']??0)]));return back()->with('status','Download added');}
+ public function updateProduct(Request $request,DownloadProduct $product){$data=$request->validate($this->productRules(false));unset($data['file']);$data['is_published']=$request->boolean('is_published');$data['sort_order']=(int)($data['sort_order']??0);if($request->hasFile('file')){$old=$product->file_path;$data=array_merge($data,$this->upload($request->file('file'),'products'));$product->update($data);$this->remove($old);}else{$product->update($data);}return back()->with('status','Download updated');}
+ public function deleteProduct(DownloadProduct $product){$path=$product->file_path;$product->delete();$this->remove($path);return back()->with('status','Download removed');}
+ public function release(Request $request){$data=$request->validate($this->releaseRules(true));$file=$request->file('file');unset($data['file']);$release=SonarDeskRelease::create(array_merge($data,$this->upload($file,'releases'),['sha256'=>hash_file('sha256',$file->getRealPath()),'is_published'=>$request->boolean('is_published'),'is_featured'=>$request->boolean('is_featured'),'published_at'=>$request->boolean('is_published')?now():null]));return back()->with('status','Release '.$release->version.' created');}
+ public function updateRelease(Request $request,SonarDeskRelease $release){$data=$request->validate($this->releaseRules(false));unset($data['file']);$data['is_published']=$request->boolean('is_published');$data['is_featured']=$request->boolean('is_featured');$data['published_at']=$data['is_published']?($release->published_at??now()):null;if($request->hasFile('file')){$file=$request->file('file');$old=$release->file_path;$data=array_merge($data,$this->upload($file,'releases'),['sha256'=>hash_file('sha256',$file->getRealPath())]);$release->update($data);$this->remove($old);}else{$release->update($data);}return back()->with('status','Release updated');}
+ public function deleteRelease(SonarDeskRelease $release){$path=$release->file_path;$release->delete();$this->remove($path);return back()->with('status','Release removed');}
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
index 6c36343..dadf977 100644
--- a/Modules/Licensing/Http/Controllers/DistributionController.php
+++ b/Modules/Licensing/Http/Controllers/DistributionController.php
@@ -5,7 +5,7 @@ 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 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')->orderBy('sort_order')->orderByDesc('created_at')->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);}
diff --git a/Modules/Licensing/Models/DownloadProduct.php b/Modules/Licensing/Models/DownloadProduct.php
index c8279ba..a61649a 100644
--- a/Modules/Licensing/Models/DownloadProduct.php
+++ b/Modules/Licensing/Models/DownloadProduct.php
@@ -3,6 +3,6 @@ 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'];}
+ protected $fillable=['name','description','version','platform','file_path','original_filename','file_size','is_published','release_notes','minimum_system','sort_order'];
+ protected function casts():array{return ['is_published'=>'boolean','file_size'=>'integer','sort_order'=>'integer'];}
}
diff --git a/Modules/Licensing/Resources/views/admin/layout.blade.php b/Modules/Licensing/Resources/views/admin/layout.blade.php
index 88a45f3..41b64b1 100644
--- a/Modules/Licensing/Resources/views/admin/layout.blade.php
+++ b/Modules/Licensing/Resources/views/admin/layout.blade.php
@@ -3,13 +3,16 @@
{{ \Illuminate\Support\Str::limit(trim(preg_replace('/\s+/', ' ', strip_tags(\Illuminate\Support\Str::markdown($release->release_notes ?: 'Release information available on the details page.', ['html_input'=>'strip','allow_unsafe_links'=>false])))), 160) }}
{!! \Illuminate\Support\Str::markdown($release->release_notes ?: 'No release notes were provided for this version.', ['html_input'=>'strip','allow_unsafe_links'=>false]) !!}