4 Commits
Author SHA1 Message Date
Mark Gregory 627f578cd8 cod consolidation and style updates 2026-09-10 09:42:16 +01:00
Mark Gregory 8dd7d76b8e cod consolidation and style updates 2026-09-10 09:41:59 +01:00
Mark Gregory 2648a8f5b5 cod consolidation 2026-09-10 09:41:32 +01:00
Mark Gregory 7037ad983a new front end 2026-09-10 09:23:56 +01:00
48 changed files with 629 additions and 194 deletions
@@ -1,10 +1,36 @@
<?php <?php
namespace App\Console\Commands; namespace App\Console\Commands;
use App\Models\User; use App\Models\User;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str; 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'; final class RsyncStudioAdminCreate extends Command
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; } {
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;
}
} }
@@ -1,10 +1,29 @@
<?php <?php
namespace App\Console\Commands; namespace App\Console\Commands;
use App\Models\LicenseProduct; use App\Models\LicenseProduct;
use App\Services\Licensing\LicenseIssuer; use App\Services\Licensing\LicenseIssuer;
use Illuminate\Console\Command; use Illuminate\Console\Command;
class RsyncStudioLicenseIssue extends Command class RsyncStudioLicenseIssue extends Command
{ {
protected $signature='rsync-studio:license:issue {product} {--name=} {--email=}'; protected $description='Issue a manual Rsync Studio licence key'; protected $signature = 'rsync-studio:license:issue {product} {--name=} {--email=}';
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; }
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;
}
} }
@@ -1,9 +1,22 @@
<?php <?php
namespace App\Console\Commands; namespace App\Console\Commands;
use App\Models\License; use App\Models\License;
use Illuminate\Console\Command; use Illuminate\Console\Command;
class RsyncStudioLicenseRevoke extends Command class RsyncStudioLicenseRevoke extends Command
{ {
protected $signature='rsync-studio:license:revoke {id} {--reason=Administrative revocation}'; protected $description='Revoke a Rsync Studio licence'; protected $signature = 'rsync-studio:license:revoke {id} {--reason=Administrative revocation}';
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; }
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;
}
} }
@@ -1,10 +1,21 @@
<?php <?php
namespace App\Console\Commands; namespace App\Console\Commands;
use App\Models\LicenseProduct; use App\Models\LicenseProduct;
use Illuminate\Console\Command; use Illuminate\Console\Command;
class RsyncStudioProductCreate extends 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 $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; } 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;
}
} }
+42 -7
View File
@@ -1,14 +1,49 @@
<?php <?php
namespace App\Http\Controllers\Admin; namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\License; use App\Models\License;
use App\Models\LicenseProduct; use App\Models\LicenseActivation;
use App\Models\User; use App\Models\User;
use Illuminate\Http\Request; 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()]); } final class AdminController extends Controller
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 index()
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.');} 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.');
}
} }
@@ -1,12 +1,13 @@
<?php <?php
namespace App\Http\Controllers\Api; namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\LicenseWebhookEvent; use App\Models\LicenseWebhookEvent;
use App\Services\Licensing\LicenseDeliveryService;
use App\Services\Payments\LemonSqueezyWebhookVerifier; use App\Services\Payments\LemonSqueezyWebhookVerifier;
use App\Services\Payments\PaddleWebhookVerifier; use App\Services\Payments\PaddleWebhookVerifier;
use App\Services\Payments\PurchaseProvisioner; use App\Services\Payments\PurchaseProvisioner;
use App\Services\Licensing\LicenseDeliveryService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@@ -15,30 +16,63 @@ final class PaymentWebhookController extends Controller
{ {
public function __invoke(string $provider, Request $request, PurchaseProvisioner $provisioner, LicenseDeliveryService $delivery): JsonResponse public function __invoke(string $provider, Request $request, PurchaseProvisioner $provisioner, LicenseDeliveryService $delivery): JsonResponse
{ {
$raw=(string)$request->getContent(); $raw = (string) $request->getContent();
$verifier=match($provider){'paddle'=>app(PaddleWebhookVerifier::class),'lemon-squeezy'=>app(LemonSqueezyWebhookVerifier::class),default=>null}; $verifier = match ($provider) {
if (!$verifier) return response()->json(['message'=>'Unknown webhook provider.'],404); 'paddle' => app(PaddleWebhookVerifier::class),'lemon-squeezy' => app(LemonSqueezyWebhookVerifier::class),default => null
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); } if (! $verifier) {
$externalID=(string)($provider==='paddle'?($payload['event_id']??''):($payload['meta']['event_name']??'').':'.($payload['data']['id']??'')); return response()->json(['message' => 'Unknown webhook provider.'], 404);
$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 {
$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 { try {
DB::transaction(function() use($provider,$externalID,$eventType,$payload,$raw,$provisioner){ 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]); $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 ($event->processed_at) {
if (!hash_equals($event->payload_sha256,hash('sha256',$raw))) throw new \RuntimeException('Webhook ID was reused with a different payload.'); return;
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; } 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 // Delivery is intentionally outside the webhook transaction. A temporary mail
// outage must not roll back an otherwise valid Paddle event. Pending outbox // outage must not roll back an otherwise valid Paddle event. Pending outbox
// rows remain available for portal resend or a later webhook delivery attempt. // 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]);
} }
} }
@@ -1,14 +1,35 @@
<?php <?php
namespace App\Http\Controllers\Auth; namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
final class AuthenticatedSessionController extends Controller {
public function create(){ return view('auth.login'); } final class AuthenticatedSessionController extends Controller
public function store(Request $request){ {
$credentials=$request->validate(['email'=>['required','email'],'password'=>['required','string']]); public function create()
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')); return view('auth.login');
} }
public function destroy(Request $request){ Auth::logout(); $request->session()->invalidate(); $request->session()->regenerateToken(); return redirect()->route('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');
}
} }
@@ -1,10 +1,31 @@
<?php <?php
namespace App\Http\Controllers\Auth; namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use Illuminate\Foundation\Auth\EmailVerificationRequest; use Illuminate\Foundation\Auth\EmailVerificationRequest;
use Illuminate\Http\Request; use Illuminate\Http\Request;
final class EmailVerificationController extends Controller {
public function notice(){ return view('auth.verify-email'); } final class EmailVerificationController extends Controller
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.'); } 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.');
}
} }
@@ -1,5 +1,7 @@
<?php <?php
namespace App\Http\Controllers\Auth; namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\User; use App\Models\User;
use Illuminate\Auth\Events\PasswordReset; use Illuminate\Auth\Events\PasswordReset;
@@ -7,9 +9,35 @@ use Illuminate\Http\Request;
use Illuminate\Support\Facades\Password; use Illuminate\Support\Facades\Password;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\Validation\Rules\Password as PasswordRule; use Illuminate\Validation\Rules\Password as PasswordRule;
final class PasswordController extends Controller {
public function requestForm(){ return view('auth.forgot-password'); } final class PasswordController extends Controller
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 requestForm()
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)]); } {
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)]);
}
} }
@@ -1,15 +1,29 @@
<?php <?php
namespace App\Http\Controllers\Auth; namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\User; use App\Models\User;
use Illuminate\Auth\Events\Registered; use Illuminate\Auth\Events\Registered;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\Rules\Password; use Illuminate\Validation\Rules\Password;
final class RegisteredUserController extends Controller {
public function create(){ return view('auth.register'); } final class RegisteredUserController extends Controller
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()]]); public function create()
$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'); {
} 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');
}
} }
@@ -78,7 +78,7 @@ final class PortalController extends Controller
{ {
$this->authorise($request, $license); $this->authorise($request, $license);
abort_unless($license->payment_provider === 'paddle' && $license->external_subscription_id, 422); 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 { try {
$this->paddle->cancelSubscriptionImmediately($license->external_subscription_id); $this->paddle->cancelSubscriptionImmediately($license->external_subscription_id);
@@ -101,7 +101,7 @@ final class PortalController extends Controller
{ {
$this->authorise($request, $license); $this->authorise($request, $license);
$item = $license->activations()->whereKey($activation)->firstOrFail(); $item = $license->activations()->whereKey($activation)->firstOrFail();
if (!$item->deactivated_at) { if (! $item->deactivated_at) {
$item->update(['deactivated_at' => now()]); $item->update(['deactivated_at' => now()]);
} }
@@ -112,7 +112,7 @@ final class PortalController extends Controller
{ {
$this->authorise($request, $license); $this->authorise($request, $license);
$out = LicenseDeliveryOutbox::where('license_id', $license->id)->first(); $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.']); return back()->withErrors(['licence' => 'This licence was issued manually and its full key is not recoverable. Contact support.']);
} }
+8 -2
View File
@@ -1,11 +1,17 @@
<?php <?php
namespace App\Http\Middleware; namespace App\Http\Middleware;
use Closure; use Closure;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
final class AdminOnly {
public function handle(Request $request, Closure $next): Response { final class AdminOnly
{
public function handle(Request $request, Closure $next): Response
{
abort_unless($request->user()?->is_admin === true, 403); abort_unless($request->user()?->is_admin === true, 403);
return $next($request); return $next($request);
} }
} }
+22 -5
View File
@@ -1,4 +1,5 @@
<?php <?php
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Concerns\HasUuids;
@@ -9,11 +10,27 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
class License extends Model class License extends Model
{ {
use HasUuids; use HasUuids;
public $incrementing = false; public $incrementing = false;
protected $keyType = 'string'; 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','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']; 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'];
public function product(): BelongsTo { return $this->belongsTo(LicenseProduct::class, 'license_product_id'); }
public function activations(): HasMany { return $this->hasMany(LicenseActivation::class); } 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 getIsUsableAttribute(): bool { return $this->revoked_at === null && ($this->expires_at === null || $this->expires_at->isFuture()); }
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());
}
} }
+7 -2
View File
@@ -1,9 +1,14 @@
<?php <?php
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
class LicenseActivation extends Model class LicenseActivation extends Model
{ {
public $timestamps = false; 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']; 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'];
} }
+7 -2
View File
@@ -1,9 +1,14 @@
<?php <?php
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
class LicenseDeliveryOutbox extends Model class LicenseDeliveryOutbox extends Model
{ {
protected $table = 'license_delivery_outbox'; protected $table = 'license_delivery_outbox';
protected $fillable = ['license_id','encrypted_license_key','recipient_email','delivered_at'];
protected $casts = ['delivered_at'=>'datetime']; protected $fillable = ['license_id', 'encrypted_license_key', 'recipient_email', 'delivered_at'];
protected $casts = ['delivered_at' => 'datetime'];
} }
+4 -2
View File
@@ -1,10 +1,12 @@
<?php <?php
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
class LicenseProduct extends 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 $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'];
protected $casts = ['active' => 'boolean', 'activation_limit' => 'integer', 'max_major_version' => 'integer'];
} }
+6 -2
View File
@@ -1,8 +1,12 @@
<?php <?php
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
class LicenseWebhookEvent extends Model class LicenseWebhookEvent extends Model
{ {
protected $fillable = ['provider','external_event_id','event_type','payload_sha256','payload','processed_at','processing_error']; protected $fillable = ['provider', 'external_event_id', 'event_type', 'payload_sha256', 'payload', 'processed_at', 'processing_error'];
protected $casts = ['payload'=>'array','processed_at'=>'datetime'];
protected $casts = ['payload' => 'array', 'processed_at' => 'datetime'];
} }
+9 -2
View File
@@ -25,8 +25,15 @@ class SupportTicket extends Model
]; ];
} }
public function user(): BelongsTo { return $this->belongsTo(User::class); } public function user(): BelongsTo
public function assignee(): BelongsTo { return $this->belongsTo(User::class, 'assigned_to'); } {
return $this->belongsTo(User::class);
}
public function assignee(): BelongsTo
{
return $this->belongsTo(User::class, 'assigned_to');
}
public function prunable(): Builder public function prunable(): Builder
{ {
+10 -3
View File
@@ -1,4 +1,5 @@
<?php <?php
namespace App\Models; namespace App\Models;
use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Contracts\Auth\MustVerifyEmail;
@@ -9,7 +10,13 @@ use Illuminate\Notifications\Notifiable;
class User extends Authenticatable implements MustVerifyEmail class User extends Authenticatable implements MustVerifyEmail
{ {
use HasFactory, Notifiable; use HasFactory, Notifiable;
protected $fillable = ['name','email','password','is_admin'];
protected $hidden = ['password','remember_token']; protected $fillable = ['name', 'email', 'password', 'is_admin'];
protected function casts(): array { return ['email_verified_at'=>'datetime','password'=>'hashed','is_admin'=>'boolean']; }
protected $hidden = ['password', 'remember_token'];
protected function casts(): array
{
return ['email_verified_at' => 'datetime', 'password' => 'hashed', 'is_admin' => 'boolean'];
}
} }
+1 -1
View File
@@ -4,9 +4,9 @@ namespace App\Notifications;
use App\Models\SupportTicket; use App\Models\SupportTicket;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Notifications\AnonymousNotifiable;
use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification; use Illuminate\Notifications\Notification;
use Illuminate\Notifications\AnonymousNotifiable;
class NewSupportTicket extends Notification class NewSupportTicket extends Notification
{ {
@@ -22,7 +22,9 @@ final class LicenseDeliveryService
foreach ($pending as $outbox) { foreach ($pending as $outbox) {
$plain = Crypt::decryptString($outbox->encrypted_license_key); $plain = Crypt::decryptString($outbox->encrypted_license_key);
$recipient = strtolower(trim((string) $outbox->recipient_email)); $recipient = strtolower(trim((string) $outbox->recipient_email));
if ($recipient === '') continue; if ($recipient === '') {
continue;
}
Mail::raw( 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.", "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.",
+22 -18
View File
@@ -1,41 +1,45 @@
<?php <?php
namespace App\Services\Licensing; namespace App\Services\Licensing;
use App\Models\License; use App\Models\License;
use App\Models\LicenseProduct;
use App\Models\LicenseDeliveryOutbox; use App\Models\LicenseDeliveryOutbox;
use Illuminate\Support\Facades\DB; use App\Models\LicenseProduct;
use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
final class LicenseIssuer 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 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) { return DB::transaction(function () use ($product, $name, $email, $provider, $orderID, $customerID, $queueDelivery) {
if ($provider && $orderID) { if ($provider && $orderID) {
$existing = License::where('payment_provider',$provider)->where('external_order_id',$orderID)->first(); $existing = License::where('payment_provider', $provider)->where('external_order_id', $orderID)->first();
if ($existing) return [$existing, null]; if ($existing) {
return [$existing, null];
}
} }
$plain = LicenseKey::generate(); $plain = LicenseKey::generate();
$license = License::create([ $license = License::create([
'license_product_id'=>$product->id, 'license_product_id' => $product->id,
'license_key_hash'=>LicenseKey::hash($plain), 'license_key_hash' => LicenseKey::hash($plain),
'license_key_last4'=>substr(LicenseKey::normalize($plain), -4), 'license_key_last4' => substr(LicenseKey::normalize($plain), -4),
'customer_name'=>$name, 'customer_name' => $name,
'customer_email'=>$email ? strtolower(trim($email)) : null, 'customer_email' => $email ? strtolower(trim($email)) : null,
'edition'=>$product->edition, 'edition' => $product->edition,
'activation_limit'=>$product->activation_limit, 'activation_limit' => $product->activation_limit,
'max_major_version'=>$product->max_major_version, 'max_major_version' => $product->max_major_version,
'payment_provider'=>$provider, 'payment_provider' => $provider,
'external_order_id'=>$orderID, 'external_order_id' => $orderID,
'external_customer_id'=>$customerID, 'external_customer_id' => $customerID,
]); ]);
if ($queueDelivery) { if ($queueDelivery) {
LicenseDeliveryOutbox::updateOrCreate( LicenseDeliveryOutbox::updateOrCreate(
['license_id'=>$license->id], ['license_id' => $license->id],
['encrypted_license_key'=>Crypt::encryptString($plain),'recipient_email'=>$license->customer_email,'delivered_at'=>null] ['encrypted_license_key' => Crypt::encryptString($plain), 'recipient_email' => $license->customer_email, 'delivered_at' => null]
); );
} }
return [$license, $plain]; return [$license, $plain];
}); });
} }
+5 -1
View File
@@ -1,4 +1,5 @@
<?php <?php
namespace App\Services\Licensing; namespace App\Services\Licensing;
final class LicenseKey final class LicenseKey
@@ -7,7 +8,10 @@ final class LicenseKey
{ {
$alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; $alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
$raw = ''; $raw = '';
for ($i=0; $i<20; $i++) $raw .= $alphabet[random_int(0, strlen($alphabet)-1)]; for ($i = 0; $i < 20; $i++) {
$raw .= $alphabet[random_int(0, strlen($alphabet) - 1)];
}
return implode('-', str_split($raw, 5)); return implode('-', str_split($raw, 5));
} }
+10 -2
View File
@@ -1,4 +1,5 @@
<?php <?php
namespace App\Services\Licensing; namespace App\Services\Licensing;
use App\Models\License; use App\Models\License;
@@ -26,21 +27,28 @@ final class LicenseSigner
ksort($payload, SORT_STRING); ksort($payload, SORT_STRING);
$json = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); $json = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
$sig = sodium_crypto_sign_detached($json, $secret); $sig = sodium_crypto_sign_detached($json, $secret);
return 'RS3.'.self::b64url($json).'.'.self::b64url($sig); return 'RS3.'.self::b64url($json).'.'.self::b64url($sig);
} }
public function publicKeyBase64(): string public function publicKeyBase64(): string
{ {
$pair = sodium_crypto_sign_seed_keypair($this->privateSeed()); $pair = sodium_crypto_sign_seed_keypair($this->privateSeed());
return base64_encode(sodium_crypto_sign_publickey($pair)); return base64_encode(sodium_crypto_sign_publickey($pair));
} }
private function privateSeed(): string private function privateSeed(): string
{ {
$path = config('rsync-studio.private_key_path'); $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); $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; return $decoded;
} }
@@ -1,4 +1,5 @@
<?php <?php
namespace App\Services\Licensing; namespace App\Services\Licensing;
use RuntimeException; use RuntimeException;
@@ -8,21 +9,32 @@ final class LicenseTokenVerifier
public function verify(string $token): array public function verify(string $token): array
{ {
$parts = explode('.', trim($token)); $parts = explode('.', trim($token));
if (count($parts) !== 3 || $parts[0] !== 'RS3') throw new RuntimeException('Invalid licence token.'); if (count($parts) !== 3 || $parts[0] !== 'RS3') {
throw new RuntimeException('Invalid licence token.');
}
$payload = $this->decode($parts[1]); $payload = $this->decode($parts[1]);
$signature = $this->decode($parts[2]); $signature = $this->decode($parts[2]);
$public = base64_decode((string) config('rsync-studio.public_key'), true); $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 ($public === false || strlen($public) !== SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES) {
if (!sodium_crypto_sign_verify_detached($signature, $payload, $public)) throw new RuntimeException('Licence token signature is invalid.'); 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); $decoded = json_decode($payload, true, flags: JSON_THROW_ON_ERROR);
return is_array($decoded) ? $decoded : throw new RuntimeException('Licence token payload is invalid.'); return is_array($decoded) ? $decoded : throw new RuntimeException('Licence token payload is invalid.');
} }
private function decode(string $value): string private function decode(string $value): string
{ {
$value = strtr($value, '-_', '+/'); $value = strtr($value, '-_', '+/');
$value .= str_repeat('=', (4 - strlen($value) % 4) % 4); $value .= str_repeat('=', (4 - strlen($value) % 4) % 4);
$out = base64_decode($value, true); $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; return $out;
} }
} }
@@ -1,4 +1,5 @@
<?php <?php
namespace App\Services\Payments; namespace App\Services\Payments;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -10,8 +11,12 @@ final class LemonSqueezyWebhookVerifier implements WebhookVerifier
{ {
$secret = (string) config('rsync-studio.lemon_squeezy.webhook_secret'); $secret = (string) config('rsync-studio.lemon_squeezy.webhook_secret');
$signature = (string) $request->header('X-Signature'); $signature = (string) $request->header('X-Signature');
if ($secret === '' || $signature === '') throw new RuntimeException('Lemon Squeezy webhook verification is not configured.'); if ($secret === '' || $signature === '') {
$expected = hash_hmac('sha256',$rawBody,$secret); throw new RuntimeException('Lemon Squeezy webhook verification is not configured.');
if (!hash_equals($expected,$signature)) throw new RuntimeException('Lemon Squeezy webhook signature is invalid.'); }
$expected = hash_hmac('sha256', $rawBody, $secret);
if (! hash_equals($expected, $signature)) {
throw new RuntimeException('Lemon Squeezy webhook signature is invalid.');
}
} }
} }
+9 -4
View File
@@ -10,12 +10,17 @@ final class PaddleClient
{ {
public function customer(?string $customerID): ?array 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); $response = $this->request()->get('/customers/'.$customerID);
if (!$response->successful()) return null; if (! $response->successful()) {
return null;
}
$data = $response->json('data'); $data = $response->json('data');
return is_array($data) ? $data : null; return is_array($data) ? $data : null;
} }
@@ -62,13 +67,13 @@ final class PaddleClient
private function subscriptionData(Response $response, string $fallback): array private function subscriptionData(Response $response, string $fallback): array
{ {
if (!$response->successful()) { if (! $response->successful()) {
$detail = trim((string) ($response->json('error.detail') ?? '')); $detail = trim((string) ($response->json('error.detail') ?? ''));
throw new RuntimeException($detail !== '' ? $detail : $fallback); throw new RuntimeException($detail !== '' ? $detail : $fallback);
} }
$data = $response->json('data'); $data = $response->json('data');
if (!is_array($data)) { if (! is_array($data)) {
throw new RuntimeException($fallback); throw new RuntimeException($fallback);
} }
@@ -1,4 +1,5 @@
<?php <?php
namespace App\Services\Payments; namespace App\Services\Payments;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -10,15 +11,28 @@ final class PaddleWebhookVerifier implements WebhookVerifier
{ {
$secret = (string) config('rsync-studio.paddle.webhook_secret'); $secret = (string) config('rsync-studio.paddle.webhook_secret');
$header = (string) $request->header('Paddle-Signature'); $header = (string) $request->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 = []; $parts = [];
foreach (explode(';', $header) as $part) { [$k,$v] = array_pad(explode('=',trim($part),2),2,null); if ($k && $v) $parts[$k][]=$v; } foreach (explode(';', $header) as $part) {
$ts = isset($parts['ts'][0]) ? (int)$parts['ts'][0] : 0; [$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'] ?? []; $signatures = $parts['h1'] ?? [];
$tolerance = max(1,(int)config('rsync-studio.paddle.timestamp_tolerance',300)); $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.'); if (! $ts || abs(time() - $ts) > $tolerance) {
throw new RuntimeException('Paddle webhook timestamp is outside the allowed tolerance.');
}
$expected = hash_hmac('sha256', $ts.':'.$rawBody, $secret); $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.'); throw new RuntimeException('Paddle webhook signature is invalid.');
} }
} }
+14 -4
View File
@@ -16,11 +16,13 @@ final class PurchaseProvisioner
{ {
if ($provider === 'paddle') { if ($provider === 'paddle') {
$this->provisionPaddle($event); $this->provisionPaddle($event);
return; return;
} }
if ($provider === 'lemon-squeezy') { if ($provider === 'lemon-squeezy') {
$this->provisionLemonSqueezy($event); $this->provisionLemonSqueezy($event);
return; return;
} }
@@ -34,11 +36,13 @@ final class PurchaseProvisioner
if (str_starts_with($type, 'subscription.')) { if (str_starts_with($type, 'subscription.')) {
$this->syncPaddleSubscription($type, $data); $this->syncPaddleSubscription($type, $data);
return; return;
} }
if ($type === 'transaction.completed') { if ($type === 'transaction.completed') {
$this->syncPaddleTransaction($data); $this->syncPaddleTransaction($data);
return; return;
} }
@@ -71,7 +75,7 @@ final class PurchaseProvisioner
->where('external_subscription_id', $subscriptionID) ->where('external_subscription_id', $subscriptionID)
->first(); ->first();
if (!$license) { if (! $license) {
$product = $this->mappedProduct('paddle', $priceID, $productID); $product = $this->mappedProduct('paddle', $priceID, $productID);
$custom = is_array($data['custom_data'] ?? null) ? $data['custom_data'] : []; $custom = is_array($data['custom_data'] ?? null) ? $data['custom_data'] : [];
$customer = $this->paddle->customer($this->nullableString($data['customer_id'] ?? null)); $customer = $this->paddle->customer($this->nullableString($data['customer_id'] ?? null));
@@ -136,6 +140,7 @@ final class PurchaseProvisioner
->where('external_subscription_id', $subscriptionID) ->where('external_subscription_id', $subscriptionID)
->whereNull('external_order_id') ->whereNull('external_order_id')
->update(['external_order_id' => $transactionID ?: null]); ->update(['external_order_id' => $transactionID ?: null]);
return; return;
} }
@@ -170,10 +175,11 @@ final class PurchaseProvisioner
->where('external_order_id', $order) ->where('external_order_id', $order)
->whereNull('revoked_at') ->whereNull('revoked_at')
->update(['revoked_at' => now(), 'revoke_reason' => 'Payment refunded or subscription expired']); ->update(['revoked_at' => now(), 'revoke_reason' => 'Payment refunded or subscription expired']);
return; return;
} }
if (!in_array($type, ['order_created', 'subscription_created'], true)) { if (! in_array($type, ['order_created', 'subscription_created'], true)) {
return; return;
} }
@@ -210,7 +216,7 @@ final class PurchaseProvisioner
->first(); ->first();
} }
if (!$product) { if (! $product) {
throw new RuntimeException("No active licence product maps to {$provider} price {$priceID} / product {$productID}."); throw new RuntimeException("No active licence product maps to {$provider} price {$priceID} / product {$productID}.");
} }
@@ -224,13 +230,17 @@ final class PurchaseProvisioner
return $item; return $item;
} }
} }
return $items[0] ?? []; return $items[0] ?? [];
} }
private function nullableString(mixed $value): ?string private function nullableString(mixed $value): ?string
{ {
if (!is_scalar($value)) return null; if (! is_scalar($value)) {
return null;
}
$value = trim((string) $value); $value = trim((string) $value);
return $value === '' ? null : $value; return $value === '' ? null : $value;
} }
} }
+7 -1
View File
@@ -1,4 +1,10 @@
<?php <?php
namespace App\Services\Payments; namespace App\Services\Payments;
use Illuminate\Http\Request; use Illuminate\Http\Request;
interface WebhookVerifier { public function verify(Request $request, string $rawBody): void; }
interface WebhookVerifier
{
public function verify(Request $request, string $rawBody): void;
}
+3 -1
View File
@@ -1,5 +1,7 @@
<?php <?php
use App\Providers\RsyncStudioLicensingServiceProvider;
return [ return [
App\Providers\RsyncStudioLicensingServiceProvider::class, RsyncStudioLicensingServiceProvider::class,
]; ];
+2 -1
View File
@@ -9,7 +9,8 @@
"php": "^8.3", "php": "^8.3",
"deployer/deployer": "^8.0", "deployer/deployer": "^8.0",
"laravel/framework": "^13.17", "laravel/framework": "^13.17",
"laravel/tinker": "^3.0" "laravel/tinker": "^3.0",
"predis/predis": "^3.6"
}, },
"require-dev": { "require-dev": {
"fakerphp/faker": "^1.23", "fakerphp/faker": "^1.23",
Generated
+64 -1
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "03c9ba92496a9b294eb87b214b3c0df4", "content-hash": "1f5b5a47e5910fdf5b2c47e1bc45e1b0",
"packages": [ "packages": [
{ {
"name": "brick/math", "name": "brick/math",
@@ -2738,6 +2738,69 @@
], ],
"time": "2026-08-24T00:54:40+00:00" "time": "2026-08-24T00:54:40+00:00"
}, },
{
"name": "predis/predis",
"version": "v3.6.0",
"source": {
"type": "git",
"url": "https://github.com/predis/predis.git",
"reference": "2ff20c08bb63697245ffee3f198d1673086c302d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/predis/predis/zipball/2ff20c08bb63697245ffee3f198d1673086c302d",
"reference": "2ff20c08bb63697245ffee3f198d1673086c302d",
"shasum": ""
},
"require": {
"php": "^7.2 || ^8.0",
"psr/http-message": "^1.0|^2.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.3",
"phpstan/phpstan": "^1.9",
"phpunit/phpcov": "^6.0 || ^8.0",
"phpunit/phpunit": "^8.0 || ~9.4.4"
},
"suggest": {
"ext-relay": "Faster connection with in-memory caching (>=0.6.2)"
},
"type": "library",
"autoload": {
"psr-4": {
"Predis\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Till Krüss",
"homepage": "https://till.im",
"role": "Maintainer"
}
],
"description": "A flexible and feature-complete Redis/Valkey client for PHP.",
"homepage": "http://github.com/predis/predis",
"keywords": [
"nosql",
"predis",
"redis"
],
"support": {
"issues": "https://github.com/predis/predis/issues",
"source": "https://github.com/predis/predis/tree/v3.6.0"
},
"funding": [
{
"url": "https://github.com/sponsors/tillkruss",
"type": "github"
}
],
"time": "2026-08-14T23:07:56+00:00"
},
{ {
"name": "psr/clock", "name": "psr/clock",
"version": "1.0.0", "version": "1.0.0",
-7
View File
@@ -1,7 +0,0 @@
{
"require": {
"php": "^8.3",
"ext-sodium": "*",
"laravel/framework": "^13.0"
}
}
Binary file not shown.
@@ -4,7 +4,8 @@ use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint; use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
return new class extends Migration { return new class extends Migration
{
public function up(): void public function up(): void
{ {
Schema::create('license_products', function (Blueprint $table) { Schema::create('license_products', function (Blueprint $table) {
+9 -7
View File
@@ -10,17 +10,19 @@ class PaddleCatalogSeeder extends Seeder
public function run(): void public function run(): void
{ {
$products = [ $products = [
['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-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' => '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-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' => '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) { 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( LicenseProduct::updateOrCreate(
['code'=>$data['code']], ['code' => $data['code']],
$data + ['payment_provider'=>'paddle','max_major_version'=>config('rsync-studio.default_max_major_version'),'active'=>true] $data + ['payment_provider' => 'paddle', 'max_major_version' => config('rsync-studio.default_max_major_version'), 'active' => true]
); );
} }
} }
+10 -1
View File
@@ -1,3 +1,12 @@
<?php <?php
require __DIR__.'/api.stage4.php'; use App\Http\Controllers\Api\LicenseActivationController;
use App\Http\Controllers\Api\PaymentWebhookController;
use Illuminate\Support\Facades\Route;
Route::prefix('v1')->group(function () {
Route::post('/activate', [LicenseActivationController::class, 'activate'])->middleware('throttle:license-activation');
Route::post('/validate', [LicenseActivationController::class, 'validateInstallation'])->middleware('throttle:license-activation');
Route::post('/deactivate', [LicenseActivationController::class, 'deactivate'])->middleware('throttle:license-activation');
Route::post('/webhooks/{provider}', PaymentWebhookController::class)->whereIn('provider', ['paddle', 'lemon-squeezy']);
});
-12
View File
@@ -1,12 +0,0 @@
<?php
use App\Http\Controllers\Api\LicenseActivationController;
use App\Http\Controllers\Api\PaymentWebhookController;
use Illuminate\Support\Facades\Route;
Route::prefix('v1')->group(function () {
Route::post('/activate', [LicenseActivationController::class, 'activate'])->middleware('throttle:license-activation');
Route::post('/validate', [LicenseActivationController::class, 'validateInstallation'])->middleware('throttle:license-activation');
Route::post('/deactivate', [LicenseActivationController::class, 'deactivate'])->middleware('throttle:license-activation');
Route::post('/webhooks/{provider}', PaymentWebhookController::class)->whereIn('provider', ['paddle','lemon-squeezy']);
});
+53 -1
View File
@@ -1,3 +1,55 @@
<?php <?php
require __DIR__.'/web.stage5.php'; use App\Http\Controllers\Admin\AdminController;
use App\Http\Controllers\Admin\SupportTicketController;
use App\Http\Controllers\Auth\AuthenticatedSessionController;
use App\Http\Controllers\Auth\EmailVerificationController;
use App\Http\Controllers\Auth\PasswordController;
use App\Http\Controllers\Auth\RegisteredUserController;
use App\Http\Controllers\CheckoutController;
use App\Http\Controllers\Portal\PortalController;
use App\Http\Controllers\Support\SupportController;
use App\Http\Middleware\AdminOnly;
use Illuminate\Support\Facades\Route;
Route::get('/', fn () => view('portal.welcome'))->name('home');
Route::get('/buy', CheckoutController::class)->name('buy');
Route::get('/support', [SupportController::class, 'create'])->name('support');
Route::post('/support', [SupportController::class, 'store'])->middleware('throttle:5,10')->name('support.store');
Route::view('/privacy', 'legal.privacy')->name('privacy');
Route::view('/terms', 'legal.terms')->name('terms');
Route::view('/cookies', 'legal.cookies')->name('cookies');
Route::middleware('guest')->group(function () {
Route::get('/login', [AuthenticatedSessionController::class, 'create'])->name('login');
Route::post('/login', [AuthenticatedSessionController::class, 'store'])->middleware('throttle:login');
Route::get('/register', [RegisteredUserController::class, 'create'])->name('register');
Route::post('/register', [RegisteredUserController::class, 'store'])->middleware('throttle:registration');
Route::get('/forgot-password', [PasswordController::class, 'requestForm'])->name('password.request');
Route::post('/forgot-password', [PasswordController::class, 'email'])->name('password.email')->middleware('throttle:5,1');
Route::get('/reset-password/{token}', [PasswordController::class, 'resetForm'])->name('password.reset');
Route::post('/reset-password', [PasswordController::class, 'reset'])->name('password.update');
});
Route::middleware('auth')->group(function () {
Route::post('/logout', [AuthenticatedSessionController::class, 'destroy'])->name('logout');
Route::get('/verify-email', [EmailVerificationController::class, 'notice'])->name('verification.notice');
Route::get('/verify-email/{id}/{hash}', [EmailVerificationController::class, 'verify'])->middleware(['signed', 'throttle:6,1'])->name('verification.verify');
Route::post('/email/verification-notification', [EmailVerificationController::class, 'resend'])->middleware('throttle:6,1')->name('verification.send');
});
Route::middleware(['auth', 'verified'])->prefix('portal')->name('portal.')->group(function () {
Route::get('/', [PortalController::class, 'index'])->name('index');
Route::get('/licences/{license}', [PortalController::class, 'show'])->name('show');
Route::post('/licences/{license}/upgrade', [PortalController::class, 'upgrade'])->middleware('throttle:5,1')->name('upgrade');
Route::delete('/licences/{license}/subscription', [PortalController::class, 'cancel'])->middleware('throttle:5,1')->name('cancel');
Route::post('/licences/{license}/resend', [PortalController::class, 'resend'])->middleware('throttle:3,10')->name('resend');
Route::delete('/licences/{license}/activations/{activation}', [PortalController::class, 'deactivate'])->name('deactivate');
});
Route::middleware(['auth', 'verified', AdminOnly::class])->prefix('admin')->name('admin.')->group(function () {
Route::get('/', [AdminController::class, 'index'])->name('index');
Route::get('/licences', [AdminController::class, 'licenses'])->name('licenses');
Route::get('/licences/{license}', [AdminController::class, 'show'])->name('license');
Route::post('/licences/{license}/revoke', [AdminController::class, 'revoke'])->name('revoke');
Route::post('/licences/{license}/restore', [AdminController::class, 'restore'])->name('restore');
Route::get('/support', [SupportTicketController::class, 'index'])->name('support.index');
Route::get('/support/{ticket}', [SupportTicketController::class, 'show'])->name('support.show');
Route::patch('/support/{ticket}',[SupportTicketController::class, 'update'])->name('support.update');
});
-23
View File
@@ -1,23 +0,0 @@
<?php
use App\Http\Controllers\Admin\AdminController;
use App\Http\Controllers\Admin\SupportTicketController;
use App\Http\Controllers\Support\SupportController;
use App\Http\Controllers\Auth\AuthenticatedSessionController;
use App\Http\Controllers\Auth\EmailVerificationController;
use App\Http\Controllers\Auth\PasswordController;
use App\Http\Controllers\Auth\RegisteredUserController;
use App\Http\Controllers\CheckoutController;
use App\Http\Controllers\Portal\PortalController;
use App\Http\Middleware\AdminOnly;
use Illuminate\Support\Facades\Route;
Route::get('/', fn()=>view('portal.welcome'))->name('home');
Route::get('/buy', CheckoutController::class)->name('buy');
Route::get('/support', [SupportController::class, 'create'])->name('support');
Route::post('/support', [SupportController::class, 'store'])->middleware('throttle:5,10')->name('support.store');
Route::view('/privacy', 'legal.privacy')->name('privacy');
Route::view('/terms', 'legal.terms')->name('terms');
Route::view('/cookies', 'legal.cookies')->name('cookies');
Route::middleware('guest')->group(function(){Route::get('/login',[AuthenticatedSessionController::class,'create'])->name('login');Route::post('/login',[AuthenticatedSessionController::class,'store'])->middleware('throttle:login');Route::get('/register',[RegisteredUserController::class,'create'])->name('register');Route::post('/register',[RegisteredUserController::class,'store'])->middleware('throttle:registration');Route::get('/forgot-password',[PasswordController::class,'requestForm'])->name('password.request');Route::post('/forgot-password',[PasswordController::class,'email'])->name('password.email')->middleware('throttle:5,1');Route::get('/reset-password/{token}',[PasswordController::class,'resetForm'])->name('password.reset');Route::post('/reset-password',[PasswordController::class,'reset'])->name('password.update');});
Route::middleware('auth')->group(function(){Route::post('/logout',[AuthenticatedSessionController::class,'destroy'])->name('logout');Route::get('/verify-email',[EmailVerificationController::class,'notice'])->name('verification.notice');Route::get('/verify-email/{id}/{hash}',[EmailVerificationController::class,'verify'])->middleware(['signed','throttle:6,1'])->name('verification.verify');Route::post('/email/verification-notification',[EmailVerificationController::class,'resend'])->middleware('throttle:6,1')->name('verification.send');});
Route::middleware(['auth','verified'])->prefix('portal')->name('portal.')->group(function(){Route::get('/',[PortalController::class,'index'])->name('index');Route::get('/licences/{license}',[PortalController::class,'show'])->name('show');Route::post('/licences/{license}/upgrade',[PortalController::class,'upgrade'])->middleware('throttle:5,1')->name('upgrade');Route::delete('/licences/{license}/subscription',[PortalController::class,'cancel'])->middleware('throttle:5,1')->name('cancel');Route::post('/licences/{license}/resend',[PortalController::class,'resend'])->middleware('throttle:3,10')->name('resend');Route::delete('/licences/{license}/activations/{activation}',[PortalController::class,'deactivate'])->name('deactivate');});
Route::middleware(['auth','verified',AdminOnly::class])->prefix('admin')->name('admin.')->group(function(){Route::get('/',[AdminController::class,'index'])->name('index');Route::get('/licences',[AdminController::class,'licenses'])->name('licenses');Route::get('/licences/{license}',[AdminController::class,'show'])->name('license');Route::post('/licences/{license}/revoke',[AdminController::class,'revoke'])->name('revoke');Route::post('/licences/{license}/restore',[AdminController::class,'restore'])->name('restore');Route::get('/support',[SupportTicketController::class,'index'])->name('support.index');Route::get('/support/{ticket}',[SupportTicketController::class,'show'])->name('support.show');Route::patch('/support/{ticket}',[SupportTicketController::class,'update'])->name('support.update');});
+10 -8
View File
@@ -1,8 +1,9 @@
<?php <?php
namespace Tests\Feature; namespace Tests\Feature;
use App\Models\LicenseProduct;
use App\Models\LicenseActivation; use App\Models\LicenseActivation;
use App\Models\LicenseProduct;
use App\Services\Licensing\LicenseIssuer; use App\Services\Licensing\LicenseIssuer;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase; use Tests\TestCase;
@@ -10,15 +11,17 @@ use Tests\TestCase;
class LicenseActivationTest extends TestCase class LicenseActivationTest extends TestCase
{ {
use RefreshDatabase; use RefreshDatabase;
public function test_activation_is_idempotent_and_enforces_machine_limit(): void public function test_activation_is_idempotent_and_enforces_machine_limit(): void
{ {
$product=LicenseProduct::create(['code'=>'test','name'=>'Test','edition'=>'Professional','activation_limit'=>1,'max_major_version'=>1,'active'=>true]); $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'); [, $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']; $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()->assertJsonStructure(['licenseToken']);
$this->postJson('/api/v1/activate',$one)->assertOk(); $this->postJson('/api/v1/activate', $one)->assertOk();
$this->postJson('/api/v1/activate',array_merge($one,['installationID'=>'22222222-2222-4222-8222-222222222222']))->assertStatus(409); $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 public function test_validation_refreshes_an_active_installation_without_calling_paddle(): void
{ {
$product = LicenseProduct::create([ $product = LicenseProduct::create([
@@ -79,5 +82,4 @@ class LicenseActivationTest extends TestCase
'message' => 'This Mac has been deactivated from the licence portal.', 'message' => 'This Mac has been deactivated from the licence portal.',
]); ]);
} }
} }
@@ -36,12 +36,12 @@ class PaddleSubscriptionProvisioningTest extends TestCase
'status' => $status, 'status' => $status,
'customer_id' => 'ctm_test', 'customer_id' => 'ctm_test',
'transaction_id' => 'txn_test', 'transaction_id' => 'txn_test',
'custom_data' => ['customer_name'=>'Mark','customer_email'=>'mark@example.com'], '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'], 'current_billing_period' => ['starts_at' => '2026-09-09T00:00:00Z', 'ends_at' => '2026-10-09T00:00:00Z'],
'scheduled_change' => null, 'scheduled_change' => null,
'items' => [[ 'items' => [[
'recurring' => true, 'recurring' => true,
'price' => ['id'=>$price,'product_id'=>$product], 'price' => ['id' => $price, 'product_id' => $product],
]], ]],
], $extra), ], $extra),
]; ];
@@ -83,7 +83,7 @@ class PaddleSubscriptionProvisioningTest extends TestCase
$this->product('pri_personal_monthly', 'Personal', 1); $this->product('pri_personal_monthly', 'Personal', 1);
app(PurchaseProvisioner::class)->provision('paddle', $this->subscription( app(PurchaseProvisioner::class)->provision('paddle', $this->subscription(
'subscription.updated', 'active', 'pri_personal_monthly', 'pro_personal', '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(); $license = License::firstOrFail();
@@ -96,7 +96,7 @@ class PaddleSubscriptionProvisioningTest extends TestCase
$this->product('pri_personal_monthly', 'Personal', 1); $this->product('pri_personal_monthly', 'Personal', 1);
$service = app(PurchaseProvisioner::class); $service = app(PurchaseProvisioner::class);
$service->provision('paddle', $this->subscription('subscription.created', 'active', 'pri_personal_monthly', 'pro_personal')); $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(); $license = License::firstOrFail();
$this->assertNotNull($license->revoked_at); $this->assertNotNull($license->revoked_at);