diff --git a/rsync-studio-licensing/.editorconfig b/rsync-studio-licensing/.editorconfig old mode 100644 new mode 100755 diff --git a/rsync-studio-licensing/.env.example b/rsync-studio-licensing/.env.example old mode 100644 new mode 100755 index ed05089..6b9ff41 --- a/rsync-studio-licensing/.env.example +++ b/rsync-studio-licensing/.env.example @@ -72,6 +72,16 @@ RSYNC_STUDIO_DEFAULT_MAX_MAJOR_VERSION=1 RSYNC_STUDIO_PURCHASE_URL= RSYNC_STUDIO_PORTAL_URL= RSYNC_STUDIO_SUPPORT_URL= +PADDLE_API_KEY= +PADDLE_API_URL=https://sandbox-api.paddle.com +PADDLE_CLIENT_SIDE_TOKEN= +PADDLE_ENVIRONMENT=sandbox PADDLE_WEBHOOK_SECRET= PADDLE_WEBHOOK_TOLERANCE=300 +PADDLE_PERSONAL_PRODUCT_ID= +PADDLE_PERSONAL_MONTHLY_PRICE_ID= +PADDLE_PERSONAL_ANNUAL_PRICE_ID= +PADDLE_PROFESSIONAL_PRODUCT_ID= +PADDLE_PROFESSIONAL_MONTHLY_PRICE_ID= +PADDLE_PROFESSIONAL_ANNUAL_PRICE_ID= LEMON_SQUEEZY_WEBHOOK_SECRET= diff --git a/rsync-studio-licensing/.gitattributes b/rsync-studio-licensing/.gitattributes old mode 100644 new mode 100755 diff --git a/rsync-studio-licensing/.gitignore b/rsync-studio-licensing/.gitignore old mode 100644 new mode 100755 diff --git a/rsync-studio-licensing/.npmrc b/rsync-studio-licensing/.npmrc old mode 100644 new mode 100755 diff --git a/rsync-studio-licensing/AGENTS.md b/rsync-studio-licensing/AGENTS.md old mode 100644 new mode 100755 diff --git a/rsync-studio-licensing/CLAUDE.md b/rsync-studio-licensing/CLAUDE.md old mode 100644 new mode 100755 diff --git a/rsync-studio-licensing/README.md b/rsync-studio-licensing/README.md old mode 100644 new mode 100755 diff --git a/rsync-studio-licensing/app/Console/Commands/RsyncStudioAdminCreate.php b/rsync-studio-licensing/app/Console/Commands/RsyncStudioAdminCreate.php old mode 100644 new mode 100755 index 6671411..ee52644 --- a/rsync-studio-licensing/app/Console/Commands/RsyncStudioAdminCreate.php +++ b/rsync-studio-licensing/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/rsync-studio-licensing/app/Console/Commands/RsyncStudioLicenseIssue.php b/rsync-studio-licensing/app/Console/Commands/RsyncStudioLicenseIssue.php old mode 100644 new mode 100755 index 886cefd..aa5d895 --- a/rsync-studio-licensing/app/Console/Commands/RsyncStudioLicenseIssue.php +++ b/rsync-studio-licensing/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/rsync-studio-licensing/app/Console/Commands/RsyncStudioLicenseRevoke.php b/rsync-studio-licensing/app/Console/Commands/RsyncStudioLicenseRevoke.php old mode 100644 new mode 100755 index c608df3..f48204f --- a/rsync-studio-licensing/app/Console/Commands/RsyncStudioLicenseRevoke.php +++ b/rsync-studio-licensing/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/rsync-studio-licensing/app/Console/Commands/RsyncStudioProductCreate.php b/rsync-studio-licensing/app/Console/Commands/RsyncStudioProductCreate.php old mode 100644 new mode 100755 index 098b20d..6191cb4 --- a/rsync-studio-licensing/app/Console/Commands/RsyncStudioProductCreate.php +++ b/rsync-studio-licensing/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/rsync-studio-licensing/app/Http/Controllers/Admin/AdminController.php b/rsync-studio-licensing/app/Http/Controllers/Admin/AdminController.php old mode 100644 new mode 100755 index 1d87ddc..6c2bb25 --- a/rsync-studio-licensing/app/Http/Controllers/Admin/AdminController.php +++ b/rsync-studio-licensing/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/rsync-studio-licensing/app/Http/Controllers/Admin/SupportTicketController.php b/rsync-studio-licensing/app/Http/Controllers/Admin/SupportTicketController.php new file mode 100755 index 0000000..99484af --- /dev/null +++ b/rsync-studio-licensing/app/Http/Controllers/Admin/SupportTicketController.php @@ -0,0 +1,59 @@ +with('assignee') + ->when($request->filled('status'), fn ($q) => $q->where('status', $request->string('status'))) + ->latest() + ->paginate(50) + ->withQueryString(); + + return view('admin.support.index', compact('tickets')); + } + + public function show(SupportTicket $ticket) + { + $ticket->load(['user', 'assignee']); + if (auth()->user()) { + auth()->user()->unreadNotifications()->get() + ->filter(fn ($notification) => (int) ($notification->data['support_ticket_id'] ?? 0) === $ticket->id) + ->each->markAsRead(); + } + + return view('admin.support.show', [ + 'ticket' => $ticket, + 'admins' => User::query()->where('is_admin', true)->orderBy('name')->get(['id', 'name', 'email']), + ]); + } + + public function update(Request $request, SupportTicket $ticket): RedirectResponse + { + $data = $request->validate([ + 'status' => ['required', 'in:new,open,waiting,resolved,closed'], + 'priority' => ['required', 'in:low,normal,high,urgent'], + 'assigned_to' => ['nullable', 'integer', 'exists:users,id'], + ]); + + if ($data['assigned_to'] ?? null) { + abort_unless(User::whereKey($data['assigned_to'])->where('is_admin', true)->exists(), 422); + } + + $data['resolved_at'] = in_array($data['status'], ['resolved', 'closed'], true) + ? ($ticket->resolved_at ?? now()) + : null; + $ticket->update($data); + + return back()->with('status', 'Support ticket updated.'); + } +} diff --git a/rsync-studio-licensing/app/Http/Controllers/Api/LicenseActivationController.php b/rsync-studio-licensing/app/Http/Controllers/Api/LicenseActivationController.php old mode 100644 new mode 100755 index 4bd5b7d..d4e1aba --- a/rsync-studio-licensing/app/Http/Controllers/Api/LicenseActivationController.php +++ b/rsync-studio-licensing/app/Http/Controllers/Api/LicenseActivationController.php @@ -1,4 +1,5 @@ validate([ - 'licenseKey'=>['required','string','max:128'], - 'installationID'=>['required','uuid'], - 'appVersion'=>['required','string','max:32'], - 'platform'=>['required','string','max:32'], + 'licenseKey' => ['required', 'string', 'max:128'], + 'installationID' => ['required', 'uuid'], + 'appVersion' => ['required', 'string', 'max:32'], + 'platform' => ['required', 'string', 'max:32'], ]); - return DB::transaction(function () use ($data,$signer) { - $license = License::where('license_key_hash', LicenseKey::hash($data['licenseKey']))->lockForUpdate()->first(); - if (!$license) throw ValidationException::withMessages(['licenseKey'=>'The licence key is not valid.']); - if (!$license->is_usable) return response()->json(['message'=>'This licence is expired or has been revoked.'], 403); + return DB::transaction(function () use ($data, $signer) { + $license = License::where('license_key_hash', LicenseKey::hash($data['licenseKey'])) + ->lockForUpdate() + ->first(); - $activation = LicenseActivation::where('license_id',$license->id)->where('installation_id',strtolower($data['installationID']))->first(); - if ($activation && $activation->deactivated_at === null) { - $activation->update(['last_seen_at'=>now(),'platform'=>$data['platform'],'app_version'=>$data['appVersion']]); - } else { - $activeCount = LicenseActivation::where('license_id',$license->id)->whereNull('deactivated_at')->count(); - if ($activeCount >= $license->activation_limit) return response()->json(['message'=>'This licence has reached its activation limit. Deactivate another Mac first.'], 409); - if ($activation) $activation->update(['deactivated_at'=>null,'activated_at'=>now(),'last_seen_at'=>now(),'platform'=>$data['platform'],'app_version'=>$data['appVersion']]); - else LicenseActivation::create(['license_id'=>$license->id,'installation_id'=>strtolower($data['installationID']),'platform'=>$data['platform'],'app_version'=>$data['appVersion'],'activated_at'=>now(),'last_seen_at'=>now()]); + if (! $license) { + throw ValidationException::withMessages(['licenseKey' => 'The licence key is not valid.']); } - return response()->json(['licenseToken'=>$signer->sign($license, $data['installationID'])]); + + if (! $license->is_usable) { + return response()->json(['message' => 'This licence is expired or has been revoked.'], 403); + } + + $installationID = strtolower($data['installationID']); + $activation = LicenseActivation::where('license_id', $license->id) + ->where('installation_id', $installationID) + ->first(); + + if ($activation && $activation->deactivated_at === null) { + $activation->update([ + 'last_seen_at' => now(), + 'platform' => $data['platform'], + 'app_version' => $data['appVersion'], + ]); + } else { + $activeCount = LicenseActivation::where('license_id', $license->id) + ->whereNull('deactivated_at') + ->count(); + + if ($activeCount >= $license->activation_limit) { + return response()->json(['message' => 'This licence has reached its activation limit. Deactivate another Mac first.'], 409); + } + + if ($activation) { + $activation->update([ + 'deactivated_at' => null, + 'activated_at' => now(), + 'last_seen_at' => now(), + 'platform' => $data['platform'], + 'app_version' => $data['appVersion'], + ]); + } else { + LicenseActivation::create([ + 'license_id' => $license->id, + 'installation_id' => $installationID, + 'platform' => $data['platform'], + 'app_version' => $data['appVersion'], + 'activated_at' => now(), + 'last_seen_at' => now(), + ]); + } + } + + return response()->json([ + 'licenseToken' => $signer->sign($license, $data['installationID']), + ]); + }); + } + + public function validateInstallation( + Request $request, + LicenseTokenVerifier $verifier, + LicenseSigner $signer, + ): JsonResponse { + $data = $request->validate([ + 'licenseToken' => ['required', 'string', 'max:8192'], + 'installationID' => ['required', 'uuid'], + 'appVersion' => ['required', 'string', 'max:32'], + 'platform' => ['required', 'string', 'max:32'], + ]); + + try { + $payload = $verifier->verify($data['licenseToken']); + } catch (\Throwable) { + return response()->json(['message' => 'The licence token is invalid.'], 400); + } + + if (strcasecmp((string) ($payload['installationID'] ?? ''), $data['installationID']) !== 0) { + return response()->json(['message' => 'The installation does not match this licence token.'], 400); + } + + return DB::transaction(function () use ($data, $payload, $signer) { + $license = License::whereKey((string) ($payload['licenseID'] ?? '')) + ->lockForUpdate() + ->first(); + + if (! $license || ! $license->is_usable) { + return response()->json(['message' => 'This licence is expired or has been revoked.'], 403); + } + + $activation = LicenseActivation::where('license_id', $license->id) + ->where('installation_id', strtolower($data['installationID'])) + ->first(); + + if (! $activation || $activation->deactivated_at !== null) { + return response()->json(['message' => 'This Mac has been deactivated from the licence portal.'], 403); + } + + $activation->update([ + 'last_seen_at' => now(), + 'platform' => $data['platform'], + 'app_version' => $data['appVersion'], + ]); + + return response()->json([ + 'active' => true, + // Refreshing the signed token also applies subscription edition/expiry changes + // without requiring the user to reactivate manually. + 'licenseToken' => $signer->sign($license, $data['installationID']), + ]); }); } public function deactivate(Request $request, LicenseTokenVerifier $verifier): JsonResponse { - $data = $request->validate(['licenseToken'=>['required','string','max:8192'],'installationID'=>['required','uuid']]); - try { $payload = $verifier->verify($data['licenseToken']); } - catch (\Throwable) { return response()->json(['message'=>'The licence token is invalid.'], 400); } - if (strcasecmp((string)($payload['installationID'] ?? ''), $data['installationID']) !== 0) return response()->json(['message'=>'The installation does not match this licence token.'], 400); - LicenseActivation::where('license_id',(string)($payload['licenseID'] ?? ''))->where('installation_id',strtolower($data['installationID']))->whereNull('deactivated_at')->update(['deactivated_at'=>now(),'last_seen_at'=>now()]); - return response()->json((object)[]); + $data = $request->validate([ + 'licenseToken' => ['required', 'string', 'max:8192'], + 'installationID' => ['required', 'uuid'], + ]); + + try { + $payload = $verifier->verify($data['licenseToken']); + } catch (\Throwable) { + return response()->json(['message' => 'The licence token is invalid.'], 400); + } + + if (strcasecmp((string) ($payload['installationID'] ?? ''), $data['installationID']) !== 0) { + return response()->json(['message' => 'The installation does not match this licence token.'], 400); + } + + LicenseActivation::where('license_id', (string) ($payload['licenseID'] ?? '')) + ->where('installation_id', strtolower($data['installationID'])) + ->whereNull('deactivated_at') + ->update(['deactivated_at' => now(), 'last_seen_at' => now()]); + + return response()->json((object) []); } } diff --git a/rsync-studio-licensing/app/Http/Controllers/Api/PaymentWebhookController.php b/rsync-studio-licensing/app/Http/Controllers/Api/PaymentWebhookController.php old mode 100644 new mode 100755 index 867d9ef..e4c8036 --- a/rsync-studio-licensing/app/Http/Controllers/Api/PaymentWebhookController.php +++ b/rsync-studio-licensing/app/Http/Controllers/Api/PaymentWebhookController.php @@ -1,8 +1,10 @@ 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); } - return response()->json(['ok'=>true]); + } 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); + } + + return response()->json(['ok' => true]); } } diff --git a/rsync-studio-licensing/app/Http/Controllers/Auth/AuthenticatedSessionController.php b/rsync-studio-licensing/app/Http/Controllers/Auth/AuthenticatedSessionController.php old mode 100644 new mode 100755 index 7622bce..d63878e --- a/rsync-studio-licensing/app/Http/Controllers/Auth/AuthenticatedSessionController.php +++ b/rsync-studio-licensing/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/rsync-studio-licensing/app/Http/Controllers/Auth/EmailVerificationController.php b/rsync-studio-licensing/app/Http/Controllers/Auth/EmailVerificationController.php old mode 100644 new mode 100755 index 1f020dc..2f38de1 --- a/rsync-studio-licensing/app/Http/Controllers/Auth/EmailVerificationController.php +++ b/rsync-studio-licensing/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/rsync-studio-licensing/app/Http/Controllers/Auth/PasswordController.php b/rsync-studio-licensing/app/Http/Controllers/Auth/PasswordController.php old mode 100644 new mode 100755 index 7a416a9..4eb6694 --- a/rsync-studio-licensing/app/Http/Controllers/Auth/PasswordController.php +++ b/rsync-studio-licensing/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/rsync-studio-licensing/app/Http/Controllers/Auth/RegisteredUserController.php b/rsync-studio-licensing/app/Http/Controllers/Auth/RegisteredUserController.php old mode 100644 new mode 100755 index 035e0ef..3b5c073 --- a/rsync-studio-licensing/app/Http/Controllers/Auth/RegisteredUserController.php +++ b/rsync-studio-licensing/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/rsync-studio-licensing/app/Http/Controllers/CheckoutController.php b/rsync-studio-licensing/app/Http/Controllers/CheckoutController.php new file mode 100755 index 0000000..bdfff2b --- /dev/null +++ b/rsync-studio-licensing/app/Http/Controllers/CheckoutController.php @@ -0,0 +1,38 @@ + ['name' => 'Personal', 'billing' => 'Monthly', 'display_price' => '£1.99 / month', 'price_id' => $paddle['personal_monthly_price_id'], 'activation_limit' => 1], + 'personal-annual' => ['name' => 'Personal', 'billing' => 'Annual', 'display_price' => '£14.99 / year', 'price_id' => $paddle['personal_annual_price_id'], 'activation_limit' => 1], + 'professional-monthly' => ['name' => 'Professional', 'billing' => 'Monthly', 'display_price' => '£3.99 / month', 'price_id' => $paddle['professional_monthly_price_id'], 'activation_limit' => 3], + 'professional-annual' => ['name' => 'Professional', 'billing' => 'Annual', 'display_price' => '£29.99 / year', 'price_id' => $paddle['professional_annual_price_id'], 'activation_limit' => 3], + ]; + + foreach ($plans as $plan) { + if (trim((string) $plan['price_id']) === '') { + throw new RuntimeException('One or more Paddle price IDs are not configured.'); + } + } + + return view('checkout.buy', [ + 'plans' => $plans, + 'paddleToken' => $token, + 'paddleEnvironment' => (string) ($paddle['environment'] ?? 'sandbox'), + ]); + } +} diff --git a/rsync-studio-licensing/app/Http/Controllers/Controller.php b/rsync-studio-licensing/app/Http/Controllers/Controller.php old mode 100644 new mode 100755 diff --git a/rsync-studio-licensing/app/Http/Controllers/Portal/PortalController.php b/rsync-studio-licensing/app/Http/Controllers/Portal/PortalController.php old mode 100644 new mode 100755 index 429263d..d76d20f --- a/rsync-studio-licensing/app/Http/Controllers/Portal/PortalController.php +++ b/rsync-studio-licensing/app/Http/Controllers/Portal/PortalController.php @@ -1,15 +1,142 @@ whereRaw('lower(customer_email) = ?', [strtolower($request->user()->email)]); } - public function index(Request $request){ return view('portal.index',['licenses'=>$this->owned($request)->latest()->get()]); } - public function show(Request $request,License $license){ abort_unless(strtolower((string)$license->customer_email)===strtolower($request->user()->email),404); $license->load(['product','activations']); return view('portal.show',compact('license')); } - public function deactivate(Request $request,License $license,int $activation){ abort_unless(strtolower((string)$license->customer_email)===strtolower($request->user()->email),404); $item=$license->activations()->whereKey($activation)->firstOrFail(); if(!$item->deactivated_at){$item->update(['deactivated_at'=>now()]);} return back()->with('status','Activation released.'); } - public function resend(Request $request,License $license){ abort_unless(strtolower((string)$license->customer_email)===strtolower($request->user()->email),404); $out=LicenseDeliveryOutbox::where('license_id',$license->id)->first(); if(!$out) return back()->withErrors(['licence'=>'This licence was issued manually and its full key is not recoverable. Contact support.']); $plain=Crypt::decryptString($out->encrypted_license_key); Mail::raw("Your Rsync Studio licence key is:\n\n{$plain}\n\nKeep this key private.",fn($m)=>$m->to($request->user()->email)->subject('Your Rsync Studio licence key')); $out->update(['delivered_at'=>now()]); return back()->with('status','Licence key sent to your verified email address.'); } +use RuntimeException; + +final class PortalController extends Controller +{ + public function __construct(private PaddleClient $paddle) {} + + private function owned(Request $request) + { + return License::with(['product', 'activations']) + ->whereRaw('lower(customer_email) = ?', [strtolower($request->user()->email)]); + } + + public function index(Request $request) + { + $licenses = $this->owned($request)->latest()->get(); + $hasProfessional = $licenses->contains(fn (License $license) => $license->is_usable && $license->edition === 'Professional'); + + return view('portal.index', compact('licenses', 'hasProfessional')); + } + + public function show(Request $request, License $license) + { + $this->authorise($request, $license); + $license->load(['product', 'activations']); + + return view('portal.show', compact('license')); + } + + public function upgrade(Request $request, License $license): RedirectResponse + { + $this->authorise($request, $license); + abort_unless($license->payment_provider === 'paddle' && $license->external_subscription_id, 422); + abort_unless($license->edition === 'Personal' && $license->is_usable, 422); + + $targetPriceID = $this->professionalPriceFor($license); + $targetProduct = LicenseProduct::query() + ->where('payment_provider', 'paddle') + ->where('external_price_id', $targetPriceID) + ->where('active', true) + ->firstOrFail(); + + try { + $subscription = $this->paddle->updateSubscriptionPrice($license->external_subscription_id, $targetPriceID); + } catch (RuntimeException $exception) { + return back()->withErrors(['licence' => $exception->getMessage()]); + } + + // Apply the entitlement immediately. Paddle webhooks remain authoritative and + // will reconcile subscription status/current-period details afterwards. + $license->update([ + 'license_product_id' => $targetProduct->id, + 'edition' => $targetProduct->edition, + 'activation_limit' => $targetProduct->activation_limit, + 'max_major_version' => $targetProduct->max_major_version, + 'external_price_id' => $targetPriceID, + 'subscription_status' => (string) ($subscription['status'] ?? $license->subscription_status), + 'revoked_at' => null, + 'revoke_reason' => null, + ]); + + return redirect()->route('portal.show', $license)->with('status', 'Upgraded to Professional. Your existing Paddle subscription was changed; no second subscription was created.'); + } + + public function cancel(Request $request, License $license): RedirectResponse + { + $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); + + try { + $this->paddle->cancelSubscriptionImmediately($license->external_subscription_id); + } catch (RuntimeException $exception) { + return back()->withErrors(['licence' => $exception->getMessage()]); + } + + $license->update([ + 'subscription_status' => 'canceled', + 'expires_at' => now(), + 'revoked_at' => now(), + 'revoke_reason' => 'Subscription canceled from licence portal', + 'cancel_scheduled_at' => null, + ]); + + return redirect()->route('portal.index')->with('status', 'Subscription canceled in Paddle and the licence is no longer active.'); + } + + public function deactivate(Request $request, License $license, int $activation) + { + $this->authorise($request, $license); + $item = $license->activations()->whereKey($activation)->firstOrFail(); + if (! $item->deactivated_at) { + $item->update(['deactivated_at' => now()]); + } + + return back()->with('status', 'Mac deactivated. Rsync Studio will detect this through the licensing API.'); + } + + public function resend(Request $request, License $license) + { + $this->authorise($request, $license); + $out = LicenseDeliveryOutbox::where('license_id', $license->id)->first(); + if (! $out) { + return back()->withErrors(['licence' => 'This licence was issued manually and its full key is not recoverable. Contact support.']); + } + + $plain = Crypt::decryptString($out->encrypted_license_key); + Mail::raw("Your Rsync Studio licence key is:\n\n{$plain}\n\nKeep this key private.", fn ($m) => $m->to($request->user()->email)->subject('Your Rsync Studio licence key')); + $out->update(['delivered_at' => now()]); + + return back()->with('status', 'Licence key sent to your verified email address.'); + } + + private function authorise(Request $request, License $license): void + { + abort_unless(strtolower((string) $license->customer_email) === strtolower($request->user()->email), 404); + } + + private function professionalPriceFor(License $license): string + { + $paddle = config('rsync-studio.paddle'); + $current = (string) $license->external_price_id; + + if ($current === (string) ($paddle['personal_annual_price_id'] ?? '')) { + return (string) ($paddle['professional_annual_price_id'] ?? ''); + } + + return (string) ($paddle['professional_monthly_price_id'] ?? ''); + } } diff --git a/rsync-studio-licensing/app/Http/Controllers/Support/SupportController.php b/rsync-studio-licensing/app/Http/Controllers/Support/SupportController.php new file mode 100755 index 0000000..13e9bec --- /dev/null +++ b/rsync-studio-licensing/app/Http/Controllers/Support/SupportController.php @@ -0,0 +1,92 @@ + [ + 'technical' => 'Technical support', + 'licensing' => 'Licence & activation', + 'billing' => 'Billing & subscription', + 'feature' => 'Feature request', + 'privacy' => 'Privacy / data request', + 'general' => 'General enquiry', + ], + ]); + } + + public function store(Request $request): RedirectResponse + { + // Honeypot: bots tend to fill hidden fields. Return success without retaining spam. + if (filled($request->input('company_website'))) { + return back()->with('status', 'Your support request has been received.'); + } + + $data = $request->validate([ + 'name' => ['required', 'string', 'max:120'], + 'email' => ['required', 'email:rfc', 'max:254'], + 'category' => ['required', 'in:technical,licensing,billing,feature,privacy,general'], + 'subject' => ['required', 'string', 'max:180'], + 'message' => ['required', 'string', 'min:10', 'max:10000'], + 'privacy_notice' => ['accepted'], + ]); + + $ticket = SupportTicket::create([ + 'user_id' => $request->user()?->id, + 'reference' => $this->reference(), + 'name' => $data['name'], + 'email' => strtolower($data['email']), + 'category' => $data['category'], + 'subject' => $data['subject'], + 'message' => $data['message'], + 'privacy_notice_accepted_at' => now(), + ]); + + $this->notifyTeam($ticket); + + Mail::raw( + "We received your Rsync Studio support request.\n\nReference: {$ticket->reference}\nSubject: {$ticket->subject}\n\nWe will reply to this email address. Please keep the reference in any follow-up.", + fn ($mail) => $mail->to($ticket->email)->subject("Rsync Studio support request {$ticket->reference}") + ); + + return redirect()->route('support')->with('status', "Support request {$ticket->reference} received. A confirmation has been emailed to you."); + } + + private function notifyTeam(SupportTicket $ticket): void + { + if (config('rsync-studio.support.notify_admin_users', true)) { + User::query()->where('is_admin', true)->get()->each->notify(new NewSupportTicket($ticket)); + } + + $emails = collect(config('rsync-studio.support.alert_emails', [])) + ->filter(fn ($email) => filter_var($email, FILTER_VALIDATE_EMAIL)) + ->unique() + ->values(); + + if ($emails->isNotEmpty()) { + Notification::route('mail', $emails->all())->notify(new NewSupportTicket($ticket)); + } + } + + private function reference(): string + { + do { + $reference = 'RS-'.now()->format('ymd').'-'.Str::upper(Str::random(6)); + } while (SupportTicket::where('reference', $reference)->exists()); + + return $reference; + } +} diff --git a/rsync-studio-licensing/app/Http/Middleware/AdminOnly.php b/rsync-studio-licensing/app/Http/Middleware/AdminOnly.php old mode 100644 new mode 100755 index 6de2fcd..e7cd3a6 --- a/rsync-studio-licensing/app/Http/Middleware/AdminOnly.php +++ b/rsync-studio-licensing/app/Http/Middleware/AdminOnly.php @@ -1,11 +1,17 @@ user()?->is_admin === true, 403); + return $next($request); } } diff --git a/rsync-studio-licensing/app/Models/License.php b/rsync-studio-licensing/app/Models/License.php old mode 100644 new mode 100755 index e42d8b0..c3dcc56 --- a/rsync-studio-licensing/app/Models/License.php +++ b/rsync-studio-licensing/app/Models/License.php @@ -1,4 +1,5 @@ 'datetime','revoked_at'=>'datetime','activation_limit'=>'integer','max_major_version'=>'integer']; - public function product(): BelongsTo { return $this->belongsTo(LicenseProduct::class, 'license_product_id'); } - public function activations(): HasMany { return $this->hasMany(LicenseActivation::class); } - public function getIsUsableAttribute(): bool { return $this->revoked_at === null && ($this->expires_at === null || $this->expires_at->isFuture()); } + + 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/rsync-studio-licensing/app/Models/LicenseActivation.php b/rsync-studio-licensing/app/Models/LicenseActivation.php old mode 100644 new mode 100755 index 8139a60..8562d4b --- a/rsync-studio-licensing/app/Models/LicenseActivation.php +++ b/rsync-studio-licensing/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/rsync-studio-licensing/app/Models/LicenseDeliveryOutbox.php b/rsync-studio-licensing/app/Models/LicenseDeliveryOutbox.php old mode 100644 new mode 100755 index 63ae8ce..e0af182 --- a/rsync-studio-licensing/app/Models/LicenseDeliveryOutbox.php +++ b/rsync-studio-licensing/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/rsync-studio-licensing/app/Models/LicenseProduct.php b/rsync-studio-licensing/app/Models/LicenseProduct.php old mode 100644 new mode 100755 index 28cf89c..85ac9f5 --- a/rsync-studio-licensing/app/Models/LicenseProduct.php +++ b/rsync-studio-licensing/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/rsync-studio-licensing/app/Models/LicenseWebhookEvent.php b/rsync-studio-licensing/app/Models/LicenseWebhookEvent.php old mode 100644 new mode 100755 index ac1c2a2..38fea30 --- a/rsync-studio-licensing/app/Models/LicenseWebhookEvent.php +++ b/rsync-studio-licensing/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/rsync-studio-licensing/app/Models/SupportTicket.php b/rsync-studio-licensing/app/Models/SupportTicket.php new file mode 100755 index 0000000..6fa73ef --- /dev/null +++ b/rsync-studio-licensing/app/Models/SupportTicket.php @@ -0,0 +1,46 @@ + 'datetime', + 'resolved_at' => 'datetime', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function assignee(): BelongsTo + { + return $this->belongsTo(User::class, 'assigned_to'); + } + + public function prunable(): Builder + { + $days = max(30, (int) config('rsync-studio.privacy.support_retention_days', 730)); + + return static::query() + ->whereNotNull('resolved_at') + ->where('resolved_at', '<=', now()->subDays($days)); + } +} diff --git a/rsync-studio-licensing/app/Models/User.php b/rsync-studio-licensing/app/Models/User.php old mode 100644 new mode 100755 index 2c45b19..ff6872c --- a/rsync-studio-licensing/app/Models/User.php +++ b/rsync-studio-licensing/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/rsync-studio-licensing/app/Notifications/NewSupportTicket.php b/rsync-studio-licensing/app/Notifications/NewSupportTicket.php new file mode 100755 index 0000000..a1528ae --- /dev/null +++ b/rsync-studio-licensing/app/Notifications/NewSupportTicket.php @@ -0,0 +1,43 @@ +subject("New Rsync Studio support request {$this->ticket->reference}") + ->greeting('New support request') + ->line("{$this->ticket->name} <{$this->ticket->email}>") + ->line("Category: {$this->ticket->category}") + ->line("Subject: {$this->ticket->subject}") + ->line($this->ticket->message) + ->action('Open support ticket', route('admin.support.show', $this->ticket)); + } + + public function toArray(object $notifiable): array + { + return [ + 'support_ticket_id' => $this->ticket->id, + 'reference' => $this->ticket->reference, + 'subject' => $this->ticket->subject, + 'email' => $this->ticket->email, + ]; + } +} diff --git a/rsync-studio-licensing/app/Providers/RsyncStudioLicensingServiceProvider.php b/rsync-studio-licensing/app/Providers/RsyncStudioLicensingServiceProvider.php old mode 100644 new mode 100755 diff --git a/rsync-studio-licensing/app/Services/Licensing/LicenseDeliveryService.php b/rsync-studio-licensing/app/Services/Licensing/LicenseDeliveryService.php new file mode 100755 index 0000000..94e804c --- /dev/null +++ b/rsync-studio-licensing/app/Services/Licensing/LicenseDeliveryService.php @@ -0,0 +1,40 @@ +whereNull('delivered_at') + ->whereNotNull('recipient_email') + ->where('recipient_email', '!=', '') + ->oldest() + ->limit(max(1, min($limit, 100))) + ->get(); + + foreach ($pending as $outbox) { + $plain = Crypt::decryptString($outbox->encrypted_license_key); + $recipient = strtolower(trim((string) $outbox->recipient_email)); + 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.", + fn ($message) => $message->to($recipient)->subject('Your Rsync Studio licence key') + ); + + $outbox->update(['delivered_at' => now()]); + $delivered++; + } + + return $delivered; + } +} diff --git a/rsync-studio-licensing/app/Services/Licensing/LicenseIssuer.php b/rsync-studio-licensing/app/Services/Licensing/LicenseIssuer.php old mode 100644 new mode 100755 index 6702788..ec51713 --- a/rsync-studio-licensing/app/Services/Licensing/LicenseIssuer.php +++ b/rsync-studio-licensing/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/rsync-studio-licensing/app/Services/Licensing/LicenseKey.php b/rsync-studio-licensing/app/Services/Licensing/LicenseKey.php old mode 100644 new mode 100755 index c1104ae..3e42361 --- a/rsync-studio-licensing/app/Services/Licensing/LicenseKey.php +++ b/rsync-studio-licensing/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/rsync-studio-licensing/app/Services/Licensing/LicenseTokenVerifier.php b/rsync-studio-licensing/app/Services/Licensing/LicenseTokenVerifier.php old mode 100644 new mode 100755 index b461cbf..90b7b3b --- a/rsync-studio-licensing/app/Services/Licensing/LicenseTokenVerifier.php +++ b/rsync-studio-licensing/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/rsync-studio-licensing/app/Services/Payments/LemonSqueezyWebhookVerifier.php b/rsync-studio-licensing/app/Services/Payments/LemonSqueezyWebhookVerifier.php old mode 100644 new mode 100755 index d94d56a..3d7a49b --- a/rsync-studio-licensing/app/Services/Payments/LemonSqueezyWebhookVerifier.php +++ b/rsync-studio-licensing/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/rsync-studio-licensing/app/Services/Payments/PaddleClient.php b/rsync-studio-licensing/app/Services/Payments/PaddleClient.php new file mode 100755 index 0000000..6dd5d35 --- /dev/null +++ b/rsync-studio-licensing/app/Services/Payments/PaddleClient.php @@ -0,0 +1,82 @@ +request()->get('/customers/'.$customerID); + + if (! $response->successful()) { + return null; + } + $data = $response->json('data'); + + return is_array($data) ? $data : null; + } + + public function updateSubscriptionPrice(string $subscriptionID, string $priceID): array + { + $this->requireWritableConfiguration(); + + $response = $this->request()->patch('/subscriptions/'.$subscriptionID, [ + 'proration_billing_mode' => 'prorated_immediately', + 'items' => [[ + 'price_id' => $priceID, + 'quantity' => 1, + ]], + ]); + + return $this->subscriptionData($response, 'Paddle could not update the subscription.'); + } + + public function cancelSubscriptionImmediately(string $subscriptionID): array + { + $this->requireWritableConfiguration(); + + $response = $this->request()->post('/subscriptions/'.$subscriptionID.'/cancel', [ + 'effective_from' => 'immediately', + ]); + + return $this->subscriptionData($response, 'Paddle could not cancel the subscription.'); + } + + private function request() + { + return Http::baseUrl((string) config('rsync-studio.paddle.api_url')) + ->withToken((string) config('rsync-studio.paddle.api_key')) + ->acceptJson() + ->timeout(15); + } + + private function requireWritableConfiguration(): void + { + if (trim((string) config('rsync-studio.paddle.api_key')) === '') { + throw new RuntimeException('Paddle subscription management is not configured.'); + } + } + + private function subscriptionData(Response $response, string $fallback): array + { + if (! $response->successful()) { + $detail = trim((string) ($response->json('error.detail') ?? '')); + throw new RuntimeException($detail !== '' ? $detail : $fallback); + } + + $data = $response->json('data'); + if (! is_array($data)) { + throw new RuntimeException($fallback); + } + + return $data; + } +} diff --git a/rsync-studio-licensing/app/Services/Payments/PaddleWebhookVerifier.php b/rsync-studio-licensing/app/Services/Payments/PaddleWebhookVerifier.php old mode 100644 new mode 100755 index bce9424..813827d --- a/rsync-studio-licensing/app/Services/Payments/PaddleWebhookVerifier.php +++ b/rsync-studio-licensing/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/rsync-studio-licensing/app/Services/Payments/PurchaseProvisioner.php b/rsync-studio-licensing/app/Services/Payments/PurchaseProvisioner.php old mode 100644 new mode 100755 index 7cb0c4c..2cc9680 --- a/rsync-studio-licensing/app/Services/Payments/PurchaseProvisioner.php +++ b/rsync-studio-licensing/app/Services/Payments/PurchaseProvisioner.php @@ -1,47 +1,246 @@ $this->fromPaddle($event), - 'lemon-squeezy' => $this->fromLemonSqueezy($event), - default => throw new RuntimeException('Unsupported payment provider.'), - }; - if ($normalized['action'] === 'ignore') return; - if ($normalized['action'] === 'revoke') { - License::where('payment_provider',$provider)->where('external_order_id',$normalized['orderID'])->whereNull('revoked_at')->update(['revoked_at'=>now(),'revoke_reason'=>'Payment refunded or reversed']); + if ($provider === 'paddle') { + $this->provisionPaddle($event); + return; } - $product = LicenseProduct::where('payment_provider',$provider)->where(function($q) use($normalized){ $q->where('external_price_id',$normalized['priceID'])->orWhere('external_product_id',$normalized['productID']); })->where('active',true)->first(); - if (!$product) throw new RuntimeException('No active licence product maps to this payment item.'); - $this->issuer->issue($product,$normalized['name'],$normalized['email'],$provider,$normalized['orderID'],$normalized['customerID'], true); + + if ($provider === 'lemon-squeezy') { + $this->provisionLemonSqueezy($event); + + return; + } + + throw new RuntimeException('Unsupported payment provider.'); } - private function fromPaddle(array $e): array + private function provisionPaddle(array $event): void { - $type=(string)($e['event_type']??''); $d=$e['data']??[]; - if (in_array($type,['transaction.refunded','transaction.canceled'],true)) return ['action'=>'revoke','orderID'=>(string)($d['id']??''),'priceID'=>'','productID'=>'','name'=>null,'email'=>null,'customerID'=>null]; - if ($type!=='transaction.completed') return ['action'=>'ignore']; - $item=$d['items'][0]??[]; $price=$item['price']??[]; $product=$price['product_id']??($item['product_id']??null); - return ['action'=>'issue','orderID'=>(string)($d['id']??''),'priceID'=>(string)($price['id']??($item['price_id']??'')),'productID'=>(string)($product??''),'name'=>$d['custom_data']['customer_name']??null,'email'=>$d['custom_data']['customer_email']??null,'customerID'=>isset($d['customer_id'])?(string)$d['customer_id']:null]; + $type = (string) ($event['event_type'] ?? ''); + $data = $event['data'] ?? []; + + if (str_starts_with($type, 'subscription.')) { + $this->syncPaddleSubscription($type, $data); + + return; + } + + if ($type === 'transaction.completed') { + $this->syncPaddleTransaction($data); + + return; + } + + if ($type === 'transaction.refunded') { + $transactionID = (string) ($data['id'] ?? ''); + if ($transactionID !== '') { + License::where('payment_provider', 'paddle') + ->where('external_order_id', $transactionID) + ->whereNull('external_subscription_id') + ->whereNull('revoked_at') + ->update(['revoked_at' => now(), 'revoke_reason' => 'Payment refunded']); + } + } } - private function fromLemonSqueezy(array $e): array + private function syncPaddleSubscription(string $type, array $data): void { - $type=(string)($e['meta']['event_name']??''); $a=$e['data']['attributes']??[]; - $order=(string)($e['data']['id']??($a['order_id']??'')); - if (in_array($type,['order_refunded','subscription_expired'],true)) return ['action'=>'revoke','orderID'=>$order,'priceID'=>'','productID'=>'','name'=>null,'email'=>null,'customerID'=>null]; - if (!in_array($type,['order_created','subscription_created'],true)) return ['action'=>'ignore']; - return ['action'=>'issue','orderID'=>$order,'priceID'=>(string)($a['variant_id']??''),'productID'=>(string)($a['product_id']??''),'name'=>$a['user_name']??null,'email'=>$a['user_email']??null,'customerID'=>isset($a['customer_id'])?(string)$a['customer_id']:null]; + $subscriptionID = (string) ($data['id'] ?? ''); + if ($subscriptionID === '') { + throw new RuntimeException('Paddle subscription webhook has no subscription ID.'); + } + + $status = (string) ($data['status'] ?? ''); + $item = $this->firstRecurringItem($data['items'] ?? []); + $price = $item['price'] ?? []; + $priceID = (string) ($price['id'] ?? $item['price_id'] ?? ''); + $productID = (string) ($price['product_id'] ?? $item['product_id'] ?? ''); + + $license = License::where('payment_provider', 'paddle') + ->where('external_subscription_id', $subscriptionID) + ->first(); + + 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)); + [$license] = $this->issuer->issue( + $product, + $this->nullableString($custom['customer_name'] ?? $custom['name'] ?? $customer['name'] ?? null), + $this->nullableString($custom['customer_email'] ?? $custom['email'] ?? $customer['email'] ?? null), + 'paddle', + $this->nullableString($data['transaction_id'] ?? null), + $this->nullableString($data['customer_id'] ?? null), + true, + ); + $license->external_subscription_id = $subscriptionID; + } elseif ($priceID !== '' && $priceID !== $license->external_price_id) { + // Upgrades/downgrades change the entitlement attached to the existing key. + $product = $this->mappedProduct('paddle', $priceID, $productID); + $license->license_product_id = $product->id; + $license->edition = $product->edition; + $license->activation_limit = $product->activation_limit; + $license->max_major_version = $product->max_major_version; + } + + $periodEnd = $data['current_billing_period']['ends_at'] ?? null; + $scheduled = $data['scheduled_change'] ?? null; + $cancelAt = is_array($scheduled) && ($scheduled['action'] ?? null) === 'cancel' + ? ($scheduled['effective_at'] ?? null) + : null; + + $license->external_customer_id = $this->nullableString($data['customer_id'] ?? null) ?? $license->external_customer_id; + $license->external_price_id = $priceID !== '' ? $priceID : $license->external_price_id; + $license->subscription_status = $status !== '' ? $status : $license->subscription_status; + $license->current_period_ends_at = $periodEnd ? Carbon::parse($periodEnd) : $license->current_period_ends_at; + $license->cancel_scheduled_at = $cancelAt ? Carbon::parse($cancelAt) : null; + + if (in_array($status, ['active', 'trialing'], true)) { + $license->revoked_at = null; + $license->revoke_reason = null; + // Trialing/active subscriptions remain valid through the current period. + $license->expires_at = $periodEnd ? Carbon::parse($periodEnd) : null; + } elseif ($status === 'past_due') { + // Do not instantly kill a licence on the first failed renewal. Preserve the + // period Paddle says is current; a later canceled event ends entitlement. + $license->expires_at = $periodEnd ? Carbon::parse($periodEnd) : ($license->expires_at ?? now()->addDays(7)); + } elseif (in_array($status, ['canceled', 'paused'], true)) { + $license->expires_at = now(); + $license->revoked_at = now(); + $license->revoke_reason = $status === 'paused' ? 'Subscription paused' : 'Subscription canceled'; + } + + $license->save(); + } + + private function syncPaddleTransaction(array $data): void + { + $subscriptionID = $this->nullableString($data['subscription_id'] ?? null); + $transactionID = (string) ($data['id'] ?? ''); + + // Recurring purchases are provisioned from subscription events because those + // events contain the authoritative trial/status/current-period state. + if ($subscriptionID) { + License::where('payment_provider', 'paddle') + ->where('external_subscription_id', $subscriptionID) + ->whereNull('external_order_id') + ->update(['external_order_id' => $transactionID ?: null]); + + return; + } + + // Keep support for any future one-time Paddle licence product. + $item = $data['items'][0] ?? []; + $price = $item['price'] ?? []; + $priceID = (string) ($price['id'] ?? $item['price_id'] ?? ''); + $productID = (string) ($price['product_id'] ?? $item['product_id'] ?? ''); + $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)); + + $this->issuer->issue( + $product, + $this->nullableString($custom['customer_name'] ?? $custom['name'] ?? $customer['name'] ?? null), + $this->nullableString($custom['customer_email'] ?? $custom['email'] ?? $customer['email'] ?? null), + 'paddle', + $transactionID, + $this->nullableString($data['customer_id'] ?? null), + true, + ); + } + + private function provisionLemonSqueezy(array $event): void + { + $type = (string) ($event['meta']['event_name'] ?? ''); + $attributes = $event['data']['attributes'] ?? []; + $order = (string) ($event['data']['id'] ?? $attributes['order_id'] ?? ''); + + if (in_array($type, ['order_refunded', 'subscription_expired'], true)) { + License::where('payment_provider', 'lemon-squeezy') + ->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)) { + return; + } + + $product = $this->mappedProduct( + 'lemon-squeezy', + (string) ($attributes['variant_id'] ?? ''), + (string) ($attributes['product_id'] ?? ''), + ); + + $this->issuer->issue( + $product, + $this->nullableString($attributes['user_name'] ?? null), + $this->nullableString($attributes['user_email'] ?? null), + 'lemon-squeezy', + $order, + $this->nullableString($attributes['customer_id'] ?? null), + true, + ); + } + + private function mappedProduct(string $provider, string $priceID, string $productID): LicenseProduct + { + // Price IDs are the authoritative mapping because monthly and annual prices + // intentionally share the same Paddle product ID. + if ($priceID !== '') { + $product = LicenseProduct::where('payment_provider', $provider) + ->where('external_price_id', $priceID) + ->where('active', true) + ->first(); + } else { + $product = LicenseProduct::where('payment_provider', $provider) + ->where('external_product_id', $productID) + ->where('active', true) + ->first(); + } + + if (! $product) { + throw new RuntimeException("No active licence product maps to {$provider} price {$priceID} / product {$productID}."); + } + + return $product; + } + + private function firstRecurringItem(array $items): array + { + foreach ($items as $item) { + if (($item['recurring'] ?? true) === true) { + return $item; + } + } + + return $items[0] ?? []; + } + + private function nullableString(mixed $value): ?string + { + if (! is_scalar($value)) { + return null; + } + $value = trim((string) $value); + + return $value === '' ? null : $value; } } diff --git a/rsync-studio-licensing/app/Services/Payments/WebhookVerifier.php b/rsync-studio-licensing/app/Services/Payments/WebhookVerifier.php old mode 100644 new mode 100755 index 5f31a79..984c28e --- a/rsync-studio-licensing/app/Services/Payments/WebhookVerifier.php +++ b/rsync-studio-licensing/app/Services/Payments/WebhookVerifier.php @@ -1,4 +1,10 @@ =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", "version": "1.0.0", diff --git a/rsync-studio-licensing/composer.stage4.json b/rsync-studio-licensing/composer.stage4.json deleted file mode 100644 index 9442786..0000000 --- a/rsync-studio-licensing/composer.stage4.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "require": { - "php": "^8.3", - "ext-sodium": "*", - "laravel/framework": "^13.0" - } -} diff --git a/rsync-studio-licensing/config/rsync-studio.php b/rsync-studio-licensing/config/rsync-studio.php old mode 100644 new mode 100755 index d500f47..54c5b73 --- a/rsync-studio-licensing/config/rsync-studio.php +++ b/rsync-studio-licensing/config/rsync-studio.php @@ -10,9 +10,35 @@ return [ 'purchase_url' => env('RSYNC_STUDIO_PURCHASE_URL'), 'portal_url' => env('RSYNC_STUDIO_PORTAL_URL'), 'support_url' => env('RSYNC_STUDIO_SUPPORT_URL'), + + 'legal' => [ + 'controller_name' => env('RSYNC_STUDIO_LEGAL_NAME', 'Rsync Studio'), + 'privacy_email' => env('RSYNC_STUDIO_PRIVACY_EMAIL', env('MAIL_FROM_ADDRESS')), + 'support_email' => env('RSYNC_STUDIO_SUPPORT_EMAIL', env('MAIL_FROM_ADDRESS')), + 'country' => env('RSYNC_STUDIO_LEGAL_COUNTRY', 'United Kingdom'), + ], + 'privacy' => [ + 'policy_version' => env('RSYNC_STUDIO_PRIVACY_VERSION', '2026-09-09'), + 'cookie_version' => env('RSYNC_STUDIO_COOKIE_VERSION', '1'), + 'support_retention_days' => (int) env('RSYNC_STUDIO_SUPPORT_RETENTION_DAYS', 730), + ], + 'support' => [ + 'alert_emails' => array_values(array_filter(array_map('trim', explode(',', (string) env('RSYNC_STUDIO_SUPPORT_ALERT_EMAILS', ''))))), + 'notify_admin_users' => filter_var(env('RSYNC_STUDIO_SUPPORT_NOTIFY_ADMINS', true), FILTER_VALIDATE_BOOL), + ], 'paddle' => [ + 'api_key' => env('PADDLE_API_KEY'), + 'client_side_token' => env('PADDLE_CLIENT_SIDE_TOKEN'), + 'environment' => env('PADDLE_ENVIRONMENT', env('APP_ENV') === 'production' ? 'production' : 'sandbox'), + 'api_url' => env('PADDLE_API_URL', env('APP_ENV') === 'production' ? 'https://api.paddle.com' : 'https://sandbox-api.paddle.com'), 'webhook_secret' => env('PADDLE_WEBHOOK_SECRET'), 'timestamp_tolerance' => (int) env('PADDLE_WEBHOOK_TOLERANCE', 300), + 'personal_product_id' => env('PADDLE_PERSONAL_PRODUCT_ID'), + 'personal_monthly_price_id' => env('PADDLE_PERSONAL_MONTHLY_PRICE_ID'), + 'personal_annual_price_id' => env('PADDLE_PERSONAL_ANNUAL_PRICE_ID'), + 'professional_product_id' => env('PADDLE_PROFESSIONAL_PRODUCT_ID'), + 'professional_monthly_price_id' => env('PADDLE_PROFESSIONAL_MONTHLY_PRICE_ID'), + 'professional_annual_price_id' => env('PADDLE_PROFESSIONAL_ANNUAL_PRICE_ID'), ], 'lemon_squeezy' => [ 'webhook_secret' => env('LEMON_SQUEEZY_WEBHOOK_SECRET'), diff --git a/rsync-studio-licensing/database/database.sqlite b/rsync-studio-licensing/database/database.sqlite old mode 100644 new mode 100755 index 63f9d49..379c23d Binary files a/rsync-studio-licensing/database/database.sqlite and b/rsync-studio-licensing/database/database.sqlite differ diff --git a/rsync-studio-licensing/database/migrations/2026_09_08_193343_create_sessions_table.php b/rsync-studio-licensing/database/migrations/2026_09_08_193343_create_sessions_table.php old mode 100644 new mode 100755 diff --git a/rsync-studio-licensing/database/migrations/2026_09_08_200000_create_rsync_studio_licensing_tables.php b/rsync-studio-licensing/database/migrations/2026_09_08_200000_create_rsync_studio_licensing_tables.php old mode 100644 new mode 100755 index 11f7b7e..babc7a3 --- a/rsync-studio-licensing/database/migrations/2026_09_08_200000_create_rsync_studio_licensing_tables.php +++ b/rsync-studio-licensing/database/migrations/2026_09_08_200000_create_rsync_studio_licensing_tables.php @@ -4,7 +4,8 @@ use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -return new class extends Migration { +return new class extends Migration +{ public function up(): void { Schema::create('license_products', function (Blueprint $table) { diff --git a/rsync-studio-licensing/database/migrations/2026_09_08_200758_create_cache_table.php b/rsync-studio-licensing/database/migrations/2026_09_08_200758_create_cache_table.php old mode 100644 new mode 100755 diff --git a/rsync-studio-licensing/database/migrations/2026_09_08_205000_create_rsync_studio_auth_tables.php b/rsync-studio-licensing/database/migrations/2026_09_08_205000_create_rsync_studio_auth_tables.php old mode 100644 new mode 100755 diff --git a/rsync-studio-licensing/database/migrations/2026_09_08_210000_add_rsync_studio_portal_fields.php b/rsync-studio-licensing/database/migrations/2026_09_08_210000_add_rsync_studio_portal_fields.php old mode 100644 new mode 100755 diff --git a/rsync-studio-licensing/database/migrations/2026_09_09_100000_add_paddle_subscription_fields.php b/rsync-studio-licensing/database/migrations/2026_09_09_100000_add_paddle_subscription_fields.php new file mode 100755 index 0000000..ef60b76 --- /dev/null +++ b/rsync-studio-licensing/database/migrations/2026_09_09_100000_add_paddle_subscription_fields.php @@ -0,0 +1,34 @@ +string('external_subscription_id')->nullable()->after('external_customer_id'); + $table->string('external_price_id')->nullable()->after('external_subscription_id'); + $table->string('subscription_status')->nullable()->after('external_price_id'); + $table->timestamp('current_period_ends_at')->nullable()->after('subscription_status'); + $table->timestamp('cancel_scheduled_at')->nullable()->after('current_period_ends_at'); + $table->unique(['payment_provider', 'external_subscription_id']); + }); + } + + public function down(): void + { + Schema::table('licenses', function (Blueprint $table) { + $table->dropUnique(['payment_provider', 'external_subscription_id']); + $table->dropColumn([ + 'external_subscription_id', + 'external_price_id', + 'subscription_status', + 'current_period_ends_at', + 'cancel_scheduled_at', + ]); + }); + } +}; diff --git a/rsync-studio-licensing/database/migrations/2026_09_09_160000_create_support_tickets_and_notifications.php b/rsync-studio-licensing/database/migrations/2026_09_09_160000_create_support_tickets_and_notifications.php new file mode 100755 index 0000000..a6223e3 --- /dev/null +++ b/rsync-studio-licensing/database/migrations/2026_09_09_160000_create_support_tickets_and_notifications.php @@ -0,0 +1,47 @@ +id(); + $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignId('assigned_to')->nullable()->constrained('users')->nullOnDelete(); + $table->string('reference', 24)->unique(); + $table->string('name', 120); + $table->string('email', 254)->index(); + $table->string('category', 40)->default('general'); + $table->string('subject', 180); + $table->text('message'); + $table->string('status', 20)->default('new')->index(); + $table->string('priority', 20)->default('normal'); + $table->timestamp('privacy_notice_accepted_at'); + $table->timestamp('resolved_at')->nullable()->index(); + $table->timestamps(); + }); + } + + if (! Schema::hasTable('notifications')) { + Schema::create('notifications', function (Blueprint $table): void { + $table->uuid('id')->primary(); + $table->string('type'); + $table->morphs('notifiable'); + $table->text('data'); + $table->timestamp('read_at')->nullable(); + $table->timestamps(); + }); + } + } + + public function down(): void + { + Schema::dropIfExists('support_tickets'); + Schema::dropIfExists('notifications'); + } +}; diff --git a/rsync-studio-licensing/database/seeders/PaddleCatalogSeeder.php b/rsync-studio-licensing/database/seeders/PaddleCatalogSeeder.php new file mode 100755 index 0000000..23b0091 --- /dev/null +++ b/rsync-studio-licensing/database/seeders/PaddleCatalogSeeder.php @@ -0,0 +1,29 @@ + '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; + } + LicenseProduct::updateOrCreate( + ['code' => $data['code']], + $data + ['payment_provider' => 'paddle', 'max_major_version' => config('rsync-studio.default_max_major_version'), 'active' => true] + ); + } + } +} diff --git a/rsync-studio-licensing/deploy.php b/rsync-studio-licensing/deploy.php old mode 100644 new mode 100755 index ea60122..ebbfe7f --- a/rsync-studio-licensing/deploy.php +++ b/rsync-studio-licensing/deploy.php @@ -9,7 +9,7 @@ set('writable_use_sudo', true); set('clear_use_sudo', true); set('cleanup_use_sudo', true); set('keep_releases', 1); -set('repository', 'git@git.absdev.net:admin/RSync-Studio.git'); +set('repository', 'git@git.absdev.net:mag/rsync_licensing_server.git'); set('node_version', '22.12.0'); set('allow_anonymous_stats', false); @@ -18,8 +18,8 @@ add('shared_dirs', ['storage']); add('writable_dirs', ['storage', 'bootstrap/cache']); // Host Definitions -host('main') - ->set('hostname', 'rsyncstudio.absdev.net') +host('prod') + ->set('hostname', 'rsyncs.absdev.net') ->set('remote_user', 'mag') ->set('branch', 'main') ->set('deploy_path', '/var/www/rsyncstudio'); @@ -28,7 +28,7 @@ host('stage') ->set('hostname', 'pegasus.absdev.net') ->set('remote_user', 'mag') ->set('branch', 'stage') - ->set('deploy_path', '/var/www/rsyncstudio'); + ->set('deploy_path', '/var/www/rsyncstudio_dev'); task('build', function (): void { run('export NVM_DIR="$HOME/.nvm" && [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" && nvm use {{node_version}} && cd {{release_path}} && npm ci --no-audit --no-fund'); @@ -50,7 +50,21 @@ task('artisan:clear', function (): void { task('services:restart', function (): void { run('sudo -n nginx -t'); run('sudo -n systemctl reload nginx'); - run('sudo -n systemctl restart php8.3-fpm'); + $phpFpm = run(<<<'BASH' + if systemctl list-unit-files php8.5-fpm.service --no-legend | grep -q '^php8.5-fpm.service'; then + echo php8.5-fpm + elif systemctl list-unit-files php8.3-fpm.service --no-legend | grep -q '^php8.3-fpm.service'; then + echo php8.3-fpm + else + exit 1 + fi + BASH); + $phpFpm = trim($phpFpm); + if ($phpFpm === '') { + throw new \RuntimeException('No supported PHP-FPM service found (8.5 or 8.3).'); + } + writeln("Restarting {$phpFpm}..."); + run("sudo -n systemctl restart {$phpFpm}"); }); after('deploy:update_code', 'deploy:write_revision_file'); diff --git a/rsync-studio-licensing/docs/PADDLE_SUBSCRIPTIONS.md b/rsync-studio-licensing/docs/PADDLE_SUBSCRIPTIONS.md new file mode 100755 index 0000000..bb6826d --- /dev/null +++ b/rsync-studio-licensing/docs/PADDLE_SUBSCRIPTIONS.md @@ -0,0 +1,71 @@ +# Paddle subscription integration + +## Sandbox configuration + +Keep the Paddle API key and webhook secret server-side only. Do not put either value in the macOS application or commit them to Git. + +Required environment variables: + +```dotenv +PADDLE_API_KEY=pdl_sdbx_apikey_... +PADDLE_API_URL=https://sandbox-api.paddle.com +PADDLE_WEBHOOK_SECRET=... + +PADDLE_PERSONAL_PRODUCT_ID=pro_01m22q4c3vt7yvtv3h3zw1jbn0 +PADDLE_PERSONAL_MONTHLY_PRICE_ID=pri_01m22q4cjx177krcpvdph7e2vy +PADDLE_PERSONAL_ANNUAL_PRICE_ID=pri_01m22q4cs9sr5gshxp6j56nvmh +PADDLE_PROFESSIONAL_PRODUCT_ID=pro_01m22q4d0s6y1557wsq9s7xw8n +PADDLE_PROFESSIONAL_MONTHLY_PRICE_ID=pri_01m22q4d6h1yvr1frawtx51gy8 +PADDLE_PROFESSIONAL_ANNUAL_PRICE_ID=pri_01m22q4dcndcdbzc20txadaz52 +``` + +Then run: + +```bash +php artisan migrate +php artisan db:seed --class=Database\\Seeders\\PaddleCatalogSeeder +php artisan config:clear +``` + +The seeder maps the four Paddle price IDs to licensing entitlements. Personal permits one active Mac; Professional permits three. + +## Paddle notification destination + +Configure the sandbox notification destination to POST to: + +`https://YOUR-HOST/api/v1/webhooks/paddle` + +Copy the destination endpoint secret into `PADDLE_WEBHOOK_SECRET`. + +Subscribe to at least: + +- `subscription.created` +- `subscription.updated` +- `subscription.activated` +- `subscription.trialing` +- `subscription.canceled` +- `subscription.paused` +- `subscription.resumed` +- `subscription.past_due` +- `transaction.completed` +- `transaction.refunded` + +## Entitlement behavior + +- Trialing and active subscriptions are usable. +- A scheduled end-of-period cancellation stays usable until Paddle changes the subscription to canceled. +- Upgrades/downgrades update the entitlement on the existing licence key. +- Canceled or paused subscriptions revoke the licence. +- Past-due subscriptions retain access through Paddle's current billing period rather than being revoked on the first failed payment. +- Paddle customer details are fetched server-side when the webhook does not carry customer email/name in custom data. + +## Important + +The API key is required only on the licensing server. Use a sandbox key with the sandbox API URL. Live Paddle products/prices have different IDs and should be configured separately when moving to production. + + +## Hosted sandbox checkout + +The licensing site exposes `/buy`, which initializes Paddle.js with `PADDLE_CLIENT_SIDE_TOKEN` and opens an overlay checkout for the four configured price IDs. Set `PADDLE_ENVIRONMENT=sandbox` while testing and use a `test_...` client-side token. The token is intentionally browser-visible; never use `PADDLE_API_KEY` in frontend code. + +After a subscription webhook provisions a licence, the webhook controller makes a best-effort attempt to deliver pending licence keys by email. Delivery failures do not roll back the verified Paddle event; the encrypted outbox remains available for portal resend. diff --git a/rsync-studio-licensing/docs/PORTAL_LAUNCH_FRONTEND.md b/rsync-studio-licensing/docs/PORTAL_LAUNCH_FRONTEND.md new file mode 100755 index 0000000..bdd65ec --- /dev/null +++ b/rsync-studio-licensing/docs/PORTAL_LAUNCH_FRONTEND.md @@ -0,0 +1,24 @@ +# Rsync Studio launch frontend + +The public `/` page is now a product-launch landing page using the same shared Laravel layout as checkout, support, authentication and the licence portal. + +## Favicon/device assets + +The website uses the existing Rsync Studio app icon as the web brand source and includes: + +- multi-resolution `public/favicon.ico` +- 16x16, 32x32 and 48x48 browser favicons +- 180x180 Apple touch icon +- 192x192 and 512x512 Android/PWA icons +- Windows 150x150 tile icon and `browserconfig.xml` +- Safari pinned-tab mask icon +- `site.webmanifest` +- Open Graph/Twitter image metadata + +The shared `resources/views/layouts/app.blade.php` loads these assets on every website page. + +## Launch page + +`resources/views/portal/welcome.blade.php` contains the public product page: hero, native app preview, features, workflow, security positioning, Personal/Professional plan summary and trial calls-to-action. + +Non-production environments automatically emit `noindex,nofollow`; production emits normal index/follow metadata. diff --git a/rsync-studio-licensing/docs/rsync-studio-paddle-ids.env b/rsync-studio-licensing/docs/rsync-studio-paddle-ids.env new file mode 100755 index 0000000..87b3c5f --- /dev/null +++ b/rsync-studio-licensing/docs/rsync-studio-paddle-ids.env @@ -0,0 +1,7 @@ +PADDLE_PERSONAL_PRODUCT_ID=pro_01m22q4c3vt7yvtv3h3zw1jbn0 +PADDLE_PERSONAL_MONTHLY_PRICE_ID=pri_01m22q4cjx177krcpvdph7e2vy +PADDLE_PERSONAL_ANNUAL_PRICE_ID=pri_01m22q4cs9sr5gshxp6j56nvmh + +PADDLE_PROFESSIONAL_PRODUCT_ID=pro_01m22q4d0s6y1557wsq9s7xw8n +PADDLE_PROFESSIONAL_MONTHLY_PRICE_ID=pri_01m22q4d6h1yvr1frawtx51gy8 +PADDLE_PROFESSIONAL_ANNUAL_PRICE_ID=pri_01m22q4dcndcdbzc20txadaz52 diff --git a/rsync-studio-licensing/package-lock.json b/rsync-studio-licensing/package-lock.json new file mode 100755 index 0000000..b211042 --- /dev/null +++ b/rsync-studio-licensing/package-lock.json @@ -0,0 +1,2354 @@ +{ + "name": "rsync-studio-licensing", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "devDependencies": { + "@tailwindcss/vite": "^4.0.0", + "concurrently": "^10.0.3", + "laravel-vite-plugin": "^3.1", + "tailwindcss": "^4.0.0", + "vite": "^8.0.0" + }, + "optionalDependencies": { + "@laravel/multiplex": "^0.4.1" + } + }, + "node_modules/@alcalzone/ansi-tokenize": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.3.0.tgz", + "integrity": "sha512-p+CMKJ93HFmLkjXKlXiVGlMQEuRb6H0MokBSwUsX+S6BRX8eV5naFZpQJFfJHjRZY0Hmnqy1/r6UWl3x+19zYA==", + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@laravel/multiplex": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@laravel/multiplex/-/multiplex-0.4.3.tgz", + "integrity": "sha512-jJTZGgKl6sK0ETYmqiPqeAwmErvqjxgDRLfzqsmqiWiupc5dfjaoVVrrdXZwb6eGylSg99WwHLibCPJs8F0bGw==", + "license": "MIT", + "optional": true, + "os": [ + "darwin", + "linux" + ], + "dependencies": { + "ansi-styles": "^6.2.3", + "commander": "^15.0.0", + "ink": "^7.1.0", + "react": "^19.2.7", + "string-width": "^8.2.2", + "wrap-ansi": "^10.0.0" + }, + "bin": { + "multiplex": "dist/cli.js" + }, + "engines": { + "node": ">=22.13.0" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.148.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.148.0.tgz", + "integrity": "sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.7.tgz", + "integrity": "sha512-EypzgnYCwyVY4NDHKzGmNJT5b+XaQEBniHxsMdeIQLB/tcCzZnhqrzHpZFbX9iaxx+5RiB8caATBtfvZP7zVxQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.7.tgz", + "integrity": "sha512-l17HE9EweWaqJZhuUuNBN/FzM62xw+DECVnJyvMsxn8vJFAGLy5QfLDoYAcronkAN8VxKZHezDpulHDPx95vFw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.7.tgz", + "integrity": "sha512-8ED8ELFvHXc6OCETIn4gXObPiaR6bckM/ipXtbzlPVDRMBfEGjCKgO90F9YtfdpDatVx/ZQw7aZ1vUMf/+T3Mw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.7.tgz", + "integrity": "sha512-/WPripjtiAIZ2tWY7ddijORT0Ujg87wxWW/qcoFVCKAWVDPhtY0xr7Dj0M3GyNGz60jGwTElhro/mkF9dT7dDQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.7.tgz", + "integrity": "sha512-14DI4NcqpvbICxSnGLx3PmtDaWqRP/KGSGb6C+JLLVPeZRl6dKdHba3pGsqT3vpdTqhEYIPG0MMQ8c0xYqoJxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.7.tgz", + "integrity": "sha512-bxrWIRvHWQvbJwi+VIie/kDJmQxcNE6xxWwZdqF/ExVAigtHkv54WTLQPb+QsZdnFy18fg7JPfWGL0RH6vwIlQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.7.tgz", + "integrity": "sha512-toOY2BChBZyuxU7OYX6Tn389di4IzAqPTycVcci0O7FSfBqzRB3RZn+K5Is6ANf4tmgRd/K1yZTsNTXbkXsnLg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.7.tgz", + "integrity": "sha512-lAIXTH/aiLRLxsTgQvfhjo4K1ydWIp00+V0voOr9beb/9ZmkUFrSIb03dXNFRgMNvkE6oGsF10ioQ6UsI+vS5Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.7.tgz", + "integrity": "sha512-kdnwS28Pkenp/mZMRwjXXXwxQ7pIsm+bF919LUK93BOyhcLsrVKdP2p9fxpiPNPAbNuch8ypQt0pm2P2LYCAGg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.7.tgz", + "integrity": "sha512-516OdsyLdr5E65paF3yBF55t8mfm9+gmtCsK3xI7XKXIT7EfRlHhxL8K/NR6Hu8BWSgF5+1w74lTL0+nxcc8Qw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.7.tgz", + "integrity": "sha512-r8/z8n7GFaYRln3xmP1Cxy0HH/HLM0uBUPkEuSVEfKGDA89M0FsZRZJRSwe/tJjRx+fpH/gjorfhB8tmEbSFLA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.7.tgz", + "integrity": "sha512-pAsE8iiDxUg1xBqdhrTfg45AVDVpirjz00sblEYClGNNcMnDb+e8beQgqIAw6LvauX/APvgxUnwrgun/YYGBhw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.7.tgz", + "integrity": "sha512-lTcIYmmnQQA8Or/2DatS6oSqcdLHvendjS+zLu+FwgToynWMRSmQdpM65fTANJgIS4mjbMOo5KT2lnT9SAb96w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.7.tgz", + "integrity": "sha512-e3Gu3WxbNk/UqQhxqU7YIYO+9ZBvWNz3U+h/qRFosscMFzdRPbXYSaSWgSnklv2fz1TgzBTcti2z35c/7irsHw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.7.tgz", + "integrity": "sha512-W/jg5qoRSqjsEv0+dZi4e687mcHqmVuU0P4fK6qS/xjetW2Gmc1W8j//z5nAeNcC8Ttm0hV46IjcYeuVwYhuiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "license": "MIT", + "optional": true, + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/auto-bind": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-5.0.1.tgz", + "integrity": "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==", + "license": "MIT", + "optional": true, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cli-boxes": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-4.0.1.tgz", + "integrity": "sha512-5IOn+jcCEHEraYolBPs/sT4BxYCe2nHg374OPiItB1O96KZFseS2gthU4twyYzeDcFew4DaUM/xwc5BQf08JJw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18.20 <19 || >=20.10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-cursor": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-4.0.0.tgz", + "integrity": "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==", + "license": "MIT", + "optional": true, + "dependencies": { + "restore-cursor": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-6.1.1.tgz", + "integrity": "sha512-06p9vyLahLa4zkGcgsGxU6iEkSOiuI4fhCH6Emhe2lPAcoUv73n72DnODsnHA+5wwXGnV0n9M9/qOQJSjYhFhw==", + "license": "MIT", + "optional": true, + "dependencies": { + "slice-ansi": "^9.0.0", + "string-width": "^8.2.0" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cliui/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/code-excerpt": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/code-excerpt/-/code-excerpt-4.0.0.tgz", + "integrity": "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==", + "license": "MIT", + "optional": true, + "dependencies": { + "convert-to-spaces": "^2.0.1" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/commander": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz", + "integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/concurrently": { + "version": "10.0.5", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.5.tgz", + "integrity": "sha512-JaP/CoftUrCcAFW/g//RbgEGwlelnEae6cfBLgH6ZdO6s8jPkn6p9SB9u6pdVxYXoiSnFqseOlHfrEfF82TVOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "5.6.2", + "rxjs": "7.8.2", + "shell-quote": "1.9.0", + "supports-color": "10.2.2", + "tree-kill": "1.2.2", + "yargs": "18.0.0" + }, + "bin": { + "conc": "dist/bin/index.js", + "concurrently": "dist/bin/index.js" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/convert-to-spaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz", + "integrity": "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/enhanced-resolve": { + "version": "5.24.5", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", + "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/es-toolkit": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz", + "integrity": "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==", + "license": "MIT", + "optional": true, + "workspaces": [ + "docs", + "benchmarks", + "tests/types", + "tests/browser-compat" + ] + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/indent-string": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", + "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ink": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/ink/-/ink-7.1.1.tgz", + "integrity": "sha512-Y43xxa1ZSPvpmfLHcN5o+OdP8Rf8ykkNJEuKYOUNZKT8wXVNLFTtEm1nSDMQkfBH+YANF4Xuu0hhZ4ejqAtN2w==", + "license": "MIT", + "optional": true, + "dependencies": { + "@alcalzone/ansi-tokenize": "^0.3.0", + "ansi-escapes": "^7.3.0", + "ansi-styles": "^6.2.3", + "auto-bind": "^5.0.1", + "chalk": "^5.6.2", + "cli-boxes": "^4.0.1", + "cli-cursor": "^4.0.0", + "cli-truncate": "^6.0.0", + "code-excerpt": "^4.0.0", + "es-toolkit": "^1.45.1", + "indent-string": "^5.0.0", + "is-in-ci": "^2.0.0", + "patch-console": "^2.0.0", + "react-reconciler": "^0.33.0", + "scheduler": "^0.27.0", + "signal-exit": "^3.0.7", + "slice-ansi": "^9.0.0", + "stack-utils": "^2.0.6", + "string-width": "^8.2.0", + "terminal-size": "^4.0.1", + "type-fest": "^5.5.0", + "widest-line": "^6.0.0", + "wrap-ansi": "^10.0.0", + "ws": "^8.20.0", + "yoga-layout": "~3.2.1" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "@types/react": ">=19.2.0", + "react": ">=19.2.0", + "react-devtools-core": ">=6.1.2" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react-devtools-core": { + "optional": true + } + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-in-ci": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-in-ci/-/is-in-ci-2.0.0.tgz", + "integrity": "sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w==", + "license": "MIT", + "optional": true, + "bin": { + "is-in-ci": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/laravel-vite-plugin": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-3.2.0.tgz", + "integrity": "sha512-xSxY9Gzeb/eancd8WeK09piAFP+a6i5QIBqNCKNv9L0Eq6wziwzSem7F1GvMSrtjMh5F/QVKFxn8t9naGOA66A==", + "dev": true, + "license": "MIT", + "dependencies": { + "picocolors": "^1.0.0", + "tinyglobby": "^0.2.12", + "vite-plugin-full-reload": "^1.1.0" + }, + "bin": { + "clean-orphaned-assets": "bin/clean.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "fontaine": "^0.8.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "fontaine": { + "optional": true + } + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "license": "MIT", + "optional": true, + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/patch-console": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/patch-console/-/patch-console-2.0.0.tgz", + "integrity": "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==", + "license": "MIT", + "optional": true, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-reconciler": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.33.0.tgz", + "integrity": "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==", + "license": "MIT", + "optional": true, + "dependencies": { + "scheduler": "^0.27.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "peerDependencies": { + "react": "^19.2.0" + } + }, + "node_modules/restore-cursor": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz", + "integrity": "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==", + "license": "MIT", + "optional": true, + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/rolldown": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.7.tgz", + "integrity": "sha512-g0EtLvBjTUB7jhyV0S/TCup3v/XSVl45vUIGbOGU4QPiyjTenCe4mKuFvW9fEgYmS2Fo42AUssRmNuMziXdrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.148.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.7", + "@rolldown/binding-android-arm64": "1.2.7", + "@rolldown/binding-darwin-arm64": "1.2.7", + "@rolldown/binding-darwin-x64": "1.2.7", + "@rolldown/binding-freebsd-x64": "1.2.7", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.7", + "@rolldown/binding-linux-arm64-gnu": "1.2.7", + "@rolldown/binding-linux-arm64-musl": "1.2.7", + "@rolldown/binding-linux-ppc64-gnu": "1.2.7", + "@rolldown/binding-linux-s390x-gnu": "1.2.7", + "@rolldown/binding-linux-x64-gnu": "1.2.7", + "@rolldown/binding-linux-x64-musl": "1.2.7", + "@rolldown/binding-openharmony-arm64": "1.2.7", + "@rolldown/binding-win32-arm64-msvc": "1.2.7", + "@rolldown/binding-win32-x64-msvc": "1.2.7" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT", + "optional": true + }, + "node_modules/shell-quote": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz", + "integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC", + "optional": true + }, + "node_modules/slice-ansi": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-9.0.0.tgz", + "integrity": "sha512-SO/3iYL5S3W57LLEniscOGPZgOqZUPCx6d3dB+52B80yJ0XstzsC/eV8gnA4tM3MHDrKz+OCFSLNjswdSC+/bA==", + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-styles": "^6.2.3", + "is-fullwidth-code-point": "^5.1.0" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-width": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", + "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", + "license": "MIT", + "optional": true, + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/terminal-size": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/terminal-size/-/terminal-size-4.0.1.tgz", + "integrity": "sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/type-fest": { + "version": "5.9.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.9.0.tgz", + "integrity": "sha512-yANm3Jr3GiJ1qgJlxGAVxTOIcEOk1rhQHamlXtnrCK7EHP4HeM9OGxtMg/W7HFdrVzw/ZWJKGVIJusVH85sLtw==", + "license": "(MIT OR CC0-1.0)", + "optional": true, + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/vite": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", + "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.26", + "rolldown": "~1.2.4", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0 || ^0.5.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-plugin-full-reload": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/vite-plugin-full-reload/-/vite-plugin-full-reload-1.2.0.tgz", + "integrity": "sha512-kz18NW79x0IHbxRSHm0jttP4zoO9P9gXh+n6UTwlNKnviTTEpOlum6oS9SmecrTtSr+muHEn5TUuC75UovQzcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picocolors": "^1.0.0", + "picomatch": "^2.3.1" + } + }, + "node_modules/vite-plugin-full-reload/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vite/node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/vite/node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/widest-line": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-6.0.0.tgz", + "integrity": "sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA==", + "license": "MIT", + "optional": true, + "dependencies": { + "string-width": "^8.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/wrap-ansi": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-10.0.1.tgz", + "integrity": "sha512-M0N4xzyzosiIok3svYlEo1sdLZts/8FPgYH/GPC3wvlmPoRvnoManGMrE54waYj3tISA8w6lsdesfVv67qSr8Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-styles": "^6.2.3", + "string-width": "^8.2.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^7.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yoga-layout": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz", + "integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==", + "license": "MIT", + "optional": true + } + } +} diff --git a/rsync-studio-licensing/package.json b/rsync-studio-licensing/package.json old mode 100644 new mode 100755 diff --git a/rsync-studio-licensing/phpunit.xml b/rsync-studio-licensing/phpunit.xml old mode 100644 new mode 100755 diff --git a/rsync-studio-licensing/public/.htaccess b/rsync-studio-licensing/public/.htaccess old mode 100644 new mode 100755 diff --git a/rsync-studio-licensing/public/browserconfig.xml b/rsync-studio-licensing/public/browserconfig.xml new file mode 100755 index 0000000..6492abb --- /dev/null +++ b/rsync-studio-licensing/public/browserconfig.xml @@ -0,0 +1,9 @@ + + + + + + #2563eb + + + diff --git a/rsync-studio-licensing/public/favicon.ico b/rsync-studio-licensing/public/favicon.ico old mode 100644 new mode 100755 index e69de29..439dd30 Binary files a/rsync-studio-licensing/public/favicon.ico and b/rsync-studio-licensing/public/favicon.ico differ diff --git a/rsync-studio-licensing/public/icons/android-chrome-192x192.png b/rsync-studio-licensing/public/icons/android-chrome-192x192.png new file mode 100755 index 0000000..6429177 Binary files /dev/null and b/rsync-studio-licensing/public/icons/android-chrome-192x192.png differ diff --git a/rsync-studio-licensing/public/icons/android-chrome-512x512.png b/rsync-studio-licensing/public/icons/android-chrome-512x512.png new file mode 100755 index 0000000..4defebc Binary files /dev/null and b/rsync-studio-licensing/public/icons/android-chrome-512x512.png differ diff --git a/rsync-studio-licensing/public/icons/apple-touch-icon.png b/rsync-studio-licensing/public/icons/apple-touch-icon.png new file mode 100755 index 0000000..14183ae Binary files /dev/null and b/rsync-studio-licensing/public/icons/apple-touch-icon.png differ diff --git a/rsync-studio-licensing/public/icons/favicon-16x16.png b/rsync-studio-licensing/public/icons/favicon-16x16.png new file mode 100755 index 0000000..32b6caa Binary files /dev/null and b/rsync-studio-licensing/public/icons/favicon-16x16.png differ diff --git a/rsync-studio-licensing/public/icons/favicon-32x32.png b/rsync-studio-licensing/public/icons/favicon-32x32.png new file mode 100755 index 0000000..c9f33f9 Binary files /dev/null and b/rsync-studio-licensing/public/icons/favicon-32x32.png differ diff --git a/rsync-studio-licensing/public/icons/favicon-48x48.png b/rsync-studio-licensing/public/icons/favicon-48x48.png new file mode 100755 index 0000000..c027bec Binary files /dev/null and b/rsync-studio-licensing/public/icons/favicon-48x48.png differ diff --git a/rsync-studio-licensing/public/icons/mstile-150x150.png b/rsync-studio-licensing/public/icons/mstile-150x150.png new file mode 100755 index 0000000..636b8ff Binary files /dev/null and b/rsync-studio-licensing/public/icons/mstile-150x150.png differ diff --git a/rsync-studio-licensing/public/icons/og-rsync-studio.png b/rsync-studio-licensing/public/icons/og-rsync-studio.png new file mode 100755 index 0000000..4defebc Binary files /dev/null and b/rsync-studio-licensing/public/icons/og-rsync-studio.png differ diff --git a/rsync-studio-licensing/public/icons/rsync-studio-icon-128.png b/rsync-studio-licensing/public/icons/rsync-studio-icon-128.png new file mode 100755 index 0000000..54c0cd7 Binary files /dev/null and b/rsync-studio-licensing/public/icons/rsync-studio-icon-128.png differ diff --git a/rsync-studio-licensing/public/icons/rsync-studio-icon-64.png b/rsync-studio-licensing/public/icons/rsync-studio-icon-64.png new file mode 100755 index 0000000..4276299 Binary files /dev/null and b/rsync-studio-licensing/public/icons/rsync-studio-icon-64.png differ diff --git a/rsync-studio-licensing/public/icons/safari-pinned-tab.svg b/rsync-studio-licensing/public/icons/safari-pinned-tab.svg new file mode 100755 index 0000000..d7cba39 --- /dev/null +++ b/rsync-studio-licensing/public/icons/safari-pinned-tab.svg @@ -0,0 +1,3 @@ + + + diff --git a/rsync-studio-licensing/public/index.php b/rsync-studio-licensing/public/index.php old mode 100644 new mode 100755 diff --git a/rsync-studio-licensing/public/robots.txt b/rsync-studio-licensing/public/robots.txt old mode 100644 new mode 100755 diff --git a/rsync-studio-licensing/public/site.webmanifest b/rsync-studio-licensing/public/site.webmanifest new file mode 100755 index 0000000..6878693 --- /dev/null +++ b/rsync-studio-licensing/public/site.webmanifest @@ -0,0 +1,13 @@ +{ + "name": "Rsync Studio", + "short_name": "Rsync Studio", + "description": "Native rsync job management for macOS.", + "start_url": "/", + "display": "standalone", + "background_color": "#f7f9fc", + "theme_color": "#0b1220", + "icons": [ + {"src": "/icons/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png"}, + {"src": "/icons/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png"} + ] +} diff --git a/rsync-studio-licensing/resources/css/app.css b/rsync-studio-licensing/resources/css/app.css old mode 100644 new mode 100755 diff --git a/rsync-studio-licensing/resources/js/app.js b/rsync-studio-licensing/resources/js/app.js old mode 100644 new mode 100755 diff --git a/rsync-studio-licensing/resources/views/admin/index.blade.php b/rsync-studio-licensing/resources/views/admin/index.blade.php old mode 100644 new mode 100755 index 8df543f..b0e4829 --- a/rsync-studio-licensing/resources/views/admin/index.blade.php +++ b/rsync-studio-licensing/resources/views/admin/index.blade.php @@ -1 +1,6 @@ -@extends('layouts.app') @section('content')

