diff --git a/app/Console/Commands/RsyncStudioAdminCreate.php b/app/Console/Commands/RsyncStudioAdminCreate.php index 6671411..ee52644 100644 --- a/app/Console/Commands/RsyncStudioAdminCreate.php +++ b/app/Console/Commands/RsyncStudioAdminCreate.php @@ -1,10 +1,36 @@ 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; } + +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; + } } diff --git a/app/Console/Commands/RsyncStudioLicenseIssue.php b/app/Console/Commands/RsyncStudioLicenseIssue.php index 886cefd..aa5d895 100644 --- a/app/Console/Commands/RsyncStudioLicenseIssue.php +++ b/app/Console/Commands/RsyncStudioLicenseIssue.php @@ -1,10 +1,29 @@ 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; } + 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; + } } diff --git a/app/Console/Commands/RsyncStudioLicenseRevoke.php b/app/Console/Commands/RsyncStudioLicenseRevoke.php index c608df3..f48204f 100644 --- a/app/Console/Commands/RsyncStudioLicenseRevoke.php +++ b/app/Console/Commands/RsyncStudioLicenseRevoke.php @@ -1,9 +1,22 @@ argument('id'));$l->update(['revoked_at'=>now(),'revoke_reason'=>$this->option('reason')]);$this->info('Licence revoked.');return self::SUCCESS; } + 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; + } } diff --git a/app/Console/Commands/RsyncStudioProductCreate.php b/app/Console/Commands/RsyncStudioProductCreate.php index 098b20d..6191cb4 100644 --- a/app/Console/Commands/RsyncStudioProductCreate.php +++ b/app/Console/Commands/RsyncStudioProductCreate.php @@ -1,10 +1,21 @@ $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; } + 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; + } } diff --git a/app/Http/Controllers/Admin/AdminController.php b/app/Http/Controllers/Admin/AdminController.php index 1d87ddc..6c2bb25 100644 --- a/app/Http/Controllers/Admin/AdminController.php +++ b/app/Http/Controllers/Admin/AdminController.php @@ -1,14 +1,49 @@ 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.');} + +final class AdminController extends Controller +{ + public function index() + { + return view('admin.index', ['licenseCount' => License::count(), 'activeCount' => License::whereNull('revoked_at')->count(), 'activationCount' => 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.'); + } } diff --git a/app/Http/Controllers/Api/PaymentWebhookController.php b/app/Http/Controllers/Api/PaymentWebhookController.php index afb68ce..e4c8036 100644 --- a/app/Http/Controllers/Api/PaymentWebhookController.php +++ b/app/Http/Controllers/Api/PaymentWebhookController.php @@ -1,12 +1,13 @@ 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); + $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; } + 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); } + } catch (\Throwable $e) { + report($e); + + return response()->json(['message' => 'Webhook could not be processed.'], 500); + } // Delivery is intentionally outside the webhook transaction. A temporary mail // outage must not roll back an otherwise valid Paddle event. Pending outbox // rows remain available for portal resend or a later webhook delivery attempt. - try { $delivery->deliverPending(10); } catch (\Throwable $e) { report($e); } + try { + $delivery->deliverPending(10); + } catch (\Throwable $e) { + report($e); + } - return response()->json(['ok'=>true]); + return response()->json(['ok' => true]); } } diff --git a/app/Http/Controllers/Auth/AuthenticatedSessionController.php b/app/Http/Controllers/Auth/AuthenticatedSessionController.php index 7622bce..d63878e 100644 --- a/app/Http/Controllers/Auth/AuthenticatedSessionController.php +++ b/app/Http/Controllers/Auth/AuthenticatedSessionController.php @@ -1,14 +1,35 @@ 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'); } + +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'); + } } diff --git a/app/Http/Controllers/Auth/EmailVerificationController.php b/app/Http/Controllers/Auth/EmailVerificationController.php index 1f020dc..2f38de1 100644 --- a/app/Http/Controllers/Auth/EmailVerificationController.php +++ b/app/Http/Controllers/Auth/EmailVerificationController.php @@ -1,10 +1,31 @@ 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.'); } + +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.'); + } } diff --git a/app/Http/Controllers/Auth/PasswordController.php b/app/Http/Controllers/Auth/PasswordController.php index 7a416a9..4eb6694 100644 --- a/app/Http/Controllers/Auth/PasswordController.php +++ b/app/Http/Controllers/Auth/PasswordController.php @@ -1,5 +1,7 @@ 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)]); } + +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)]); + } } diff --git a/app/Http/Controllers/Auth/RegisteredUserController.php b/app/Http/Controllers/Auth/RegisteredUserController.php index 035e0ef..3b5c073 100644 --- a/app/Http/Controllers/Auth/RegisteredUserController.php +++ b/app/Http/Controllers/Auth/RegisteredUserController.php @@ -1,15 +1,29 @@ 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'); - } + +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'); + } } diff --git a/app/Http/Controllers/Portal/PortalController.php b/app/Http/Controllers/Portal/PortalController.php index 7f59908..d76d20f 100644 --- a/app/Http/Controllers/Portal/PortalController.php +++ b/app/Http/Controllers/Portal/PortalController.php @@ -78,7 +78,7 @@ final class PortalController extends Controller { $this->authorise($request, $license); abort_unless($license->payment_provider === 'paddle' && $license->external_subscription_id, 422); - abort_unless(!in_array($license->subscription_status, ['canceled'], true), 422); + abort_unless(! in_array($license->subscription_status, ['canceled'], true), 422); try { $this->paddle->cancelSubscriptionImmediately($license->external_subscription_id); @@ -101,7 +101,7 @@ final class PortalController extends Controller { $this->authorise($request, $license); $item = $license->activations()->whereKey($activation)->firstOrFail(); - if (!$item->deactivated_at) { + if (! $item->deactivated_at) { $item->update(['deactivated_at' => now()]); } @@ -112,7 +112,7 @@ final class PortalController extends Controller { $this->authorise($request, $license); $out = LicenseDeliveryOutbox::where('license_id', $license->id)->first(); - if (!$out) { + if (! $out) { return back()->withErrors(['licence' => 'This licence was issued manually and its full key is not recoverable. Contact support.']); } diff --git a/app/Http/Middleware/AdminOnly.php b/app/Http/Middleware/AdminOnly.php index 6de2fcd..e7cd3a6 100644 --- a/app/Http/Middleware/AdminOnly.php +++ b/app/Http/Middleware/AdminOnly.php @@ -1,11 +1,17 @@ user()?->is_admin === true, 403); + return $next($request); } } diff --git a/app/Models/License.php b/app/Models/License.php index 9549d41..c3dcc56 100644 --- a/app/Models/License.php +++ b/app/Models/License.php @@ -1,4 +1,5 @@ 'datetime','revoked_at'=>'datetime','activation_limit'=>'integer','max_major_version'=>'integer','current_period_ends_at'=>'datetime','cancel_scheduled_at'=>'datetime']; - 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()); } + + 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', 'external_subscription_id', 'external_price_id', 'subscription_status', 'current_period_ends_at', 'cancel_scheduled_at']; + + protected $casts = ['expires_at' => 'datetime', 'revoked_at' => 'datetime', 'activation_limit' => 'integer', 'max_major_version' => 'integer', 'current_period_ends_at' => 'datetime', 'cancel_scheduled_at' => 'datetime']; + + 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()); + } } diff --git a/app/Models/LicenseActivation.php b/app/Models/LicenseActivation.php index 8139a60..8562d4b 100644 --- a/app/Models/LicenseActivation.php +++ b/app/Models/LicenseActivation.php @@ -1,9 +1,14 @@ 'datetime','last_seen_at'=>'datetime','deactivated_at'=>'datetime']; + + 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']; } diff --git a/app/Models/LicenseDeliveryOutbox.php b/app/Models/LicenseDeliveryOutbox.php index 63ae8ce..e0af182 100644 --- a/app/Models/LicenseDeliveryOutbox.php +++ b/app/Models/LicenseDeliveryOutbox.php @@ -1,9 +1,14 @@ 'datetime']; + + protected $fillable = ['license_id', 'encrypted_license_key', 'recipient_email', 'delivered_at']; + + protected $casts = ['delivered_at' => 'datetime']; } diff --git a/app/Models/LicenseProduct.php b/app/Models/LicenseProduct.php index 28cf89c..85ac9f5 100644 --- a/app/Models/LicenseProduct.php +++ b/app/Models/LicenseProduct.php @@ -1,10 +1,12 @@ 'boolean','activation_limit'=>'integer','max_major_version'=>'integer']; + 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']; } diff --git a/app/Models/LicenseWebhookEvent.php b/app/Models/LicenseWebhookEvent.php index ac1c2a2..38fea30 100644 --- a/app/Models/LicenseWebhookEvent.php +++ b/app/Models/LicenseWebhookEvent.php @@ -1,8 +1,12 @@ 'array','processed_at'=>'datetime']; + protected $fillable = ['provider', 'external_event_id', 'event_type', 'payload_sha256', 'payload', 'processed_at', 'processing_error']; + + protected $casts = ['payload' => 'array', 'processed_at' => 'datetime']; } diff --git a/app/Models/SupportTicket.php b/app/Models/SupportTicket.php index f12c556..6fa73ef 100644 --- a/app/Models/SupportTicket.php +++ b/app/Models/SupportTicket.php @@ -25,8 +25,15 @@ class SupportTicket extends Model ]; } - public function user(): BelongsTo { return $this->belongsTo(User::class); } - public function assignee(): BelongsTo { return $this->belongsTo(User::class, 'assigned_to'); } + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function assignee(): BelongsTo + { + return $this->belongsTo(User::class, 'assigned_to'); + } public function prunable(): Builder { diff --git a/app/Models/User.php b/app/Models/User.php index 2c45b19..ff6872c 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -1,4 +1,5 @@ 'datetime','password'=>'hashed','is_admin'=>'boolean']; } + + 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']; + } } diff --git a/app/Notifications/NewSupportTicket.php b/app/Notifications/NewSupportTicket.php index 6f76b82..a1528ae 100644 --- a/app/Notifications/NewSupportTicket.php +++ b/app/Notifications/NewSupportTicket.php @@ -4,9 +4,9 @@ namespace App\Notifications; use App\Models\SupportTicket; use Illuminate\Bus\Queueable; +use Illuminate\Notifications\AnonymousNotifiable; use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Notifications\Notification; -use Illuminate\Notifications\AnonymousNotifiable; class NewSupportTicket extends Notification { diff --git a/app/Services/Licensing/LicenseDeliveryService.php b/app/Services/Licensing/LicenseDeliveryService.php index 73637a7..94e804c 100644 --- a/app/Services/Licensing/LicenseDeliveryService.php +++ b/app/Services/Licensing/LicenseDeliveryService.php @@ -22,7 +22,9 @@ final class LicenseDeliveryService foreach ($pending as $outbox) { $plain = Crypt::decryptString($outbox->encrypted_license_key); $recipient = strtolower(trim((string) $outbox->recipient_email)); - if ($recipient === '') continue; + if ($recipient === '') { + continue; + } Mail::raw( "Thank you for choosing Rsync Studio.\n\nYour licence key is:\n\n{$plain}\n\nOpen Rsync Studio → Settings → Licence and enter this key to activate your Mac.\n\nKeep this key private. You can manage activations from the Rsync Studio licence portal.", diff --git a/app/Services/Licensing/LicenseIssuer.php b/app/Services/Licensing/LicenseIssuer.php index 6702788..ec51713 100644 --- a/app/Services/Licensing/LicenseIssuer.php +++ b/app/Services/Licensing/LicenseIssuer.php @@ -1,41 +1,45 @@ where('external_order_id',$orderID)->first(); - if ($existing) return [$existing, null]; + $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, + '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] + ['license_id' => $license->id], + ['encrypted_license_key' => Crypt::encryptString($plain), 'recipient_email' => $license->customer_email, 'delivered_at' => null] ); } + return [$license, $plain]; }); } diff --git a/app/Services/Licensing/LicenseKey.php b/app/Services/Licensing/LicenseKey.php index c1104ae..3e42361 100644 --- a/app/Services/Licensing/LicenseKey.php +++ b/app/Services/Licensing/LicenseKey.php @@ -1,4 +1,5 @@ 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.'); + 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.'); + 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; } diff --git a/app/Services/Licensing/LicenseTokenVerifier.php b/app/Services/Licensing/LicenseTokenVerifier.php index b461cbf..90b7b3b 100644 --- a/app/Services/Licensing/LicenseTokenVerifier.php +++ b/app/Services/Licensing/LicenseTokenVerifier.php @@ -1,4 +1,5 @@ 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.'); + 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.'); + if ($out === false) { + throw new RuntimeException('Invalid Base64URL value.'); + } + return $out; } } diff --git a/app/Services/Payments/LemonSqueezyWebhookVerifier.php b/app/Services/Payments/LemonSqueezyWebhookVerifier.php index d94d56a..3d7a49b 100644 --- a/app/Services/Payments/LemonSqueezyWebhookVerifier.php +++ b/app/Services/Payments/LemonSqueezyWebhookVerifier.php @@ -1,4 +1,5 @@ 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.'); + 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.'); + } } } diff --git a/app/Services/Payments/PaddleClient.php b/app/Services/Payments/PaddleClient.php index 7463176..6dd5d35 100644 --- a/app/Services/Payments/PaddleClient.php +++ b/app/Services/Payments/PaddleClient.php @@ -10,12 +10,17 @@ final class PaddleClient { public function customer(?string $customerID): ?array { - if (!$customerID || !config('rsync-studio.paddle.api_key')) return null; + if (! $customerID || ! config('rsync-studio.paddle.api_key')) { + return null; + } $response = $this->request()->get('/customers/'.$customerID); - if (!$response->successful()) return null; + if (! $response->successful()) { + return null; + } $data = $response->json('data'); + return is_array($data) ? $data : null; } @@ -62,13 +67,13 @@ final class PaddleClient private function subscriptionData(Response $response, string $fallback): array { - if (!$response->successful()) { + if (! $response->successful()) { $detail = trim((string) ($response->json('error.detail') ?? '')); throw new RuntimeException($detail !== '' ? $detail : $fallback); } $data = $response->json('data'); - if (!is_array($data)) { + if (! is_array($data)) { throw new RuntimeException($fallback); } diff --git a/app/Services/Payments/PaddleWebhookVerifier.php b/app/Services/Payments/PaddleWebhookVerifier.php index bce9424..813827d 100644 --- a/app/Services/Payments/PaddleWebhookVerifier.php +++ b/app/Services/Payments/PaddleWebhookVerifier.php @@ -1,4 +1,5 @@ header('Paddle-Signature'); - if ($secret === '' || $header === '') throw new RuntimeException('Paddle webhook verification is not configured.'); + 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; + 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.'); + $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; + foreach ($signatures as $signature) { + if (hash_equals($expected, $signature)) { + return; + } + } throw new RuntimeException('Paddle webhook signature is invalid.'); } } diff --git a/app/Services/Payments/PurchaseProvisioner.php b/app/Services/Payments/PurchaseProvisioner.php index 6a3c7fd..2cc9680 100644 --- a/app/Services/Payments/PurchaseProvisioner.php +++ b/app/Services/Payments/PurchaseProvisioner.php @@ -16,11 +16,13 @@ final class PurchaseProvisioner { if ($provider === 'paddle') { $this->provisionPaddle($event); + return; } if ($provider === 'lemon-squeezy') { $this->provisionLemonSqueezy($event); + return; } @@ -34,11 +36,13 @@ final class PurchaseProvisioner if (str_starts_with($type, 'subscription.')) { $this->syncPaddleSubscription($type, $data); + return; } if ($type === 'transaction.completed') { $this->syncPaddleTransaction($data); + return; } @@ -71,7 +75,7 @@ final class PurchaseProvisioner ->where('external_subscription_id', $subscriptionID) ->first(); - if (!$license) { + if (! $license) { $product = $this->mappedProduct('paddle', $priceID, $productID); $custom = is_array($data['custom_data'] ?? null) ? $data['custom_data'] : []; $customer = $this->paddle->customer($this->nullableString($data['customer_id'] ?? null)); @@ -136,6 +140,7 @@ final class PurchaseProvisioner ->where('external_subscription_id', $subscriptionID) ->whereNull('external_order_id') ->update(['external_order_id' => $transactionID ?: null]); + return; } @@ -170,10 +175,11 @@ final class PurchaseProvisioner ->where('external_order_id', $order) ->whereNull('revoked_at') ->update(['revoked_at' => now(), 'revoke_reason' => 'Payment refunded or subscription expired']); + return; } - if (!in_array($type, ['order_created', 'subscription_created'], true)) { + if (! in_array($type, ['order_created', 'subscription_created'], true)) { return; } @@ -210,7 +216,7 @@ final class PurchaseProvisioner ->first(); } - if (!$product) { + if (! $product) { throw new RuntimeException("No active licence product maps to {$provider} price {$priceID} / product {$productID}."); } @@ -224,13 +230,17 @@ final class PurchaseProvisioner return $item; } } + return $items[0] ?? []; } private function nullableString(mixed $value): ?string { - if (!is_scalar($value)) return null; + if (! is_scalar($value)) { + return null; + } $value = trim((string) $value); + return $value === '' ? null : $value; } } diff --git a/app/Services/Payments/WebhookVerifier.php b/app/Services/Payments/WebhookVerifier.php index 5f31a79..984c28e 100644 --- a/app/Services/Payments/WebhookVerifier.php +++ b/app/Services/Payments/WebhookVerifier.php @@ -1,4 +1,10 @@ 'personal-monthly','name'=>'Rsync Studio Personal Monthly','edition'=>'Personal','activation_limit'=>1,'external_product_id'=>config('rsync-studio.paddle.personal_product_id'),'external_price_id'=>config('rsync-studio.paddle.personal_monthly_price_id')], - ['code'=>'personal-annual','name'=>'Rsync Studio Personal Annual','edition'=>'Personal','activation_limit'=>1,'external_product_id'=>config('rsync-studio.paddle.personal_product_id'),'external_price_id'=>config('rsync-studio.paddle.personal_annual_price_id')], - ['code'=>'professional-monthly','name'=>'Rsync Studio Professional Monthly','edition'=>'Professional','activation_limit'=>3,'external_product_id'=>config('rsync-studio.paddle.professional_product_id'),'external_price_id'=>config('rsync-studio.paddle.professional_monthly_price_id')], - ['code'=>'professional-annual','name'=>'Rsync Studio Professional Annual','edition'=>'Professional','activation_limit'=>3,'external_product_id'=>config('rsync-studio.paddle.professional_product_id'),'external_price_id'=>config('rsync-studio.paddle.professional_annual_price_id')], + ['code' => 'personal-monthly', 'name' => 'Rsync Studio Personal Monthly', 'edition' => 'Personal', 'activation_limit' => 1, 'external_product_id' => config('rsync-studio.paddle.personal_product_id'), 'external_price_id' => config('rsync-studio.paddle.personal_monthly_price_id')], + ['code' => 'personal-annual', 'name' => 'Rsync Studio Personal Annual', 'edition' => 'Personal', 'activation_limit' => 1, 'external_product_id' => config('rsync-studio.paddle.personal_product_id'), 'external_price_id' => config('rsync-studio.paddle.personal_annual_price_id')], + ['code' => 'professional-monthly', 'name' => 'Rsync Studio Professional Monthly', 'edition' => 'Professional', 'activation_limit' => 3, 'external_product_id' => config('rsync-studio.paddle.professional_product_id'), 'external_price_id' => config('rsync-studio.paddle.professional_monthly_price_id')], + ['code' => 'professional-annual', 'name' => 'Rsync Studio Professional Annual', 'edition' => 'Professional', 'activation_limit' => 3, 'external_product_id' => config('rsync-studio.paddle.professional_product_id'), 'external_price_id' => config('rsync-studio.paddle.professional_annual_price_id')], ]; foreach ($products as $data) { - if (!$data['external_product_id'] || !$data['external_price_id']) continue; + if (! $data['external_product_id'] || ! $data['external_price_id']) { + continue; + } LicenseProduct::updateOrCreate( - ['code'=>$data['code']], - $data + ['payment_provider'=>'paddle','max_major_version'=>config('rsync-studio.default_max_major_version'),'active'=>true] + ['code' => $data['code']], + $data + ['payment_provider' => 'paddle', 'max_major_version' => config('rsync-studio.default_max_major_version'), 'active' => true] ); } } diff --git a/tests/Feature/LicenseActivationTest.php b/tests/Feature/LicenseActivationTest.php index 4615edb..7d81a87 100644 --- a/tests/Feature/LicenseActivationTest.php +++ b/tests/Feature/LicenseActivationTest.php @@ -1,8 +1,9 @@ 'test','name'=>'Test','edition'=>'Professional','activation_limit'=>1,'max_major_version'=>1,'active'=>true]); - [, $key]=app(LicenseIssuer::class)->issue($product,'Test','test@example.com'); - $one=['licenseKey'=>$key,'installationID'=>'11111111-1111-4111-8111-111111111111','appVersion'=>'1.0.0','platform'=>'macOS']; - $this->postJson('/api/v1/activate',$one)->assertOk()->assertJsonStructure(['licenseToken']); - $this->postJson('/api/v1/activate',$one)->assertOk(); - $this->postJson('/api/v1/activate',array_merge($one,['installationID'=>'22222222-2222-4222-8222-222222222222']))->assertStatus(409); + $product = LicenseProduct::create(['code' => 'test', 'name' => 'Test', 'edition' => 'Professional', 'activation_limit' => 1, 'max_major_version' => 1, 'active' => true]); + [, $key] = app(LicenseIssuer::class)->issue($product, 'Test', 'test@example.com'); + $one = ['licenseKey' => $key, 'installationID' => '11111111-1111-4111-8111-111111111111', 'appVersion' => '1.0.0', 'platform' => 'macOS']; + $this->postJson('/api/v1/activate', $one)->assertOk()->assertJsonStructure(['licenseToken']); + $this->postJson('/api/v1/activate', $one)->assertOk(); + $this->postJson('/api/v1/activate', array_merge($one, ['installationID' => '22222222-2222-4222-8222-222222222222']))->assertStatus(409); } + public function test_validation_refreshes_an_active_installation_without_calling_paddle(): void { $product = LicenseProduct::create([ @@ -79,5 +82,4 @@ class LicenseActivationTest extends TestCase 'message' => 'This Mac has been deactivated from the licence portal.', ]); } - } diff --git a/tests/Feature/PaddleSubscriptionProvisioningTest.php b/tests/Feature/PaddleSubscriptionProvisioningTest.php index a2f9ee5..527216e 100644 --- a/tests/Feature/PaddleSubscriptionProvisioningTest.php +++ b/tests/Feature/PaddleSubscriptionProvisioningTest.php @@ -36,12 +36,12 @@ class PaddleSubscriptionProvisioningTest extends TestCase 'status' => $status, 'customer_id' => 'ctm_test', 'transaction_id' => 'txn_test', - 'custom_data' => ['customer_name'=>'Mark','customer_email'=>'mark@example.com'], - 'current_billing_period' => ['starts_at'=>'2026-09-09T00:00:00Z','ends_at'=>'2026-10-09T00:00:00Z'], + 'custom_data' => ['customer_name' => 'Mark', 'customer_email' => 'mark@example.com'], + 'current_billing_period' => ['starts_at' => '2026-09-09T00:00:00Z', 'ends_at' => '2026-10-09T00:00:00Z'], 'scheduled_change' => null, 'items' => [[ 'recurring' => true, - 'price' => ['id'=>$price,'product_id'=>$product], + 'price' => ['id' => $price, 'product_id' => $product], ]], ], $extra), ]; @@ -83,7 +83,7 @@ class PaddleSubscriptionProvisioningTest extends TestCase $this->product('pri_personal_monthly', 'Personal', 1); app(PurchaseProvisioner::class)->provision('paddle', $this->subscription( 'subscription.updated', 'active', 'pri_personal_monthly', 'pro_personal', - ['data'=>['scheduled_change'=>['action'=>'cancel','effective_at'=>'2026-10-09T00:00:00Z']]] + ['data' => ['scheduled_change' => ['action' => 'cancel', 'effective_at' => '2026-10-09T00:00:00Z']]] )); $license = License::firstOrFail(); @@ -96,7 +96,7 @@ class PaddleSubscriptionProvisioningTest extends TestCase $this->product('pri_personal_monthly', 'Personal', 1); $service = app(PurchaseProvisioner::class); $service->provision('paddle', $this->subscription('subscription.created', 'active', 'pri_personal_monthly', 'pro_personal')); - $service->provision('paddle', $this->subscription('subscription.canceled', 'canceled', 'pri_personal_monthly', 'pro_personal', ['data'=>['current_billing_period'=>null]])); + $service->provision('paddle', $this->subscription('subscription.canceled', 'canceled', 'pri_personal_monthly', 'pro_personal', ['data' => ['current_billing_period' => null]])); $license = License::firstOrFail(); $this->assertNotNull($license->revoked_at);