New updates
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
namespace App\Console\Commands;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
final class RsyncStudioAdminCreate extends Command {
|
||||
protected $signature='rsync-studio:admin {email} {--name=Administrator}'; protected $description='Create or promote an Rsync Studio licensing administrator';
|
||||
public function handle(): int { $email=strtolower(trim($this->argument('email'))); $password=Str::password(24,true,true,false,false); $u=User::firstOrNew(['email'=>$email]); $u->name=$this->option('name'); $u->is_admin=true; $u->email_verified_at=now(); if(!$u->exists){$u->password=Hash::make($password);} $u->save(); $this->info("Administrator ready: {$email}"); if(!$u->wasRecentlyCreated){$this->line('Existing password retained.');}else{$this->warn("Temporary password (shown once): {$password}");} return self::SUCCESS; }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
namespace App\Console\Commands;
|
||||
use App\Models\LicenseProduct;
|
||||
use App\Services\Licensing\LicenseIssuer;
|
||||
use Illuminate\Console\Command;
|
||||
class RsyncStudioLicenseIssue extends Command
|
||||
{
|
||||
protected $signature='rsync-studio:license:issue {product} {--name=} {--email=}'; protected $description='Issue a manual Rsync Studio licence key';
|
||||
public function handle(LicenseIssuer $issuer): int { $p=LicenseProduct::where('code',$this->argument('product'))->firstOrFail(); [$l,$key]=$issuer->issue($p,$this->option('name')?:null,$this->option('email')?:null); if(!$key){$this->warn('Licence already existed; plaintext key is intentionally not recoverable.');return self::FAILURE;} $this->line('Licence ID: '.$l->id);$this->line('Licence key: '.$key);$this->warn('This is the only time the plaintext key is shown.');return self::SUCCESS; }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
namespace App\Console\Commands;
|
||||
use App\Models\License;
|
||||
use Illuminate\Console\Command;
|
||||
class RsyncStudioLicenseRevoke extends Command
|
||||
{
|
||||
protected $signature='rsync-studio:license:revoke {id} {--reason=Administrative revocation}'; protected $description='Revoke a Rsync Studio licence';
|
||||
public function handle(): int { $l=License::findOrFail($this->argument('id'));$l->update(['revoked_at'=>now(),'revoke_reason'=>$this->option('reason')]);$this->info('Licence revoked.');return self::SUCCESS; }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
namespace App\Console\Commands;
|
||||
use App\Models\LicenseProduct;
|
||||
use Illuminate\Console\Command;
|
||||
class RsyncStudioProductCreate extends Command
|
||||
{
|
||||
protected $signature='rsync-studio:product {code} {--name=Rsync Studio} {--edition=Professional} {--activations=1} {--max-major=1} {--provider=} {--product-id=} {--price-id=}';
|
||||
protected $description='Create or update a Rsync Studio licensing product';
|
||||
public function handle(): int { $p=LicenseProduct::updateOrCreate(['code'=>$this->argument('code')],['name'=>$this->option('name'),'edition'=>$this->option('edition'),'activation_limit'=>(int)$this->option('activations'),'max_major_version'=>$this->option('max-major')!==''?(int)$this->option('max-major'):null,'payment_provider'=>$this->option('provider')?:null,'external_product_id'=>$this->option('product-id')?:null,'external_price_id'=>$this->option('price-id')?:null,'active'=>true]); $this->info("Product {$p->code} ready."); return self::SUCCESS; }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
namespace App\Http\Controllers\Admin;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\License;
|
||||
use App\Models\LicenseProduct;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
final class AdminController extends Controller {
|
||||
public function index(){ return view('admin.index',['licenseCount'=>License::count(),'activeCount'=>License::whereNull('revoked_at')->count(),'activationCount'=>\App\Models\LicenseActivation::whereNull('deactivated_at')->count(),'userCount'=>User::count()]); }
|
||||
public function licenses(Request $r){ $q=License::with('product')->latest(); if($s=trim((string)$r->query('q'))) $q->where(fn($x)=>$x->where('customer_email','like',"%{$s}%")->orWhere('customer_name','like',"%{$s}%")->orWhere('license_key_last4','like',"%{$s}%")); return view('admin.licenses',['licenses'=>$q->paginate(50)->withQueryString()]); }
|
||||
public function show(License $license){$license->load(['product','activations']); return view('admin.license',compact('license'));}
|
||||
public function revoke(Request $request,License $license){$data=$request->validate(['reason'=>['nullable','string','max:255']]);$license->update(['revoked_at'=>now(),'revoke_reason'=>$data['reason']?:'Revoked by administrator']);return back()->with('status','Licence revoked.');}
|
||||
public function restore(License $license){$license->update(['revoked_at'=>null,'revoke_reason'=>null]);return back()->with('status','Licence restored.');}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\License;
|
||||
use App\Models\LicenseActivation;
|
||||
use App\Services\Licensing\LicenseKey;
|
||||
use App\Services\Licensing\LicenseSigner;
|
||||
use App\Services\Licensing\LicenseTokenVerifier;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
final class LicenseActivationController extends Controller
|
||||
{
|
||||
public function activate(Request $request, LicenseSigner $signer): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'licenseKey'=>['required','string','max:128'],
|
||||
'installationID'=>['required','uuid'],
|
||||
'appVersion'=>['required','string','max:32'],
|
||||
'platform'=>['required','string','max:32'],
|
||||
]);
|
||||
|
||||
return DB::transaction(function () use ($data,$signer) {
|
||||
$license = License::where('license_key_hash', LicenseKey::hash($data['licenseKey']))->lockForUpdate()->first();
|
||||
if (!$license) throw ValidationException::withMessages(['licenseKey'=>'The licence key is not valid.']);
|
||||
if (!$license->is_usable) return response()->json(['message'=>'This licence is expired or has been revoked.'], 403);
|
||||
|
||||
$activation = LicenseActivation::where('license_id',$license->id)->where('installation_id',strtolower($data['installationID']))->first();
|
||||
if ($activation && $activation->deactivated_at === null) {
|
||||
$activation->update(['last_seen_at'=>now(),'platform'=>$data['platform'],'app_version'=>$data['appVersion']]);
|
||||
} else {
|
||||
$activeCount = LicenseActivation::where('license_id',$license->id)->whereNull('deactivated_at')->count();
|
||||
if ($activeCount >= $license->activation_limit) return response()->json(['message'=>'This licence has reached its activation limit. Deactivate another Mac first.'], 409);
|
||||
if ($activation) $activation->update(['deactivated_at'=>null,'activated_at'=>now(),'last_seen_at'=>now(),'platform'=>$data['platform'],'app_version'=>$data['appVersion']]);
|
||||
else LicenseActivation::create(['license_id'=>$license->id,'installation_id'=>strtolower($data['installationID']),'platform'=>$data['platform'],'app_version'=>$data['appVersion'],'activated_at'=>now(),'last_seen_at'=>now()]);
|
||||
}
|
||||
return response()->json(['licenseToken'=>$signer->sign($license, $data['installationID'])]);
|
||||
});
|
||||
}
|
||||
|
||||
public function deactivate(Request $request, LicenseTokenVerifier $verifier): JsonResponse
|
||||
{
|
||||
$data = $request->validate(['licenseToken'=>['required','string','max:8192'],'installationID'=>['required','uuid']]);
|
||||
try { $payload = $verifier->verify($data['licenseToken']); }
|
||||
catch (\Throwable) { return response()->json(['message'=>'The licence token is invalid.'], 400); }
|
||||
if (strcasecmp((string)($payload['installationID'] ?? ''), $data['installationID']) !== 0) return response()->json(['message'=>'The installation does not match this licence token.'], 400);
|
||||
LicenseActivation::where('license_id',(string)($payload['licenseID'] ?? ''))->where('installation_id',strtolower($data['installationID']))->whereNull('deactivated_at')->update(['deactivated_at'=>now(),'last_seen_at'=>now()]);
|
||||
return response()->json((object)[]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\LicenseWebhookEvent;
|
||||
use App\Services\Payments\LemonSqueezyWebhookVerifier;
|
||||
use App\Services\Payments\PaddleWebhookVerifier;
|
||||
use App\Services\Payments\PurchaseProvisioner;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class PaymentWebhookController extends Controller
|
||||
{
|
||||
public function __invoke(string $provider, Request $request, PurchaseProvisioner $provisioner): JsonResponse
|
||||
{
|
||||
$raw=(string)$request->getContent();
|
||||
$verifier=match($provider){'paddle'=>app(PaddleWebhookVerifier::class),'lemon-squeezy'=>app(LemonSqueezyWebhookVerifier::class),default=>null};
|
||||
if (!$verifier) return response()->json(['message'=>'Unknown webhook provider.'],404);
|
||||
try { $verifier->verify($request,$raw); } catch (\Throwable $e) { report($e); return response()->json(['message'=>'Invalid webhook signature.'],400); }
|
||||
try { $payload=json_decode($raw,true,flags:JSON_THROW_ON_ERROR); } catch (\Throwable) { return response()->json(['message'=>'Invalid JSON.'],400); }
|
||||
$externalID=(string)($provider==='paddle'?($payload['event_id']??''):($payload['meta']['event_name']??'').':'.($payload['data']['id']??''));
|
||||
$eventType=(string)($provider==='paddle'?($payload['event_type']??'unknown'):($payload['meta']['event_name']??'unknown'));
|
||||
if ($externalID==='') return response()->json(['message'=>'Webhook event has no stable identifier.'],400);
|
||||
|
||||
try {
|
||||
DB::transaction(function() use($provider,$externalID,$eventType,$payload,$raw,$provisioner){
|
||||
$event=LicenseWebhookEvent::firstOrCreate(['provider'=>$provider,'external_event_id'=>$externalID],['event_type'=>$eventType,'payload_sha256'=>hash('sha256',$raw),'payload'=>$payload]);
|
||||
if ($event->processed_at) return;
|
||||
if (!hash_equals($event->payload_sha256,hash('sha256',$raw))) throw new \RuntimeException('Webhook ID was reused with a different payload.');
|
||||
try { $provisioner->provision($provider,$payload); $event->update(['processed_at'=>now(),'processing_error'=>null]); }
|
||||
catch (\Throwable $e) { $event->update(['processing_error'=>mb_substr($e->getMessage(),0,4000)]); throw $e; }
|
||||
});
|
||||
} catch (\Throwable $e) { report($e); return response()->json(['message'=>'Webhook could not be processed.'],500); }
|
||||
return response()->json(['ok'=>true]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
namespace App\Http\Controllers\Auth;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
final class AuthenticatedSessionController extends Controller {
|
||||
public function create(){ return view('auth.login'); }
|
||||
public function store(Request $request){
|
||||
$credentials=$request->validate(['email'=>['required','email'],'password'=>['required','string']]);
|
||||
if(!Auth::attempt(['email'=>strtolower($credentials['email']),'password'=>$credentials['password']], $request->boolean('remember'))){ return back()->withErrors(['email'=>'The supplied credentials are invalid.'])->onlyInput('email'); }
|
||||
$request->session()->regenerate(); return redirect()->intended(route('portal.index'));
|
||||
}
|
||||
public function destroy(Request $request){ Auth::logout(); $request->session()->invalidate(); $request->session()->regenerateToken(); return redirect()->route('login'); }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
namespace App\Http\Controllers\Auth;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Foundation\Auth\EmailVerificationRequest;
|
||||
use Illuminate\Http\Request;
|
||||
final class EmailVerificationController extends Controller {
|
||||
public function notice(){ return view('auth.verify-email'); }
|
||||
public function verify(EmailVerificationRequest $request){ $request->fulfill(); return redirect()->route('portal.index')->with('status','Email verified.'); }
|
||||
public function resend(Request $request){ if(!$request->user()->hasVerifiedEmail()) $request->user()->sendEmailVerificationNotification(); return back()->with('status','Verification link sent.'); }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
namespace App\Http\Controllers\Auth;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Events\PasswordReset;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rules\Password as PasswordRule;
|
||||
final class PasswordController extends Controller {
|
||||
public function requestForm(){ return view('auth.forgot-password'); }
|
||||
public function email(Request $request){ $request->validate(['email'=>['required','email']]); $status=Password::sendResetLink(['email'=>strtolower($request->email)]); return $status===Password::RESET_LINK_SENT?back()->with('status',__($status)):back()->withErrors(['email'=>__($status)]); }
|
||||
public function resetForm(Request $request,string $token){ return view('auth.reset-password',['token'=>$token,'email'=>$request->query('email')]); }
|
||||
public function reset(Request $request){ $data=$request->validate(['token'=>['required'],'email'=>['required','email'],'password'=>['required','confirmed',PasswordRule::min(12)->letters()->mixedCase()->numbers()]]); $status=Password::reset($data,function(User $user,string $password){$user->forceFill(['password'=>$password,'remember_token'=>Str::random(60)])->save();event(new PasswordReset($user));}); return $status===Password::PASSWORD_RESET?redirect()->route('login')->with('status',__($status)):back()->withErrors(['email'=>__($status)]); }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
namespace App\Http\Controllers\Auth;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Events\Registered;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
final class RegisteredUserController extends Controller {
|
||||
public function create(){ return view('auth.register'); }
|
||||
public function store(Request $request){
|
||||
$data=$request->validate(['name'=>['required','string','max:120'],'email'=>['required','email','max:255','unique:users,email'],'password'=>['required','confirmed',Password::min(12)->letters()->mixedCase()->numbers()]]);
|
||||
$user=User::create(['name'=>$data['name'],'email'=>strtolower($data['email']),'password'=>$data['password']]); event(new Registered($user)); Auth::login($user); $request->session()->regenerate(); return redirect()->route('verification.notice');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
// Application controller base class.
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
namespace App\Http\Controllers\Portal;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\License;
|
||||
use App\Models\LicenseDeliveryOutbox;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
final class PortalController extends Controller {
|
||||
private function owned(Request $request){ return License::with(['product','activations'])->whereRaw('lower(customer_email) = ?', [strtolower($request->user()->email)]); }
|
||||
public function index(Request $request){ return view('portal.index',['licenses'=>$this->owned($request)->latest()->get()]); }
|
||||
public function show(Request $request,License $license){ abort_unless(strtolower((string)$license->customer_email)===strtolower($request->user()->email),404); $license->load(['product','activations']); return view('portal.show',compact('license')); }
|
||||
public function deactivate(Request $request,License $license,int $activation){ abort_unless(strtolower((string)$license->customer_email)===strtolower($request->user()->email),404); $item=$license->activations()->whereKey($activation)->firstOrFail(); if(!$item->deactivated_at){$item->update(['deactivated_at'=>now()]);} return back()->with('status','Activation released.'); }
|
||||
public function resend(Request $request,License $license){ abort_unless(strtolower((string)$license->customer_email)===strtolower($request->user()->email),404); $out=LicenseDeliveryOutbox::where('license_id',$license->id)->first(); if(!$out) return back()->withErrors(['licence'=>'This licence was issued manually and its full key is not recoverable. Contact support.']); $plain=Crypt::decryptString($out->encrypted_license_key); Mail::raw("Your Rsync Studio licence key is:\n\n{$plain}\n\nKeep this key private.",fn($m)=>$m->to($request->user()->email)->subject('Your Rsync Studio licence key')); $out->update(['delivered_at'=>now()]); return back()->with('status','Licence key sent to your verified email address.'); }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
namespace App\Http\Middleware;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
final class AdminOnly {
|
||||
public function handle(Request $request, Closure $next): Response {
|
||||
abort_unless($request->user()?->is_admin === true, 403);
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class License extends Model
|
||||
{
|
||||
use HasUuids;
|
||||
public $incrementing = false;
|
||||
protected $keyType = 'string';
|
||||
protected $fillable = ['license_product_id','license_key_hash','license_key_last4','customer_name','customer_email','edition','activation_limit','max_major_version','expires_at','revoked_at','revoke_reason','payment_provider','external_order_id','external_customer_id'];
|
||||
protected $casts = ['expires_at'=>'datetime','revoked_at'=>'datetime','activation_limit'=>'integer','max_major_version'=>'integer'];
|
||||
public function product(): BelongsTo { return $this->belongsTo(LicenseProduct::class, 'license_product_id'); }
|
||||
public function activations(): HasMany { return $this->hasMany(LicenseActivation::class); }
|
||||
public function getIsUsableAttribute(): bool { return $this->revoked_at === null && ($this->expires_at === null || $this->expires_at->isFuture()); }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
namespace App\Models;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
class LicenseActivation extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
protected $fillable = ['license_id','installation_id','installation_name','platform','app_version','activated_at','last_seen_at','deactivated_at'];
|
||||
protected $casts = ['activated_at'=>'datetime','last_seen_at'=>'datetime','deactivated_at'=>'datetime'];
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
namespace App\Models;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
class LicenseDeliveryOutbox extends Model
|
||||
{
|
||||
protected $table = 'license_delivery_outbox';
|
||||
protected $fillable = ['license_id','encrypted_license_key','recipient_email','delivered_at'];
|
||||
protected $casts = ['delivered_at'=>'datetime'];
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class LicenseProduct extends Model
|
||||
{
|
||||
protected $fillable = ['code','name','edition','activation_limit','max_major_version','payment_provider','external_product_id','external_price_id','active'];
|
||||
protected $casts = ['active'=>'boolean','activation_limit'=>'integer','max_major_version'=>'integer'];
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
namespace App\Models;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
class LicenseWebhookEvent extends Model
|
||||
{
|
||||
protected $fillable = ['provider','external_event_id','event_type','payload_sha256','payload','processed_at','processing_error'];
|
||||
protected $casts = ['payload'=>'array','processed_at'=>'datetime'];
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
|
||||
class User extends Authenticatable implements MustVerifyEmail
|
||||
{
|
||||
use HasFactory, Notifiable;
|
||||
protected $fillable = ['name','email','password','is_admin'];
|
||||
protected $hidden = ['password','remember_token'];
|
||||
protected function casts(): array { return ['email_verified_at'=>'datetime','password'=>'hashed','is_admin'=>'boolean']; }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
final class RsyncStudioLicensingServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function boot(): void
|
||||
{
|
||||
RateLimiter::for('license-activation', function (Request $request): array {
|
||||
return [
|
||||
Limit::perMinute(12)->by($request->ip()),
|
||||
Limit::perMinute(6)->by(hash('sha256', (string) $request->input('installationID'))),
|
||||
];
|
||||
});
|
||||
|
||||
RateLimiter::for('login', fn (Request $request) => Limit::perMinute(5)
|
||||
->by(strtolower((string) $request->input('email')).'|'.$request->ip()));
|
||||
|
||||
RateLimiter::for('registration', fn (Request $request) => Limit::perHour(5)->by($request->ip()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
namespace App\Services\Licensing;
|
||||
|
||||
use App\Models\License;
|
||||
use App\Models\LicenseProduct;
|
||||
use App\Models\LicenseDeliveryOutbox;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
|
||||
final class LicenseIssuer
|
||||
{
|
||||
public function issue(LicenseProduct $product, ?string $name, ?string $email, ?string $provider = null, ?string $orderID = null, ?string $customerID = null, bool $queueDelivery = false): array
|
||||
{
|
||||
return DB::transaction(function () use ($product,$name,$email,$provider,$orderID,$customerID,$queueDelivery) {
|
||||
if ($provider && $orderID) {
|
||||
$existing = License::where('payment_provider',$provider)->where('external_order_id',$orderID)->first();
|
||||
if ($existing) return [$existing, null];
|
||||
}
|
||||
$plain = LicenseKey::generate();
|
||||
$license = License::create([
|
||||
'license_product_id'=>$product->id,
|
||||
'license_key_hash'=>LicenseKey::hash($plain),
|
||||
'license_key_last4'=>substr(LicenseKey::normalize($plain), -4),
|
||||
'customer_name'=>$name,
|
||||
'customer_email'=>$email ? strtolower(trim($email)) : null,
|
||||
'edition'=>$product->edition,
|
||||
'activation_limit'=>$product->activation_limit,
|
||||
'max_major_version'=>$product->max_major_version,
|
||||
'payment_provider'=>$provider,
|
||||
'external_order_id'=>$orderID,
|
||||
'external_customer_id'=>$customerID,
|
||||
]);
|
||||
if ($queueDelivery) {
|
||||
LicenseDeliveryOutbox::updateOrCreate(
|
||||
['license_id'=>$license->id],
|
||||
['encrypted_license_key'=>Crypt::encryptString($plain),'recipient_email'=>$license->customer_email,'delivered_at'=>null]
|
||||
);
|
||||
}
|
||||
return [$license, $plain];
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
namespace App\Services\Licensing;
|
||||
|
||||
final class LicenseKey
|
||||
{
|
||||
public static function generate(): string
|
||||
{
|
||||
$alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||
$raw = '';
|
||||
for ($i=0; $i<20; $i++) $raw .= $alphabet[random_int(0, strlen($alphabet)-1)];
|
||||
return implode('-', str_split($raw, 5));
|
||||
}
|
||||
|
||||
public static function normalize(string $value): string
|
||||
{
|
||||
return strtoupper(preg_replace('/[^A-Z0-9]/i', '', trim($value)) ?? '');
|
||||
}
|
||||
|
||||
public static function hash(string $value): string
|
||||
{
|
||||
return hash('sha256', self::normalize($value));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
namespace App\Services\Licensing;
|
||||
|
||||
use App\Models\License;
|
||||
use Carbon\CarbonInterface;
|
||||
use RuntimeException;
|
||||
|
||||
final class LicenseSigner
|
||||
{
|
||||
public function sign(License $license, string $installationID, ?CarbonInterface $issuedAt = null): string
|
||||
{
|
||||
$seed = $this->privateSeed();
|
||||
$pair = sodium_crypto_sign_seed_keypair($seed);
|
||||
$secret = sodium_crypto_sign_secretkey($pair);
|
||||
|
||||
$payload = [
|
||||
'customerEmail' => $license->customer_email,
|
||||
'customerName' => $license->customer_name,
|
||||
'edition' => $license->edition,
|
||||
'expiresAt' => $license->expires_at?->utc()->format('Y-m-d\\TH:i:s\\Z'),
|
||||
'installationID' => strtolower($installationID),
|
||||
'issuedAt' => ($issuedAt ?? now())->utc()->format('Y-m-d\\TH:i:s\\Z'),
|
||||
'licenseID' => strtolower((string) $license->id),
|
||||
'maxMajorVersion' => $license->max_major_version,
|
||||
];
|
||||
ksort($payload, SORT_STRING);
|
||||
$json = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
|
||||
$sig = sodium_crypto_sign_detached($json, $secret);
|
||||
return 'RS3.'.self::b64url($json).'.'.self::b64url($sig);
|
||||
}
|
||||
|
||||
public function publicKeyBase64(): string
|
||||
{
|
||||
$pair = sodium_crypto_sign_seed_keypair($this->privateSeed());
|
||||
return base64_encode(sodium_crypto_sign_publickey($pair));
|
||||
}
|
||||
|
||||
private function privateSeed(): string
|
||||
{
|
||||
$path = config('rsync-studio.private_key_path');
|
||||
if (!$path || !is_file($path)) throw new RuntimeException('RSYNC_STUDIO_LICENSE_PRIVATE_KEY_PATH is not configured or does not exist.');
|
||||
$decoded = base64_decode(trim((string) file_get_contents($path)), true);
|
||||
if ($decoded === false || strlen($decoded) !== SODIUM_CRYPTO_SIGN_SEEDBYTES) throw new RuntimeException('The Rsync Studio private key must be a Base64-encoded 32-byte Ed25519 seed.');
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
private static function b64url(string $value): string
|
||||
{
|
||||
return rtrim(strtr(base64_encode($value), '+/', '-_'), '=');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
namespace App\Services\Licensing;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class LicenseTokenVerifier
|
||||
{
|
||||
public function verify(string $token): array
|
||||
{
|
||||
$parts = explode('.', trim($token));
|
||||
if (count($parts) !== 3 || $parts[0] !== 'RS3') throw new RuntimeException('Invalid licence token.');
|
||||
$payload = $this->decode($parts[1]);
|
||||
$signature = $this->decode($parts[2]);
|
||||
$public = base64_decode((string) config('rsync-studio.public_key'), true);
|
||||
if ($public === false || strlen($public) !== SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES) throw new RuntimeException('Server licence public key is invalid.');
|
||||
if (!sodium_crypto_sign_verify_detached($signature, $payload, $public)) throw new RuntimeException('Licence token signature is invalid.');
|
||||
$decoded = json_decode($payload, true, flags: JSON_THROW_ON_ERROR);
|
||||
return is_array($decoded) ? $decoded : throw new RuntimeException('Licence token payload is invalid.');
|
||||
}
|
||||
private function decode(string $value): string
|
||||
{
|
||||
$value = strtr($value, '-_', '+/');
|
||||
$value .= str_repeat('=', (4 - strlen($value) % 4) % 4);
|
||||
$out = base64_decode($value, true);
|
||||
if ($out === false) throw new RuntimeException('Invalid Base64URL value.');
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
namespace App\Services\Payments;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use RuntimeException;
|
||||
|
||||
final class LemonSqueezyWebhookVerifier implements WebhookVerifier
|
||||
{
|
||||
public function verify(Request $request, string $rawBody): void
|
||||
{
|
||||
$secret = (string) config('rsync-studio.lemon_squeezy.webhook_secret');
|
||||
$signature = (string) $request->header('X-Signature');
|
||||
if ($secret === '' || $signature === '') throw new RuntimeException('Lemon Squeezy webhook verification is not configured.');
|
||||
$expected = hash_hmac('sha256',$rawBody,$secret);
|
||||
if (!hash_equals($expected,$signature)) throw new RuntimeException('Lemon Squeezy webhook signature is invalid.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
namespace App\Services\Payments;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use RuntimeException;
|
||||
|
||||
final class PaddleWebhookVerifier implements WebhookVerifier
|
||||
{
|
||||
public function verify(Request $request, string $rawBody): void
|
||||
{
|
||||
$secret = (string) config('rsync-studio.paddle.webhook_secret');
|
||||
$header = (string) $request->header('Paddle-Signature');
|
||||
if ($secret === '' || $header === '') throw new RuntimeException('Paddle webhook verification is not configured.');
|
||||
$parts = [];
|
||||
foreach (explode(';', $header) as $part) { [$k,$v] = array_pad(explode('=',trim($part),2),2,null); if ($k && $v) $parts[$k][]=$v; }
|
||||
$ts = isset($parts['ts'][0]) ? (int)$parts['ts'][0] : 0;
|
||||
$signatures = $parts['h1'] ?? [];
|
||||
$tolerance = max(1,(int)config('rsync-studio.paddle.timestamp_tolerance',300));
|
||||
if (!$ts || abs(time()-$ts)>$tolerance) throw new RuntimeException('Paddle webhook timestamp is outside the allowed tolerance.');
|
||||
$expected = hash_hmac('sha256', $ts.':'.$rawBody, $secret);
|
||||
foreach ($signatures as $signature) if (hash_equals($expected,$signature)) return;
|
||||
throw new RuntimeException('Paddle webhook signature is invalid.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
namespace App\Services\Payments;
|
||||
|
||||
use App\Models\License;
|
||||
use App\Models\LicenseProduct;
|
||||
use App\Services\Licensing\LicenseIssuer;
|
||||
use RuntimeException;
|
||||
|
||||
final class PurchaseProvisioner
|
||||
{
|
||||
public function __construct(private LicenseIssuer $issuer) {}
|
||||
|
||||
public function provision(string $provider, array $event): void
|
||||
{
|
||||
$normalized = match ($provider) {
|
||||
'paddle' => $this->fromPaddle($event),
|
||||
'lemon-squeezy' => $this->fromLemonSqueezy($event),
|
||||
default => throw new RuntimeException('Unsupported payment provider.'),
|
||||
};
|
||||
if ($normalized['action'] === 'ignore') return;
|
||||
if ($normalized['action'] === 'revoke') {
|
||||
License::where('payment_provider',$provider)->where('external_order_id',$normalized['orderID'])->whereNull('revoked_at')->update(['revoked_at'=>now(),'revoke_reason'=>'Payment refunded or reversed']);
|
||||
return;
|
||||
}
|
||||
$product = LicenseProduct::where('payment_provider',$provider)->where(function($q) use($normalized){ $q->where('external_price_id',$normalized['priceID'])->orWhere('external_product_id',$normalized['productID']); })->where('active',true)->first();
|
||||
if (!$product) throw new RuntimeException('No active licence product maps to this payment item.');
|
||||
$this->issuer->issue($product,$normalized['name'],$normalized['email'],$provider,$normalized['orderID'],$normalized['customerID'], true);
|
||||
}
|
||||
|
||||
private function fromPaddle(array $e): array
|
||||
{
|
||||
$type=(string)($e['event_type']??''); $d=$e['data']??[];
|
||||
if (in_array($type,['transaction.refunded','transaction.canceled'],true)) return ['action'=>'revoke','orderID'=>(string)($d['id']??''),'priceID'=>'','productID'=>'','name'=>null,'email'=>null,'customerID'=>null];
|
||||
if ($type!=='transaction.completed') return ['action'=>'ignore'];
|
||||
$item=$d['items'][0]??[]; $price=$item['price']??[]; $product=$price['product_id']??($item['product_id']??null);
|
||||
return ['action'=>'issue','orderID'=>(string)($d['id']??''),'priceID'=>(string)($price['id']??($item['price_id']??'')),'productID'=>(string)($product??''),'name'=>$d['custom_data']['customer_name']??null,'email'=>$d['custom_data']['customer_email']??null,'customerID'=>isset($d['customer_id'])?(string)$d['customer_id']:null];
|
||||
}
|
||||
|
||||
private function fromLemonSqueezy(array $e): array
|
||||
{
|
||||
$type=(string)($e['meta']['event_name']??''); $a=$e['data']['attributes']??[];
|
||||
$order=(string)($e['data']['id']??($a['order_id']??''));
|
||||
if (in_array($type,['order_refunded','subscription_expired'],true)) return ['action'=>'revoke','orderID'=>$order,'priceID'=>'','productID'=>'','name'=>null,'email'=>null,'customerID'=>null];
|
||||
if (!in_array($type,['order_created','subscription_created'],true)) return ['action'=>'ignore'];
|
||||
return ['action'=>'issue','orderID'=>$order,'priceID'=>(string)($a['variant_id']??''),'productID'=>(string)($a['product_id']??''),'name'=>$a['user_name']??null,'email'=>$a['user_email']??null,'customerID'=>isset($a['customer_id'])?(string)$a['customer_id']:null];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<?php
|
||||
namespace App\Services\Payments;
|
||||
use Illuminate\Http\Request;
|
||||
interface WebhookVerifier { public function verify(Request $request, string $rawBody): void; }
|
||||
Reference in New Issue
Block a user