Licensing administration

{{ $licenseCount }}

Licences

{{ $activeCount }}

Not revoked

{{ $activationCount }}

Active installations

{{ $userCount }}

Portal users

Manage licences@endsection +@extends('layouts.app') +@section('content') +
Admin

Licensing administration

Manage licences, activations and customer support.

+
{{ $licenseCount }}

Licences

{{ $activeCount }}

Not revoked

{{ $activationCount }}

Active installations

{{ $userCount }}

Portal users

+
Manage licencesSupport inbox
+@endsection diff --git a/rsync-studio-licensing/resources/views/admin/license.blade.php b/rsync-studio-licensing/resources/views/admin/license.blade.php old mode 100644 new mode 100755 diff --git a/rsync-studio-licensing/resources/views/admin/licenses.blade.php b/rsync-studio-licensing/resources/views/admin/licenses.blade.php old mode 100644 new mode 100755 diff --git a/rsync-studio-licensing/resources/views/admin/support/index.blade.php b/rsync-studio-licensing/resources/views/admin/support/index.blade.php new file mode 100755 index 0000000..db55a42 --- /dev/null +++ b/rsync-studio-licensing/resources/views/admin/support/index.blade.php @@ -0,0 +1,24 @@ +@extends('layouts.app', ['title' => 'Support Inbox — Rsync Studio']) +@section('content') +
Admin

Support inbox

Customer, licence, billing and privacy requests.

+
+ + +
+
+@forelse($tickets as $ticket) + +
{{ $ticket->reference }}{{ $ticket->subject }}{{ $ticket->name }} · {{ $ticket->email }}
+ {{ ucfirst($ticket->category) }} + {{ ucfirst($ticket->status) }} + +
+@empty +

No support requests

The support inbox is empty.

+@endforelse +
+{{ $tickets->links() }} +@endsection diff --git a/rsync-studio-licensing/resources/views/admin/support/show.blade.php b/rsync-studio-licensing/resources/views/admin/support/show.blade.php new file mode 100755 index 0000000..4248c64 --- /dev/null +++ b/rsync-studio-licensing/resources/views/admin/support/show.blade.php @@ -0,0 +1,14 @@ +@extends('layouts.app', ['title' => $ticket->reference.' — Support']) +@section('content') +← Support inbox +
{{ $ticket->reference }}

{{ $ticket->subject }}

{{ $ticket->name }} · {{ $ticket->email }}

{{ ucfirst($ticket->status) }}
+
+

Request

{{ ucfirst($ticket->category) }}{{ $ticket->created_at->format('j M Y H:i') }}
{!! nl2br(e($ticket->message)) !!}
Reply by email
+ +
+@endsection diff --git a/rsync-studio-licensing/resources/views/auth/forgot-password.blade.php b/rsync-studio-licensing/resources/views/auth/forgot-password.blade.php old mode 100644 new mode 100755 diff --git a/rsync-studio-licensing/resources/views/auth/login.blade.php b/rsync-studio-licensing/resources/views/auth/login.blade.php old mode 100644 new mode 100755 diff --git a/rsync-studio-licensing/resources/views/auth/register.blade.php b/rsync-studio-licensing/resources/views/auth/register.blade.php old mode 100644 new mode 100755 diff --git a/rsync-studio-licensing/resources/views/auth/reset-password.blade.php b/rsync-studio-licensing/resources/views/auth/reset-password.blade.php old mode 100644 new mode 100755 diff --git a/rsync-studio-licensing/resources/views/auth/verify-email.blade.php b/rsync-studio-licensing/resources/views/auth/verify-email.blade.php old mode 100644 new mode 100755 diff --git a/rsync-studio-licensing/resources/views/checkout/buy.blade.php b/rsync-studio-licensing/resources/views/checkout/buy.blade.php new file mode 100755 index 0000000..6b4ea18 --- /dev/null +++ b/rsync-studio-licensing/resources/views/checkout/buy.blade.php @@ -0,0 +1,91 @@ +@extends('layouts.app') +@section('content') +
+ Rsync Studio +

Choose your licence

+

Every subscription starts with a 7-day free trial. Cancel before the trial ends and you won't be charged.

+ @if($paddleEnvironment === 'sandbox')
Sandbox checkout — no real payment is taken
@endif +
+ +
+@foreach($plans as $code => $plan) +
+
+
+
+

{{ $plan['name'] }}

+
{{ $plan['billing'] }} billing
+
+ @if($plan['billing'] === 'Annual')Best value@endif +
+
{{ $plan['display_price'] }}
+
    +
  • 7-day free trial
  • +
  • {{ $plan['activation_limit'] }} active Mac{{ $plan['activation_limit'] === 1 ? '' : 's' }}
  • +
  • Secure online activation
  • +
  • Licence management portal
  • +
+
+ +
+@endforeach +
+ + + + + +@endsection diff --git a/rsync-studio-licensing/resources/views/layouts/app.blade.php b/rsync-studio-licensing/resources/views/layouts/app.blade.php old mode 100644 new mode 100755 index db27fb8..699b519 --- a/rsync-studio-licensing/resources/views/layouts/app.blade.php +++ b/rsync-studio-licensing/resources/views/layouts/app.blade.php @@ -1 +1,315 @@ -{{ $title ?? 'Rsync Studio' }}
@if(session('status'))
{{ session('status') }}
@endif @if($errors->any())
{{ $errors->first() }}
@endif @yield('content')
+ + + + + + + + @php + $pageTitle = $title ?? 'Rsync Studio'; + $pageDescription = $description ?? 'Native rsync job management for macOS. Create, schedule, watch and review reliable local and SSH sync jobs.'; + $canonicalUrl = url()->current(); + $socialImage = url('/icons/og-rsync-studio.png'); + @endphp + + {{ $pageTitle }} + + + + + + + + + @if(app()->environment('production')) + + @else + + @endif + + + + + + + + + + + + + + + + + + + + + + + + @stack('head') + + + + +@hasSection('fullbleed') + @yield('fullbleed') +@else +
+ @if(session('status'))
{{ session('status') }}
@endif + @if($errors->any())
{{ $errors->first() }}
@endif + @yield('content') +
+@endif + + + + + + + + + + + + diff --git a/rsync-studio-licensing/resources/views/legal/cookies.blade.php b/rsync-studio-licensing/resources/views/legal/cookies.blade.php new file mode 100755 index 0000000..b46c1cb --- /dev/null +++ b/rsync-studio-licensing/resources/views/legal/cookies.blade.php @@ -0,0 +1,16 @@ +@extends('layouts.app', ['title' => 'Cookie Policy — Rsync Studio']) + +@section('content') + +@endsection diff --git a/rsync-studio-licensing/resources/views/legal/privacy.blade.php b/rsync-studio-licensing/resources/views/legal/privacy.blade.php new file mode 100755 index 0000000..04866ae --- /dev/null +++ b/rsync-studio-licensing/resources/views/legal/privacy.blade.php @@ -0,0 +1,39 @@ +@extends('layouts.app', ['title' => 'Privacy Policy — Rsync Studio']) + +@section('content') +@php + $legal = config('rsync-studio.legal'); + $retention = config('rsync-studio.privacy.support_retention_days', 730); +@endphp + +@endsection diff --git a/rsync-studio-licensing/resources/views/legal/terms.blade.php b/rsync-studio-licensing/resources/views/legal/terms.blade.php new file mode 100755 index 0000000..617f52f --- /dev/null +++ b/rsync-studio-licensing/resources/views/legal/terms.blade.php @@ -0,0 +1,19 @@ +@extends('layouts.app', ['title' => 'Terms of Service — Rsync Studio']) + +@section('content') +@php($legal = config('rsync-studio.legal')) + +@endsection diff --git a/rsync-studio-licensing/resources/views/portal/index.blade.php b/rsync-studio-licensing/resources/views/portal/index.blade.php old mode 100644 new mode 100755 index f43bef1..c6f1ea5 --- a/rsync-studio-licensing/resources/views/portal/index.blade.php +++ b/rsync-studio-licensing/resources/views/portal/index.blade.php @@ -1 +1,113 @@ -@extends('layouts.app') @section('content')

My licences

@forelse($licenses as $license)

{{ $license->product->name }} · {{ $license->edition }}

Licence ending {{ $license->license_key_last4 }} · {{ $license->activations->whereNull('deactivated_at')->count() }}/{{ $license->activation_limit }} activations

{{ $license->is_usable ? 'Active':'Unavailable' }}

Manage
@empty
No licences are associated with {{ auth()->user()->email }}.
@endforelse@endsection +@extends('layouts.app') + +@section('content') +
+
+ Account +

My licences

+

Manage subscriptions, licence keys and activated Macs.

+
+ Buy another licence +
+ +@php + $activeLicences = $licenses->filter(fn ($license) => $license->is_usable); + $historyLicences = $licenses->reject(fn ($license) => $license->is_usable); +@endphp + +@if($activeLicences->isEmpty()) +
+

No active licences

+

No active Rsync Studio licence is associated with {{ auth()->user()->email }}.

+ Choose a plan +
+@else +
+ @foreach($activeLicences as $license) + @php + $activeCount = $license->activations->whereNull('deactivated_at')->count(); + $isDuplicatePersonal = $hasProfessional && $license->edition === 'Personal'; + $billing = str_contains(strtolower((string) optional($license->product)->code), 'annual') ? 'Annual' : 'Monthly'; + @endphp +
+
+
+
+
+ Active + {{ $license->edition }} + {{ $billing }} +
+

Rsync Studio {{ $license->edition }}

+

Licence ending {{ $license->license_key_last4 }}

+
+ Manage licence +
+ +
+
+ Macs activated + {{ $activeCount }} of {{ $license->activation_limit }} +
+
+ Subscription + {{ ucfirst($license->subscription_status ?: 'active') }} +
+
+ {{ $license->subscription_status === 'trialing' ? 'Trial ends' : 'Renews' }} + {{ optional($license->current_period_ends_at)->format('j M Y') ?: '—' }} +
+
+ +
+ +
+ + @if($isDuplicatePersonal) +
+
+ You already have an active Professional subscription. +

This Personal plan is a separate Paddle subscription and could be billed separately. Cancel it if it was only created while upgrading.

+
+
+ @csrf + @method('DELETE') + +
+
+ @elseif($license->edition === 'Personal') +
+
+ Need more Macs? + Upgrade this subscription to Professional for up to 3 Macs. +
+
+ @csrf + +
+
+ @endif +
+
+ @endforeach +
+@endif + +@if($historyLicences->isNotEmpty()) +
+ Licence history {{ $historyLicences->count() }} +
+ @foreach($historyLicences as $license) +
+
+ Rsync Studio {{ $license->edition }} + Licence ending {{ $license->license_key_last4 }} +
+
{{ ucfirst($license->subscription_status ?: ($license->revoked_at ? 'revoked' : 'unavailable')) }}
+ Details +
+ @endforeach +
+
+@endif +@endsection diff --git a/rsync-studio-licensing/resources/views/portal/show.blade.php b/rsync-studio-licensing/resources/views/portal/show.blade.php old mode 100644 new mode 100755 index ff70c71..1832157 --- a/rsync-studio-licensing/resources/views/portal/show.blade.php +++ b/rsync-studio-licensing/resources/views/portal/show.blade.php @@ -1 +1,80 @@ -@extends('layouts.app') @section('content')

{{ $license->product->name }}

{{ $license->edition }} · key ending {{ $license->license_key_last4 }}

@csrf

Installations

@foreach($license->activations->whereNull('deactivated_at') as $a)@endforeach
InstallationPlatformVersionLast seen
{{ $a->installation_name ?: $a->installation_id }}{{ $a->platform }}{{ $a->app_version }}{{ optional($a->last_seen_at)->toDayDateTimeString() }}
@csrf @method('DELETE')
@endsection +@extends('layouts.app') + +@section('content') +@php + $activeActivations = $license->activations->whereNull('deactivated_at'); + $billing = str_contains(strtolower((string) optional($license->product)->code), 'annual') ? 'Annual' : 'Monthly'; +@endphp + +← My licences + +
+
+
+ {{ $license->is_usable ? 'Active' : 'Unavailable' }} + {{ $license->edition }} + {{ $billing }} +
+

Rsync Studio {{ $license->edition }}

+

Licence ending {{ $license->license_key_last4 }}

+
+
+ @csrf + +
+
+ +
+
+
+
+

Activated Macs

+

{{ $activeActivations->count() }} of {{ $license->activation_limit }} activation slots in use.

+
+ {{ $activeActivations->count() }}/{{ $license->activation_limit }} +
+ + @forelse($activeActivations as $activation) +
+
⌘
+
+ {{ $activation->installation_name ?: 'Mac' }} + {{ $activation->platform ?: 'macOS' }}@if($activation->app_version) · Rsync Studio {{ $activation->app_version }}@endif + Last seen {{ optional($activation->last_seen_at)->diffForHumans() ?: 'not yet recorded' }} +
+
+ @csrf + @method('DELETE') + +
+
+ @empty +
No Macs are currently activated with this licence.
+ @endforelse +
+ + +
+@endsection diff --git a/rsync-studio-licensing/resources/views/portal/welcome.blade.php b/rsync-studio-licensing/resources/views/portal/welcome.blade.php old mode 100644 new mode 100755 index c40dd42..ef03706 --- a/rsync-studio-licensing/resources/views/portal/welcome.blade.php +++ b/rsync-studio-licensing/resources/views/portal/welcome.blade.php @@ -1 +1,147 @@ -@extends('layouts.app') @section('content')

Rsync Studio licensing

Manage your Rsync Studio licence, installations and recovery options securely.

@guestSign in@endguest
@endsection +@extends('layouts.app', [ + 'title' => 'Rsync Studio — Native rsync for macOS', + 'description' => 'Create, schedule, watch and review local and SSH rsync jobs with a native macOS interface. Start a 7-day free trial.' +]) + +@section('fullbleed') +
+
+
+
+ Native macOS rsync management +

Rsync without the command-line friction.

+

Build reliable local and SSH sync jobs, test them safely with Dry Run, schedule recurring transfers, watch folders for changes and keep a complete run history — all from one native Mac app.

+ +

Personal for 1 Mac · Professional for up to 3 Macs · Monthly or annual

+
+ ✓ Native macOS app + ✓ Local & SSH jobs + ✓ Dry Run safeguards +
+
+ +
+
+ + Rsync Studio — Jobs +
+
+ +
+
Sync jobsSync All
+
+
DeveloperReady
+

Developer folder → remote server · SSH

+
+
+
+
Web Sites22:00
+

Sites folder → remote server · Daily

+
+
+
+
Project WatchWatching
+

Project folder · File watch

+
+
+
Last run: Successrsync 3.4.1
+
+
+
+
+
+ +
+
+
+ Everything in one place +

Built for everyday rsync work.

+

Keep the power of rsync while replacing repetitive commands and scattered scripts with a clear, repeatable workflow.

+
+
+
↔

Local & SSH sync

Create local copies or securely sync to remote Macs and Linux servers over SSH.

+
✓

Dry Run first

Preview changes before files are transferred or deleted, so risky jobs can be checked before they run.

+
◷

Flexible schedules

Run jobs once, hourly, daily, weekly, monthly or on your own recurring interval.

+
⌁

Watch for changes

Use macOS file events to trigger selected jobs when files or folders change.

+
≡

Complete run history

Review commands, results, stdout, stderr and transfer statistics for previous runs.

+
⌘

Mac-first experience

Manage jobs, defaults, background operation and connection settings from a native macOS interface.

+
+
+
+ +
+
+
+ A safer workflow +

From setup to sync in four clear steps.

+
+
+
1

Create a job

Choose your source, destination and rsync options once.

+
2

Run a Dry Run

Check the generated operation before changing any files.

+
3

Run or automate

Start it manually, schedule it or attach a file watcher.

+
4

Review history

See the result and retain the command and transfer output.

+
+
+
+ +
+
+
+
+ Designed around your Mac +

Your sync workflow stays direct.

+

Rsync Studio runs rsync and SSH from your Mac. The licensing service is separate from your file-transfer path.

+
+
+
K
Keychain-aware settingsStored passwords use macOS Keychain rather than application preferences.
+
S
SSH-native connectionsUse the standard macOS SSH client, keys and known-host verification for remote jobs.
+
D
Dry Run safeguardsTest jobs before enabling destructive options such as destination deletion.
+
+
+
+
+ +
+
+
+ Simple licensing +

Choose the number of Macs you need.

+

Both plans include the full Rsync Studio workflow and a 7-day free trial. Checkout shows pricing in your supported local currency.

+
+
+
+

Personal

+

For a single Mac and an individual sync workflow.

+
1 Mac
Monthly or annual subscription
+
  • Local and SSH jobs
  • Scheduling and file watch
  • Dry Run and history
  • 7-day free trial
+ Choose Personal +
+ +
+
+
+ +
+
+

Make rsync easier to run every day.

Start with a 7-day free trial and choose Personal or Professional at checkout.

+ Start free trial +
+
+
+@endsection diff --git a/rsync-studio-licensing/resources/views/support.blade.php b/rsync-studio-licensing/resources/views/support.blade.php new file mode 100755 index 0000000..aba46f0 --- /dev/null +++ b/rsync-studio-licensing/resources/views/support.blade.php @@ -0,0 +1,100 @@ +@extends('layouts.app', [ + 'title' => 'Support — Rsync Studio', + 'description' => 'Contact Rsync Studio support for technical, licence, billing and privacy enquiries.' +]) + +@section('content') +
+ Support +

How can we help?

+

Send us the details below. Your request is saved securely, assigned a reference and emailed to the support team.

+
+ +
+
+
+
+

Contact support

+

We will reply to the email address you provide.

+
+ Support ticket +
+ +
+ @csrf + + +
+
+ + + @error('name')
{{ $message }}
@enderror +
+
+ + + @error('email')
{{ $message }}
@enderror +
+
+ +
+
+ + + @error('category')
{{ $message }}
@enderror +
+
+ + + @error('subject')
{{ $message }}
@enderror +
+
+ +
+ + +
Do not include passwords, private SSH keys or your full licence token.
+ @error('message')
{{ $message }}
@enderror +
+ + + @error('privacy_notice')
{{ $message }}
@enderror + +
+ + @auth + @if(auth()->user()->hasVerifiedEmail())Open licence portal@endif + @endauth +
+
+
+ + +
+@endsection diff --git a/rsync-studio-licensing/resources/views/welcome.blade.php b/rsync-studio-licensing/resources/views/welcome.blade.php old mode 100644 new mode 100755 diff --git a/rsync-studio-licensing/routes/api.php b/rsync-studio-licensing/routes/api.php old mode 100644 new mode 100755 index 767dc20..9548279 --- a/rsync-studio-licensing/routes/api.php +++ b/rsync-studio-licensing/routes/api.php @@ -1,3 +1,12 @@ 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']); +}); diff --git a/rsync-studio-licensing/routes/api.stage4.php b/rsync-studio-licensing/routes/api.stage4.php deleted file mode 100644 index 2f3994a..0000000 --- a/rsync-studio-licensing/routes/api.stage4.php +++ /dev/null @@ -1,11 +0,0 @@ -group(function () { - Route::post('/activate', [LicenseActivationController::class, 'activate'])->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']); -}); diff --git a/rsync-studio-licensing/routes/console.php b/rsync-studio-licensing/routes/console.php new file mode 100755 index 0000000..b98b725 --- /dev/null +++ b/rsync-studio-licensing/routes/console.php @@ -0,0 +1,6 @@ +dailyAt('03:20')->withoutOverlapping(); diff --git a/rsync-studio-licensing/routes/web.php b/rsync-studio-licensing/routes/web.php old mode 100644 new mode 100755 index 97fbe0f..fd9f8c6 --- a/rsync-studio-licensing/routes/web.php +++ b/rsync-studio-licensing/routes/web.php @@ -1,3 +1,55 @@ 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'); +}); diff --git a/rsync-studio-licensing/routes/web.stage5.php b/rsync-studio-licensing/routes/web.stage5.php deleted file mode 100644 index 61f0703..0000000 --- a/rsync-studio-licensing/routes/web.stage5.php +++ /dev/null @@ -1,14 +0,0 @@ -view('portal.welcome'))->name('home'); -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}/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');}); diff --git a/rsync-studio-licensing/scripts/create-rsync-studio-catalog.sh b/rsync-studio-licensing/scripts/create-rsync-studio-catalog.sh new file mode 100755 index 0000000..d430c09 --- /dev/null +++ b/rsync-studio-licensing/scripts/create-rsync-studio-catalog.sh @@ -0,0 +1,247 @@ +#!/usr/bin/env bash + +set -euo pipefail + +PADDLE_API_KEY="${PADDLE_API_KEY:-}" + +if [[ -z "$PADDLE_API_KEY" ]]; then + echo "ERROR: PADDLE_API_KEY is not set." + echo 'Run: export PADDLE_API_KEY="pdl_sdbx_apikey_..."' + exit 1 +fi + +API="https://sandbox-api.paddle.com" + +api_post() { + local endpoint="$1" + local payload="$2" + + curl --fail-with-body --silent --show-error \ + --request POST \ + --url "${API}${endpoint}" \ + --header "Authorization: Bearer ${PADDLE_API_KEY}" \ + --header "Content-Type: application/json" \ + --data "$payload" +} + +create_product() { + local name="$1" + local description="$2" + + api_post "/products" "$(jq -n \ + --arg name "$name" \ + --arg description "$description" \ + '{ + name: $name, + description: $description, + type: "standard", + tax_category: "standard" + }' + )" +} + +create_price() { + local product_id="$1" + local name="$2" + local description="$3" + local cycle="$4" + local usd="$5" + local gbp="$6" + local eur="$7" + local aud="$8" + + api_post "/prices" "$(jq -n \ + --arg product_id "$product_id" \ + --arg name "$name" \ + --arg description "$description" \ + --arg cycle "$cycle" \ + --arg usd "$usd" \ + --arg gbp "$gbp" \ + --arg eur "$eur" \ + --arg aud "$aud" \ + '{ + product_id: $product_id, + name: $name, + description: $description, + + billing_cycle: { + interval: $cycle, + frequency: 1 + }, + + trial_period: { + interval: "day", + frequency: 7 + }, + + unit_price: { + amount: $usd, + currency_code: "USD" + }, + + unit_price_overrides: [ + { + country_codes: ["GB"], + unit_price: { + amount: $gbp, + currency_code: "GBP" + } + }, + { + country_codes: ["IE"], + unit_price: { + amount: $eur, + currency_code: "EUR" + } + }, + { + country_codes: ["AU"], + unit_price: { + amount: $aud, + currency_code: "AUD" + } + } + ], + + tax_mode: "account_setting", + + quantity: { + minimum: 1, + maximum: 1 + } + }' + )" +} + +echo +echo "Creating Rsync Studio Paddle sandbox catalog..." +echo + +# +# Personal +# + +PERSONAL_PRODUCT=$(create_product \ + "Rsync Studio Personal" \ + "Rsync Studio for personal use.") + +PERSONAL_PRODUCT_ID=$(echo "$PERSONAL_PRODUCT" | jq -r '.data.id') + +echo "Created Personal product: $PERSONAL_PRODUCT_ID" + +PERSONAL_MONTHLY=$(create_price \ + "$PERSONAL_PRODUCT_ID" \ + "Personal Monthly" \ + "Rsync Studio Personal — monthly subscription with 7-day free trial." \ + "month" \ + "199" \ + "199" \ + "199" \ + "299") + +PERSONAL_MONTHLY_ID=$(echo "$PERSONAL_MONTHLY" | jq -r '.data.id') + +echo "Created Personal Monthly: $PERSONAL_MONTHLY_ID" + +PERSONAL_ANNUAL=$(create_price \ + "$PERSONAL_PRODUCT_ID" \ + "Personal Annual" \ + "Rsync Studio Personal — annual subscription with 7-day free trial." \ + "year" \ + "1499" \ + "1499" \ + "1699" \ + "2499") + +PERSONAL_ANNUAL_ID=$(echo "$PERSONAL_ANNUAL" | jq -r '.data.id') + +echo "Created Personal Annual: $PERSONAL_ANNUAL_ID" + +# +# Professional +# + +PRO_PRODUCT=$(create_product \ + "Rsync Studio Professional" \ + "Rsync Studio for professional and commercial use.") + +PRO_PRODUCT_ID=$(echo "$PRO_PRODUCT" | jq -r '.data.id') + +echo "Created Professional product: $PRO_PRODUCT_ID" + +PRO_MONTHLY=$(create_price \ + "$PRO_PRODUCT_ID" \ + "Professional Monthly" \ + "Rsync Studio Professional — monthly subscription with 7-day free trial." \ + "month" \ + "399" \ + "399" \ + "449" \ + "699") + +PRO_MONTHLY_ID=$(echo "$PRO_MONTHLY" | jq -r '.data.id') + +echo "Created Professional Monthly: $PRO_MONTHLY_ID" + +PRO_ANNUAL=$(create_price \ + "$PRO_PRODUCT_ID" \ + "Professional Annual" \ + "Rsync Studio Professional — annual subscription with 7-day free trial." \ + "year" \ + "2999" \ + "2999" \ + "3499" \ + "4999") + +PRO_ANNUAL_ID=$(echo "$PRO_ANNUAL" | jq -r '.data.id') + +echo "Created Professional Annual: $PRO_ANNUAL_ID" + +echo +echo "============================================================" +echo " RSYNC STUDIO — PADDLE SANDBOX CATALOG" +echo "============================================================" +echo +echo "PERSONAL" +echo " Product ID: $PERSONAL_PRODUCT_ID" +echo " Monthly Price ID: $PERSONAL_MONTHLY_ID" +echo " Annual Price ID: $PERSONAL_ANNUAL_ID" +echo +echo "PROFESSIONAL" +echo " Product ID: $PRO_PRODUCT_ID" +echo " Monthly Price ID: $PRO_MONTHLY_ID" +echo " Annual Price ID: $PRO_ANNUAL_ID" +echo +echo "------------------------------------------------------------" +echo "Pricing" +echo "------------------------------------------------------------" +echo +printf "%-24s %-12s %-12s %-12s %-12s\n" \ + "Plan" "USD" "UK" "Ireland" "Australia" +printf "%-24s %-12s %-12s %-12s %-12s\n" \ + "Personal Monthly" '$1.99' '£1.99' '€1.99' 'A$2.99' +printf "%-24s %-12s %-12s %-12s %-12s\n" \ + "Personal Annual" '$14.99' '£14.99' '€16.99' 'A$24.99' +printf "%-24s %-12s %-12s %-12s %-12s\n" \ + "Professional Monthly" '$3.99' '£3.99' '€4.49' 'A$6.99' +printf "%-24s %-12s %-12s %-12s %-12s\n" \ + "Professional Annual" '$29.99' '£29.99' '€34.99' 'A$49.99' +echo +echo "All four prices include a 7-day FREE trial." +echo +echo "============================================================" + +cat > rsync-studio-paddle-ids.env <set('rsync-studio.paddle.client_side_token', 'test_example'); + config()->set('rsync-studio.paddle.environment', 'sandbox'); + config()->set('rsync-studio.paddle.personal_monthly_price_id', 'pri_personal_monthly'); + config()->set('rsync-studio.paddle.personal_annual_price_id', 'pri_personal_annual'); + config()->set('rsync-studio.paddle.professional_monthly_price_id', 'pri_professional_monthly'); + config()->set('rsync-studio.paddle.professional_annual_price_id', 'pri_professional_annual'); + + $this->get('/buy') + ->assertOk() + ->assertSee('7-day free trial') + ->assertSee('pri_personal_monthly') + ->assertSee('pri_personal_annual') + ->assertSee('pri_professional_monthly') + ->assertSee('pri_professional_annual') + ->assertSee('test_example'); + } +} diff --git a/rsync-studio-licensing/tests/Feature/LicenseActivationTest.php b/rsync-studio-licensing/tests/Feature/LicenseActivationTest.php old mode 100644 new mode 100755 index 4db0d4f..7d81a87 --- a/rsync-studio-licensing/tests/Feature/LicenseActivationTest.php +++ b/rsync-studio-licensing/tests/Feature/LicenseActivationTest.php @@ -1,6 +1,8 @@ '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([ + 'code' => 'validate-active', + 'name' => 'Validate Active', + 'edition' => 'Professional', + 'activation_limit' => 1, + 'max_major_version' => 1, + 'active' => true, + ]); + [, $key] = app(LicenseIssuer::class)->issue($product, 'Test', 'test@example.com'); + $request = [ + 'licenseKey' => $key, + 'installationID' => '11111111-1111-4111-8111-111111111111', + 'appVersion' => '1.0.0', + 'platform' => 'macOS', + ]; + + $token = $this->postJson('/api/v1/activate', $request)->assertOk()->json('licenseToken'); + + $this->postJson('/api/v1/validate', [ + 'licenseToken' => $token, + 'installationID' => $request['installationID'], + 'appVersion' => '1.0.1', + 'platform' => 'macOS', + ])->assertOk()->assertJson([ + 'active' => true, + ])->assertJsonStructure(['licenseToken']); + } + + public function test_validation_rejects_an_installation_deactivated_from_the_portal(): void + { + $product = LicenseProduct::create([ + 'code' => 'validate-deactivated', + 'name' => 'Validate Deactivated', + 'edition' => 'Personal', + 'activation_limit' => 1, + 'max_major_version' => 1, + 'active' => true, + ]); + [, $key] = app(LicenseIssuer::class)->issue($product, 'Test', 'test@example.com'); + $request = [ + 'licenseKey' => $key, + 'installationID' => '22222222-2222-4222-8222-222222222222', + 'appVersion' => '1.0.0', + 'platform' => 'macOS', + ]; + + $token = $this->postJson('/api/v1/activate', $request)->assertOk()->json('licenseToken'); + LicenseActivation::query()->where('installation_id', strtolower($request['installationID']))->update(['deactivated_at' => now()]); + + $this->postJson('/api/v1/validate', [ + 'licenseToken' => $token, + 'installationID' => $request['installationID'], + 'appVersion' => '1.0.0', + 'platform' => 'macOS', + ])->assertStatus(403)->assertJson([ + 'message' => 'This Mac has been deactivated from the licence portal.', + ]); } } diff --git a/rsync-studio-licensing/tests/Feature/PaddleSubscriptionProvisioningTest.php b/rsync-studio-licensing/tests/Feature/PaddleSubscriptionProvisioningTest.php new file mode 100755 index 0000000..527216e --- /dev/null +++ b/rsync-studio-licensing/tests/Feature/PaddleSubscriptionProvisioningTest.php @@ -0,0 +1,105 @@ + strtolower($edition).'-'.$price, + 'name' => $edition, + 'edition' => $edition, + 'activation_limit' => $activations, + 'max_major_version' => 1, + 'payment_provider' => 'paddle', + 'external_product_id' => $edition === 'Personal' ? 'pro_personal' : 'pro_professional', + 'external_price_id' => $price, + 'active' => true, + ]); + } + + private function subscription(string $type, string $status, string $price, string $product, array $extra = []): array + { + return [ + 'event_type' => $type, + 'data' => array_replace_recursive([ + 'id' => 'sub_test', + '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'], + 'scheduled_change' => null, + 'items' => [[ + 'recurring' => true, + 'price' => ['id' => $price, 'product_id' => $product], + ]], + ], $extra), + ]; + } + + public function test_trialing_subscription_issues_a_usable_license(): void + { + $this->product('pri_personal_monthly', 'Personal', 1); + + app(PurchaseProvisioner::class)->provision('paddle', $this->subscription( + 'subscription.created', 'trialing', 'pri_personal_monthly', 'pro_personal' + )); + + $license = License::firstOrFail(); + $this->assertSame('Personal', $license->edition); + $this->assertSame('sub_test', $license->external_subscription_id); + $this->assertSame('trialing', $license->subscription_status); + $this->assertTrue($license->is_usable); + } + + public function test_upgrade_changes_entitlement_without_changing_license(): void + { + $this->product('pri_personal_monthly', 'Personal', 1); + $this->product('pri_professional_monthly', 'Professional', 3); + $service = app(PurchaseProvisioner::class); + + $service->provision('paddle', $this->subscription('subscription.created', 'active', 'pri_personal_monthly', 'pro_personal')); + $id = License::firstOrFail()->id; + $service->provision('paddle', $this->subscription('subscription.updated', 'active', 'pri_professional_monthly', 'pro_professional')); + + $license = License::firstOrFail(); + $this->assertSame($id, $license->id); + $this->assertSame('Professional', $license->edition); + $this->assertSame(3, $license->activation_limit); + } + + public function test_scheduled_cancellation_does_not_revoke_before_effective_date(): void + { + $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']]] + )); + + $license = License::firstOrFail(); + $this->assertNull($license->revoked_at); + $this->assertNotNull($license->cancel_scheduled_at); + } + + public function test_canceled_subscription_revokes_license(): void + { + $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]])); + + $license = License::firstOrFail(); + $this->assertNotNull($license->revoked_at); + $this->assertFalse($license->is_usable); + } +} diff --git a/rsync-studio-licensing/tests/Feature/PortalSubscriptionManagementTest.php b/rsync-studio-licensing/tests/Feature/PortalSubscriptionManagementTest.php new file mode 100755 index 0000000..5ebb77f --- /dev/null +++ b/rsync-studio-licensing/tests/Feature/PortalSubscriptionManagementTest.php @@ -0,0 +1,66 @@ + $code, + 'name' => 'Rsync Studio '.$edition.' '.(str_contains($code, 'annual') ? 'Annual' : 'Monthly'), + 'edition' => $edition, + 'activation_limit' => $limit, + 'max_major_version' => 1, + 'payment_provider' => 'paddle', + 'external_product_id' => 'pro_'.strtolower($edition), + 'external_price_id' => $price, + 'active' => true, + ]); + } + + public function test_personal_subscription_is_upgraded_in_place(): void + { + config()->set('rsync-studio.paddle.personal_monthly_price_id', 'pri_personal_monthly'); + config()->set('rsync-studio.paddle.professional_monthly_price_id', 'pri_professional_monthly'); + + $personal = $this->product('personal-monthly', 'Personal', 'pri_personal_monthly', 1); + $professional = $this->product('professional-monthly', 'Professional', 'pri_professional_monthly', 3); + $user = User::factory()->create(['email' => 'mark@example.com', 'email_verified_at' => now()]); + $license = License::create([ + 'license_product_id' => $personal->id, + 'license_key_hash' => str_repeat('a', 64), + 'license_key_last4' => 'TEST', + 'customer_email' => $user->email, + 'edition' => 'Personal', + 'activation_limit' => 1, + 'max_major_version' => 1, + 'payment_provider' => 'paddle', + 'external_subscription_id' => 'sub_test', + 'external_price_id' => 'pri_personal_monthly', + 'subscription_status' => 'active', + ]); + + $this->mock(PaddleClient::class, function (MockInterface $mock) { + $mock->shouldReceive('updateSubscriptionPrice')->once()->with('sub_test', 'pri_professional_monthly')->andReturn(['status' => 'active']); + }); + + $this->actingAs($user)->post(route('portal.upgrade', $license))->assertRedirect(route('portal.show', $license)); + + $license->refresh(); + $this->assertSame($professional->id, $license->license_product_id); + $this->assertSame('Professional', $license->edition); + $this->assertSame(3, $license->activation_limit); + $this->assertSame('sub_test', $license->external_subscription_id); + } +} diff --git a/rsync-studio-licensing/vite.config.js b/rsync-studio-licensing/vite.config.js old mode 100644 new mode 100755