Compare commits
4
Commits
8f85c41bea
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
627f578cd8 | ||
|
|
8dd7d76b8e | ||
|
|
2648a8f5b5 | ||
|
|
7037ad983a |
@@ -1,10 +1,36 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Console\Commands;
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
final class RsyncStudioAdminCreate extends Command {
|
|
||||||
protected $signature='rsync-studio:admin {email} {--name=Administrator}'; protected $description='Create or promote an Rsync Studio licensing administrator';
|
final class RsyncStudioAdminCreate extends Command
|
||||||
public function handle(): int { $email=strtolower(trim($this->argument('email'))); $password=Str::password(24,true,true,false,false); $u=User::firstOrNew(['email'=>$email]); $u->name=$this->option('name'); $u->is_admin=true; $u->email_verified_at=now(); if(!$u->exists){$u->password=Hash::make($password);} $u->save(); $this->info("Administrator ready: {$email}"); if(!$u->wasRecentlyCreated){$this->line('Existing password retained.');}else{$this->warn("Temporary password (shown once): {$password}");} return self::SUCCESS; }
|
{
|
||||||
|
protected $signature = 'rsync-studio:admin {email} {--name=Administrator}';
|
||||||
|
|
||||||
|
protected $description = 'Create or promote an Rsync Studio licensing administrator';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$email = strtolower(trim($this->argument('email')));
|
||||||
|
$password = Str::password(24, true, true, false, false);
|
||||||
|
$u = User::firstOrNew(['email' => $email]);
|
||||||
|
$u->name = $this->option('name');
|
||||||
|
$u->is_admin = true;
|
||||||
|
$u->email_verified_at = now();
|
||||||
|
if (! $u->exists) {
|
||||||
|
$u->password = Hash::make($password);
|
||||||
|
} $u->save();
|
||||||
|
$this->info("Administrator ready: {$email}");
|
||||||
|
if (! $u->wasRecentlyCreated) {
|
||||||
|
$this->line('Existing password retained.');
|
||||||
|
} else {
|
||||||
|
$this->warn("Temporary password (shown once): {$password}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,29 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Console\Commands;
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
use App\Models\LicenseProduct;
|
use App\Models\LicenseProduct;
|
||||||
use App\Services\Licensing\LicenseIssuer;
|
use App\Services\Licensing\LicenseIssuer;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
class RsyncStudioLicenseIssue extends Command
|
class RsyncStudioLicenseIssue extends Command
|
||||||
{
|
{
|
||||||
protected $signature='rsync-studio:license:issue {product} {--name=} {--email=}'; protected $description='Issue a manual Rsync Studio licence key';
|
protected $signature = 'rsync-studio:license:issue {product} {--name=} {--email=}';
|
||||||
public function handle(LicenseIssuer $issuer): int { $p=LicenseProduct::where('code',$this->argument('product'))->firstOrFail(); [$l,$key]=$issuer->issue($p,$this->option('name')?:null,$this->option('email')?:null); if(!$key){$this->warn('Licence already existed; plaintext key is intentionally not recoverable.');return self::FAILURE;} $this->line('Licence ID: '.$l->id);$this->line('Licence key: '.$key);$this->warn('This is the only time the plaintext key is shown.');return self::SUCCESS; }
|
|
||||||
|
protected $description = 'Issue a manual Rsync Studio licence key';
|
||||||
|
|
||||||
|
public function handle(LicenseIssuer $issuer): int
|
||||||
|
{
|
||||||
|
$p = LicenseProduct::where('code', $this->argument('product'))->firstOrFail();
|
||||||
|
[$l,$key] = $issuer->issue($p, $this->option('name') ?: null, $this->option('email') ?: null);
|
||||||
|
if (! $key) {
|
||||||
|
$this->warn('Licence already existed; plaintext key is intentionally not recoverable.');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
} $this->line('Licence ID: '.$l->id);
|
||||||
|
$this->line('Licence key: '.$key);
|
||||||
|
$this->warn('This is the only time the plaintext key is shown.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,22 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Console\Commands;
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
use App\Models\License;
|
use App\Models\License;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
class RsyncStudioLicenseRevoke extends Command
|
class RsyncStudioLicenseRevoke extends Command
|
||||||
{
|
{
|
||||||
protected $signature='rsync-studio:license:revoke {id} {--reason=Administrative revocation}'; protected $description='Revoke a Rsync Studio licence';
|
protected $signature = 'rsync-studio:license:revoke {id} {--reason=Administrative revocation}';
|
||||||
public function handle(): int { $l=License::findOrFail($this->argument('id'));$l->update(['revoked_at'=>now(),'revoke_reason'=>$this->option('reason')]);$this->info('Licence revoked.');return self::SUCCESS; }
|
|
||||||
|
protected $description = 'Revoke a Rsync Studio licence';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$l = License::findOrFail($this->argument('id'));
|
||||||
|
$l->update(['revoked_at' => now(), 'revoke_reason' => $this->option('reason')]);
|
||||||
|
$this->info('Licence revoked.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,21 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Console\Commands;
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
use App\Models\LicenseProduct;
|
use App\Models\LicenseProduct;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
class RsyncStudioProductCreate extends Command
|
class RsyncStudioProductCreate extends Command
|
||||||
{
|
{
|
||||||
protected $signature = 'rsync-studio:product {code} {--name=Rsync Studio} {--edition=Professional} {--activations=1} {--max-major=1} {--provider=} {--product-id=} {--price-id=}';
|
protected $signature = 'rsync-studio:product {code} {--name=Rsync Studio} {--edition=Professional} {--activations=1} {--max-major=1} {--provider=} {--product-id=} {--price-id=}';
|
||||||
|
|
||||||
protected $description = 'Create or update a Rsync Studio licensing product';
|
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; }
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,49 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Http\Controllers\Admin;
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\License;
|
use App\Models\License;
|
||||||
use App\Models\LicenseProduct;
|
use App\Models\LicenseActivation;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
final class AdminController extends Controller {
|
|
||||||
public function index(){ return view('admin.index',['licenseCount'=>License::count(),'activeCount'=>License::whereNull('revoked_at')->count(),'activationCount'=>\App\Models\LicenseActivation::whereNull('deactivated_at')->count(),'userCount'=>User::count()]); }
|
final class AdminController extends Controller
|
||||||
public function licenses(Request $r){ $q=License::with('product')->latest(); if($s=trim((string)$r->query('q'))) $q->where(fn($x)=>$x->where('customer_email','like',"%{$s}%")->orWhere('customer_name','like',"%{$s}%")->orWhere('license_key_last4','like',"%{$s}%")); return view('admin.licenses',['licenses'=>$q->paginate(50)->withQueryString()]); }
|
{
|
||||||
public function show(License $license){$license->load(['product','activations']); return view('admin.license',compact('license'));}
|
public function index()
|
||||||
public function revoke(Request $request,License $license){$data=$request->validate(['reason'=>['nullable','string','max:255']]);$license->update(['revoked_at'=>now(),'revoke_reason'=>$data['reason']?:'Revoked by administrator']);return back()->with('status','Licence revoked.');}
|
{
|
||||||
public function restore(License $license){$license->update(['revoked_at'=>null,'revoke_reason'=>null]);return back()->with('status','Licence restored.');}
|
return view('admin.index', ['licenseCount' => License::count(), 'activeCount' => License::whereNull('revoked_at')->count(), 'activationCount' => LicenseActivation::whereNull('deactivated_at')->count(), 'userCount' => User::count()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function licenses(Request $r)
|
||||||
|
{
|
||||||
|
$q = License::with('product')->latest();
|
||||||
|
if ($s = trim((string) $r->query('q'))) {
|
||||||
|
$q->where(fn ($x) => $x->where('customer_email', 'like', "%{$s}%")->orWhere('customer_name', 'like', "%{$s}%")->orWhere('license_key_last4', 'like', "%{$s}%"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('admin.licenses', ['licenses' => $q->paginate(50)->withQueryString()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(License $license)
|
||||||
|
{
|
||||||
|
$license->load(['product', 'activations']);
|
||||||
|
|
||||||
|
return view('admin.license', compact('license'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function revoke(Request $request, License $license)
|
||||||
|
{
|
||||||
|
$data = $request->validate(['reason' => ['nullable', 'string', 'max:255']]);
|
||||||
|
$license->update(['revoked_at' => now(), 'revoke_reason' => $data['reason'] ?: 'Revoked by administrator']);
|
||||||
|
|
||||||
|
return back()->with('status', 'Licence revoked.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function restore(License $license)
|
||||||
|
{
|
||||||
|
$license->update(['revoked_at' => null, 'revoke_reason' => null]);
|
||||||
|
|
||||||
|
return back()->with('status','Licence restored.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Http\Controllers\Api;
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\LicenseWebhookEvent;
|
use App\Models\LicenseWebhookEvent;
|
||||||
|
use App\Services\Licensing\LicenseDeliveryService;
|
||||||
use App\Services\Payments\LemonSqueezyWebhookVerifier;
|
use App\Services\Payments\LemonSqueezyWebhookVerifier;
|
||||||
use App\Services\Payments\PaddleWebhookVerifier;
|
use App\Services\Payments\PaddleWebhookVerifier;
|
||||||
use App\Services\Payments\PurchaseProvisioner;
|
use App\Services\Payments\PurchaseProvisioner;
|
||||||
use App\Services\Licensing\LicenseDeliveryService;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
@@ -16,28 +17,61 @@ final class PaymentWebhookController extends Controller
|
|||||||
public function __invoke(string $provider, Request $request, PurchaseProvisioner $provisioner, LicenseDeliveryService $delivery): JsonResponse
|
public function __invoke(string $provider, Request $request, PurchaseProvisioner $provisioner, LicenseDeliveryService $delivery): JsonResponse
|
||||||
{
|
{
|
||||||
$raw = (string) $request->getContent();
|
$raw = (string) $request->getContent();
|
||||||
$verifier=match($provider){'paddle'=>app(PaddleWebhookVerifier::class),'lemon-squeezy'=>app(LemonSqueezyWebhookVerifier::class),default=>null};
|
$verifier = match ($provider) {
|
||||||
if (!$verifier) return response()->json(['message'=>'Unknown webhook provider.'],404);
|
'paddle' => app(PaddleWebhookVerifier::class),'lemon-squeezy' => app(LemonSqueezyWebhookVerifier::class),default => null
|
||||||
try { $verifier->verify($request,$raw); } catch (\Throwable $e) { report($e); return response()->json(['message'=>'Invalid webhook signature.'],400); }
|
};
|
||||||
try { $payload=json_decode($raw,true,flags:JSON_THROW_ON_ERROR); } catch (\Throwable) { return response()->json(['message'=>'Invalid JSON.'],400); }
|
if (! $verifier) {
|
||||||
|
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'] ?? ''));
|
$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'));
|
$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);
|
if ($externalID === '') {
|
||||||
|
return response()->json(['message' => 'Webhook event has no stable identifier.'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
DB::transaction(function () use ($provider, $externalID, $eventType, $payload, $raw, $provisioner) {
|
DB::transaction(function () use ($provider, $externalID, $eventType, $payload, $raw, $provisioner) {
|
||||||
$event = LicenseWebhookEvent::firstOrCreate(['provider' => $provider, 'external_event_id' => $externalID], ['event_type' => $eventType, 'payload_sha256' => hash('sha256', $raw), 'payload' => $payload]);
|
$event = LicenseWebhookEvent::firstOrCreate(['provider' => $provider, 'external_event_id' => $externalID], ['event_type' => $eventType, 'payload_sha256' => hash('sha256', $raw), 'payload' => $payload]);
|
||||||
if ($event->processed_at) return;
|
if ($event->processed_at) {
|
||||||
if (!hash_equals($event->payload_sha256,hash('sha256',$raw))) throw new \RuntimeException('Webhook ID was reused with a different payload.');
|
return;
|
||||||
try { $provisioner->provision($provider,$payload); $event->update(['processed_at'=>now(),'processing_error'=>null]); }
|
}
|
||||||
catch (\Throwable $e) { $event->update(['processing_error'=>mb_substr($e->getMessage(),0,4000)]); throw $e; }
|
if (! hash_equals($event->payload_sha256, hash('sha256', $raw))) {
|
||||||
|
throw new \RuntimeException('Webhook ID was reused with a different payload.');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$provisioner->provision($provider, $payload);
|
||||||
|
$event->update(['processed_at' => now(), 'processing_error' => null]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$event->update(['processing_error' => mb_substr($e->getMessage(), 0, 4000)]);
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
} catch (\Throwable $e) { report($e); return response()->json(['message'=>'Webhook could not be processed.'],500); }
|
} catch (\Throwable $e) {
|
||||||
|
report($e);
|
||||||
|
|
||||||
|
return response()->json(['message' => 'Webhook could not be processed.'], 500);
|
||||||
|
}
|
||||||
|
|
||||||
// Delivery is intentionally outside the webhook transaction. A temporary mail
|
// Delivery is intentionally outside the webhook transaction. A temporary mail
|
||||||
// outage must not roll back an otherwise valid Paddle event. Pending outbox
|
// outage must not roll back an otherwise valid Paddle event. Pending outbox
|
||||||
// rows remain available for portal resend or a later webhook delivery attempt.
|
// rows remain available for portal resend or a later webhook delivery attempt.
|
||||||
try { $delivery->deliverPending(10); } catch (\Throwable $e) { report($e); }
|
try {
|
||||||
|
$delivery->deliverPending(10);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
report($e);
|
||||||
|
}
|
||||||
|
|
||||||
return response()->json(['ok' => true]);
|
return response()->json(['ok' => true]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,35 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Http\Controllers\Auth;
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
final class AuthenticatedSessionController extends Controller {
|
|
||||||
public function create(){ return view('auth.login'); }
|
final class AuthenticatedSessionController extends Controller
|
||||||
public function store(Request $request){
|
{
|
||||||
|
public function create()
|
||||||
|
{
|
||||||
|
return view('auth.login');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request)
|
||||||
|
{
|
||||||
$credentials = $request->validate(['email' => ['required', 'email'], 'password' => ['required', 'string']]);
|
$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'); }
|
if (! Auth::attempt(['email' => strtolower($credentials['email']), 'password' => $credentials['password']], $request->boolean('remember'))) {
|
||||||
$request->session()->regenerate(); return redirect()->intended(route('portal.index'));
|
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');
|
||||||
}
|
}
|
||||||
public function destroy(Request $request){ Auth::logout(); $request->session()->invalidate(); $request->session()->regenerateToken(); return redirect()->route('login'); }
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,31 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Http\Controllers\Auth;
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use Illuminate\Foundation\Auth\EmailVerificationRequest;
|
use Illuminate\Foundation\Auth\EmailVerificationRequest;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
final class EmailVerificationController extends Controller {
|
|
||||||
public function notice(){ return view('auth.verify-email'); }
|
final class EmailVerificationController extends Controller
|
||||||
public function verify(EmailVerificationRequest $request){ $request->fulfill(); return redirect()->route('portal.index')->with('status','Email verified.'); }
|
{
|
||||||
public function resend(Request $request){ if(!$request->user()->hasVerifiedEmail()) $request->user()->sendEmailVerificationNotification(); return back()->with('status','Verification link sent.'); }
|
public function notice()
|
||||||
|
{
|
||||||
|
return view('auth.verify-email');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function verify(EmailVerificationRequest $request)
|
||||||
|
{
|
||||||
|
$request->fulfill();
|
||||||
|
|
||||||
|
return redirect()->route('portal.index')->with('status', 'Email verified.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resend(Request $request)
|
||||||
|
{
|
||||||
|
if (! $request->user()->hasVerifiedEmail()) {
|
||||||
|
$request->user()->sendEmailVerificationNotification();
|
||||||
|
}
|
||||||
|
|
||||||
|
return back()->with('status', 'Verification link sent.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Http\Controllers\Auth;
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Auth\Events\PasswordReset;
|
use Illuminate\Auth\Events\PasswordReset;
|
||||||
@@ -7,9 +9,35 @@ use Illuminate\Http\Request;
|
|||||||
use Illuminate\Support\Facades\Password;
|
use Illuminate\Support\Facades\Password;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Illuminate\Validation\Rules\Password as PasswordRule;
|
use Illuminate\Validation\Rules\Password as PasswordRule;
|
||||||
final class PasswordController extends Controller {
|
|
||||||
public function requestForm(){ return view('auth.forgot-password'); }
|
final class PasswordController extends Controller
|
||||||
public function email(Request $request){ $request->validate(['email'=>['required','email']]); $status=Password::sendResetLink(['email'=>strtolower($request->email)]); return $status===Password::RESET_LINK_SENT?back()->with('status',__($status)):back()->withErrors(['email'=>__($status)]); }
|
{
|
||||||
public function resetForm(Request $request,string $token){ return view('auth.reset-password',['token'=>$token,'email'=>$request->query('email')]); }
|
public function requestForm()
|
||||||
public function reset(Request $request){ $data=$request->validate(['token'=>['required'],'email'=>['required','email'],'password'=>['required','confirmed',PasswordRule::min(12)->letters()->mixedCase()->numbers()]]); $status=Password::reset($data,function(User $user,string $password){$user->forceFill(['password'=>$password,'remember_token'=>Str::random(60)])->save();event(new PasswordReset($user));}); return $status===Password::PASSWORD_RESET?redirect()->route('login')->with('status',__($status)):back()->withErrors(['email'=>__($status)]); }
|
{
|
||||||
|
return view('auth.forgot-password');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function email(Request $request)
|
||||||
|
{
|
||||||
|
$request->validate(['email' => ['required', 'email']]);
|
||||||
|
$status = Password::sendResetLink(['email' => strtolower($request->email)]);
|
||||||
|
|
||||||
|
return $status === Password::RESET_LINK_SENT ? back()->with('status', __($status)) : back()->withErrors(['email' => __($status)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resetForm(Request $request, string $token)
|
||||||
|
{
|
||||||
|
return view('auth.reset-password', ['token' => $token, 'email' => $request->query('email')]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reset(Request $request)
|
||||||
|
{
|
||||||
|
$data = $request->validate(['token' => ['required'], 'email' => ['required', 'email'], 'password' => ['required', 'confirmed', PasswordRule::min(12)->letters()->mixedCase()->numbers()]]);
|
||||||
|
$status = Password::reset($data, function (User $user, string $password) {
|
||||||
|
$user->forceFill(['password' => $password, 'remember_token' => Str::random(60)])->save();
|
||||||
|
event(new PasswordReset($user));
|
||||||
|
});
|
||||||
|
|
||||||
|
return $status === Password::PASSWORD_RESET ? redirect()->route('login')->with('status', __($status)) : back()->withErrors(['email' => __($status)]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,29 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Http\Controllers\Auth;
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Auth\Events\Registered;
|
use Illuminate\Auth\Events\Registered;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Validation\Rules\Password;
|
use Illuminate\Validation\Rules\Password;
|
||||||
final class RegisteredUserController extends Controller {
|
|
||||||
public function create(){ return view('auth.register'); }
|
final class RegisteredUserController extends Controller
|
||||||
public function store(Request $request){
|
{
|
||||||
|
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()]]);
|
$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');
|
$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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Http\Middleware;
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
use Closure;
|
use Closure;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
final class AdminOnly {
|
|
||||||
public function handle(Request $request, Closure $next): Response {
|
final class AdminOnly
|
||||||
|
{
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
abort_unless($request->user()?->is_admin === true, 403);
|
abort_unless($request->user()?->is_admin === true, 403);
|
||||||
|
|
||||||
return $next($request);
|
return $next($request);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-3
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||||
@@ -9,11 +10,27 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
|||||||
class License extends Model
|
class License extends Model
|
||||||
{
|
{
|
||||||
use HasUuids;
|
use HasUuids;
|
||||||
|
|
||||||
public $incrementing = false;
|
public $incrementing = false;
|
||||||
|
|
||||||
protected $keyType = 'string';
|
protected $keyType = 'string';
|
||||||
|
|
||||||
protected $fillable = ['license_product_id', 'license_key_hash', 'license_key_last4', 'customer_name', 'customer_email', 'edition', 'activation_limit', 'max_major_version', 'expires_at', 'revoked_at', 'revoke_reason', 'payment_provider', 'external_order_id', 'external_customer_id', 'external_subscription_id', 'external_price_id', 'subscription_status', 'current_period_ends_at', 'cancel_scheduled_at'];
|
protected $fillable = ['license_product_id', 'license_key_hash', 'license_key_last4', 'customer_name', 'customer_email', 'edition', 'activation_limit', 'max_major_version', 'expires_at', 'revoked_at', 'revoke_reason', 'payment_provider', 'external_order_id', 'external_customer_id', 'external_subscription_id', 'external_price_id', 'subscription_status', 'current_period_ends_at', 'cancel_scheduled_at'];
|
||||||
|
|
||||||
protected $casts = ['expires_at' => 'datetime', 'revoked_at' => 'datetime', 'activation_limit' => 'integer', 'max_major_version' => 'integer', 'current_period_ends_at' => 'datetime', 'cancel_scheduled_at' => 'datetime'];
|
protected $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 product(): BelongsTo
|
||||||
public function getIsUsableAttribute(): bool { return $this->revoked_at === null && ($this->expires_at === null || $this->expires_at->isFuture()); }
|
{
|
||||||
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
class LicenseActivation extends Model
|
class LicenseActivation extends Model
|
||||||
{
|
{
|
||||||
public $timestamps = false;
|
public $timestamps = false;
|
||||||
|
|
||||||
protected $fillable = ['license_id', 'installation_id', 'installation_name', 'platform', 'app_version', 'activated_at', 'last_seen_at', 'deactivated_at'];
|
protected $fillable = ['license_id', 'installation_id', 'installation_name', 'platform', 'app_version', 'activated_at', 'last_seen_at', 'deactivated_at'];
|
||||||
|
|
||||||
protected $casts = ['activated_at' => 'datetime', 'last_seen_at' => 'datetime', 'deactivated_at' => 'datetime'];
|
protected $casts = ['activated_at' => 'datetime', 'last_seen_at' => 'datetime', 'deactivated_at' => 'datetime'];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
class LicenseDeliveryOutbox extends Model
|
class LicenseDeliveryOutbox extends Model
|
||||||
{
|
{
|
||||||
protected $table = 'license_delivery_outbox';
|
protected $table = 'license_delivery_outbox';
|
||||||
|
|
||||||
protected $fillable = ['license_id', 'encrypted_license_key', 'recipient_email', 'delivered_at'];
|
protected $fillable = ['license_id', 'encrypted_license_key', 'recipient_email', 'delivered_at'];
|
||||||
|
|
||||||
protected $casts = ['delivered_at' => 'datetime'];
|
protected $casts = ['delivered_at' => 'datetime'];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
@@ -6,5 +7,6 @@ use Illuminate\Database\Eloquent\Model;
|
|||||||
class LicenseProduct extends Model
|
class LicenseProduct extends Model
|
||||||
{
|
{
|
||||||
protected $fillable = ['code', 'name', 'edition', 'activation_limit', 'max_major_version', 'payment_provider', 'external_product_id', 'external_price_id', 'active'];
|
protected $fillable = ['code', 'name', 'edition', 'activation_limit', 'max_major_version', 'payment_provider', 'external_product_id', 'external_price_id', 'active'];
|
||||||
|
|
||||||
protected $casts = ['active' => 'boolean', 'activation_limit' => 'integer', 'max_major_version' => 'integer'];
|
protected $casts = ['active' => 'boolean', 'activation_limit' => 'integer', 'max_major_version' => 'integer'];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
class LicenseWebhookEvent extends Model
|
class LicenseWebhookEvent extends Model
|
||||||
{
|
{
|
||||||
protected $fillable = ['provider', 'external_event_id', 'event_type', 'payload_sha256', 'payload', 'processed_at', 'processing_error'];
|
protected $fillable = ['provider', 'external_event_id', 'event_type', 'payload_sha256', 'payload', 'processed_at', 'processing_error'];
|
||||||
|
|
||||||
protected $casts = ['payload' => 'array', 'processed_at' => 'datetime'];
|
protected $casts = ['payload' => 'array', 'processed_at' => 'datetime'];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,8 +25,15 @@ class SupportTicket extends Model
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function user(): BelongsTo { return $this->belongsTo(User::class); }
|
public function user(): BelongsTo
|
||||||
public function assignee(): BelongsTo { return $this->belongsTo(User::class, 'assigned_to'); }
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function assignee(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'assigned_to');
|
||||||
|
}
|
||||||
|
|
||||||
public function prunable(): Builder
|
public function prunable(): Builder
|
||||||
{
|
{
|
||||||
|
|||||||
+8
-1
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||||
@@ -9,7 +10,13 @@ use Illuminate\Notifications\Notifiable;
|
|||||||
class User extends Authenticatable implements MustVerifyEmail
|
class User extends Authenticatable implements MustVerifyEmail
|
||||||
{
|
{
|
||||||
use HasFactory, Notifiable;
|
use HasFactory, Notifiable;
|
||||||
|
|
||||||
protected $fillable = ['name', 'email', 'password', 'is_admin'];
|
protected $fillable = ['name', 'email', 'password', 'is_admin'];
|
||||||
|
|
||||||
protected $hidden = ['password', 'remember_token'];
|
protected $hidden = ['password', 'remember_token'];
|
||||||
protected function casts(): array { return ['email_verified_at'=>'datetime','password'=>'hashed','is_admin'=>'boolean']; }
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return ['email_verified_at' => 'datetime', 'password' => 'hashed', 'is_admin' => 'boolean'];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ namespace App\Notifications;
|
|||||||
|
|
||||||
use App\Models\SupportTicket;
|
use App\Models\SupportTicket;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Notifications\AnonymousNotifiable;
|
||||||
use Illuminate\Notifications\Messages\MailMessage;
|
use Illuminate\Notifications\Messages\MailMessage;
|
||||||
use Illuminate\Notifications\Notification;
|
use Illuminate\Notifications\Notification;
|
||||||
use Illuminate\Notifications\AnonymousNotifiable;
|
|
||||||
|
|
||||||
class NewSupportTicket extends Notification
|
class NewSupportTicket extends Notification
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -22,7 +22,9 @@ final class LicenseDeliveryService
|
|||||||
foreach ($pending as $outbox) {
|
foreach ($pending as $outbox) {
|
||||||
$plain = Crypt::decryptString($outbox->encrypted_license_key);
|
$plain = Crypt::decryptString($outbox->encrypted_license_key);
|
||||||
$recipient = strtolower(trim((string) $outbox->recipient_email));
|
$recipient = strtolower(trim((string) $outbox->recipient_email));
|
||||||
if ($recipient === '') continue;
|
if ($recipient === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
Mail::raw(
|
Mail::raw(
|
||||||
"Thank you for choosing Rsync Studio.\n\nYour licence key is:\n\n{$plain}\n\nOpen Rsync Studio → Settings → Licence and enter this key to activate your Mac.\n\nKeep this key private. You can manage activations from the Rsync Studio licence portal.",
|
"Thank you for choosing Rsync Studio.\n\nYour licence key is:\n\n{$plain}\n\nOpen Rsync Studio → Settings → Licence and enter this key to activate your Mac.\n\nKeep this key private. You can manage activations from the Rsync Studio licence portal.",
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Services\Licensing;
|
namespace App\Services\Licensing;
|
||||||
|
|
||||||
use App\Models\License;
|
use App\Models\License;
|
||||||
use App\Models\LicenseProduct;
|
|
||||||
use App\Models\LicenseDeliveryOutbox;
|
use App\Models\LicenseDeliveryOutbox;
|
||||||
use Illuminate\Support\Facades\DB;
|
use App\Models\LicenseProduct;
|
||||||
use Illuminate\Support\Facades\Crypt;
|
use Illuminate\Support\Facades\Crypt;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
final class LicenseIssuer
|
final class LicenseIssuer
|
||||||
{
|
{
|
||||||
@@ -14,7 +15,9 @@ final class LicenseIssuer
|
|||||||
return DB::transaction(function () use ($product, $name, $email, $provider, $orderID, $customerID, $queueDelivery) {
|
return DB::transaction(function () use ($product, $name, $email, $provider, $orderID, $customerID, $queueDelivery) {
|
||||||
if ($provider && $orderID) {
|
if ($provider && $orderID) {
|
||||||
$existing = License::where('payment_provider', $provider)->where('external_order_id', $orderID)->first();
|
$existing = License::where('payment_provider', $provider)->where('external_order_id', $orderID)->first();
|
||||||
if ($existing) return [$existing, null];
|
if ($existing) {
|
||||||
|
return [$existing, null];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
$plain = LicenseKey::generate();
|
$plain = LicenseKey::generate();
|
||||||
$license = License::create([
|
$license = License::create([
|
||||||
@@ -36,6 +39,7 @@ final class LicenseIssuer
|
|||||||
['encrypted_license_key' => Crypt::encryptString($plain), 'recipient_email' => $license->customer_email, 'delivered_at' => null]
|
['encrypted_license_key' => Crypt::encryptString($plain), 'recipient_email' => $license->customer_email, 'delivered_at' => null]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return [$license, $plain];
|
return [$license, $plain];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Services\Licensing;
|
namespace App\Services\Licensing;
|
||||||
|
|
||||||
final class LicenseKey
|
final class LicenseKey
|
||||||
@@ -7,7 +8,10 @@ final class LicenseKey
|
|||||||
{
|
{
|
||||||
$alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
$alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||||
$raw = '';
|
$raw = '';
|
||||||
for ($i=0; $i<20; $i++) $raw .= $alphabet[random_int(0, strlen($alphabet)-1)];
|
for ($i = 0; $i < 20; $i++) {
|
||||||
|
$raw .= $alphabet[random_int(0, strlen($alphabet) - 1)];
|
||||||
|
}
|
||||||
|
|
||||||
return implode('-', str_split($raw, 5));
|
return implode('-', str_split($raw, 5));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Services\Licensing;
|
namespace App\Services\Licensing;
|
||||||
|
|
||||||
use App\Models\License;
|
use App\Models\License;
|
||||||
@@ -26,21 +27,28 @@ final class LicenseSigner
|
|||||||
ksort($payload, SORT_STRING);
|
ksort($payload, SORT_STRING);
|
||||||
$json = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
|
$json = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
|
||||||
$sig = sodium_crypto_sign_detached($json, $secret);
|
$sig = sodium_crypto_sign_detached($json, $secret);
|
||||||
|
|
||||||
return 'RS3.'.self::b64url($json).'.'.self::b64url($sig);
|
return 'RS3.'.self::b64url($json).'.'.self::b64url($sig);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function publicKeyBase64(): string
|
public function publicKeyBase64(): string
|
||||||
{
|
{
|
||||||
$pair = sodium_crypto_sign_seed_keypair($this->privateSeed());
|
$pair = sodium_crypto_sign_seed_keypair($this->privateSeed());
|
||||||
|
|
||||||
return base64_encode(sodium_crypto_sign_publickey($pair));
|
return base64_encode(sodium_crypto_sign_publickey($pair));
|
||||||
}
|
}
|
||||||
|
|
||||||
private function privateSeed(): string
|
private function privateSeed(): string
|
||||||
{
|
{
|
||||||
$path = config('rsync-studio.private_key_path');
|
$path = config('rsync-studio.private_key_path');
|
||||||
if (!$path || !is_file($path)) throw new RuntimeException('RSYNC_STUDIO_LICENSE_PRIVATE_KEY_PATH is not configured or does not exist.');
|
if (! $path || ! is_file($path)) {
|
||||||
|
throw new RuntimeException('RSYNC_STUDIO_LICENSE_PRIVATE_KEY_PATH is not configured or does not exist.');
|
||||||
|
}
|
||||||
$decoded = base64_decode(trim((string) file_get_contents($path)), true);
|
$decoded = base64_decode(trim((string) file_get_contents($path)), true);
|
||||||
if ($decoded === false || strlen($decoded) !== SODIUM_CRYPTO_SIGN_SEEDBYTES) throw new RuntimeException('The Rsync Studio private key must be a Base64-encoded 32-byte Ed25519 seed.');
|
if ($decoded === false || strlen($decoded) !== SODIUM_CRYPTO_SIGN_SEEDBYTES) {
|
||||||
|
throw new RuntimeException('The Rsync Studio private key must be a Base64-encoded 32-byte Ed25519 seed.');
|
||||||
|
}
|
||||||
|
|
||||||
return $decoded;
|
return $decoded;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Services\Licensing;
|
namespace App\Services\Licensing;
|
||||||
|
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
@@ -8,21 +9,32 @@ final class LicenseTokenVerifier
|
|||||||
public function verify(string $token): array
|
public function verify(string $token): array
|
||||||
{
|
{
|
||||||
$parts = explode('.', trim($token));
|
$parts = explode('.', trim($token));
|
||||||
if (count($parts) !== 3 || $parts[0] !== 'RS3') throw new RuntimeException('Invalid licence token.');
|
if (count($parts) !== 3 || $parts[0] !== 'RS3') {
|
||||||
|
throw new RuntimeException('Invalid licence token.');
|
||||||
|
}
|
||||||
$payload = $this->decode($parts[1]);
|
$payload = $this->decode($parts[1]);
|
||||||
$signature = $this->decode($parts[2]);
|
$signature = $this->decode($parts[2]);
|
||||||
$public = base64_decode((string) config('rsync-studio.public_key'), true);
|
$public = base64_decode((string) config('rsync-studio.public_key'), true);
|
||||||
if ($public === false || strlen($public) !== SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES) throw new RuntimeException('Server licence public key is invalid.');
|
if ($public === false || strlen($public) !== SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES) {
|
||||||
if (!sodium_crypto_sign_verify_detached($signature, $payload, $public)) throw new RuntimeException('Licence token signature is invalid.');
|
throw new RuntimeException('Server licence public key is invalid.');
|
||||||
|
}
|
||||||
|
if (! sodium_crypto_sign_verify_detached($signature, $payload, $public)) {
|
||||||
|
throw new RuntimeException('Licence token signature is invalid.');
|
||||||
|
}
|
||||||
$decoded = json_decode($payload, true, flags: JSON_THROW_ON_ERROR);
|
$decoded = json_decode($payload, true, flags: JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
return is_array($decoded) ? $decoded : throw new RuntimeException('Licence token payload is invalid.');
|
return is_array($decoded) ? $decoded : throw new RuntimeException('Licence token payload is invalid.');
|
||||||
}
|
}
|
||||||
|
|
||||||
private function decode(string $value): string
|
private function decode(string $value): string
|
||||||
{
|
{
|
||||||
$value = strtr($value, '-_', '+/');
|
$value = strtr($value, '-_', '+/');
|
||||||
$value .= str_repeat('=', (4 - strlen($value) % 4) % 4);
|
$value .= str_repeat('=', (4 - strlen($value) % 4) % 4);
|
||||||
$out = base64_decode($value, true);
|
$out = base64_decode($value, true);
|
||||||
if ($out === false) throw new RuntimeException('Invalid Base64URL value.');
|
if ($out === false) {
|
||||||
|
throw new RuntimeException('Invalid Base64URL value.');
|
||||||
|
}
|
||||||
|
|
||||||
return $out;
|
return $out;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Services\Payments;
|
namespace App\Services\Payments;
|
||||||
|
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
@@ -10,8 +11,12 @@ final class LemonSqueezyWebhookVerifier implements WebhookVerifier
|
|||||||
{
|
{
|
||||||
$secret = (string) config('rsync-studio.lemon_squeezy.webhook_secret');
|
$secret = (string) config('rsync-studio.lemon_squeezy.webhook_secret');
|
||||||
$signature = (string) $request->header('X-Signature');
|
$signature = (string) $request->header('X-Signature');
|
||||||
if ($secret === '' || $signature === '') throw new RuntimeException('Lemon Squeezy webhook verification is not configured.');
|
if ($secret === '' || $signature === '') {
|
||||||
|
throw new RuntimeException('Lemon Squeezy webhook verification is not configured.');
|
||||||
|
}
|
||||||
$expected = hash_hmac('sha256', $rawBody, $secret);
|
$expected = hash_hmac('sha256', $rawBody, $secret);
|
||||||
if (!hash_equals($expected,$signature)) throw new RuntimeException('Lemon Squeezy webhook signature is invalid.');
|
if (! hash_equals($expected, $signature)) {
|
||||||
|
throw new RuntimeException('Lemon Squeezy webhook signature is invalid.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,12 +10,17 @@ final class PaddleClient
|
|||||||
{
|
{
|
||||||
public function customer(?string $customerID): ?array
|
public function customer(?string $customerID): ?array
|
||||||
{
|
{
|
||||||
if (!$customerID || !config('rsync-studio.paddle.api_key')) return null;
|
if (! $customerID || ! config('rsync-studio.paddle.api_key')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
$response = $this->request()->get('/customers/'.$customerID);
|
$response = $this->request()->get('/customers/'.$customerID);
|
||||||
|
|
||||||
if (!$response->successful()) return null;
|
if (! $response->successful()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
$data = $response->json('data');
|
$data = $response->json('data');
|
||||||
|
|
||||||
return is_array($data) ? $data : null;
|
return is_array($data) ? $data : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Services\Payments;
|
namespace App\Services\Payments;
|
||||||
|
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
@@ -10,15 +11,28 @@ final class PaddleWebhookVerifier implements WebhookVerifier
|
|||||||
{
|
{
|
||||||
$secret = (string) config('rsync-studio.paddle.webhook_secret');
|
$secret = (string) config('rsync-studio.paddle.webhook_secret');
|
||||||
$header = (string) $request->header('Paddle-Signature');
|
$header = (string) $request->header('Paddle-Signature');
|
||||||
if ($secret === '' || $header === '') throw new RuntimeException('Paddle webhook verification is not configured.');
|
if ($secret === '' || $header === '') {
|
||||||
|
throw new RuntimeException('Paddle webhook verification is not configured.');
|
||||||
|
}
|
||||||
$parts = [];
|
$parts = [];
|
||||||
foreach (explode(';', $header) as $part) { [$k,$v] = array_pad(explode('=',trim($part),2),2,null); if ($k && $v) $parts[$k][]=$v; }
|
foreach (explode(';', $header) as $part) {
|
||||||
|
[$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;
|
$ts = isset($parts['ts'][0]) ? (int) $parts['ts'][0] : 0;
|
||||||
$signatures = $parts['h1'] ?? [];
|
$signatures = $parts['h1'] ?? [];
|
||||||
$tolerance = max(1, (int) config('rsync-studio.paddle.timestamp_tolerance', 300));
|
$tolerance = max(1, (int) config('rsync-studio.paddle.timestamp_tolerance', 300));
|
||||||
if (!$ts || abs(time()-$ts)>$tolerance) throw new RuntimeException('Paddle webhook timestamp is outside the allowed tolerance.');
|
if (! $ts || abs(time() - $ts) > $tolerance) {
|
||||||
|
throw new RuntimeException('Paddle webhook timestamp is outside the allowed tolerance.');
|
||||||
|
}
|
||||||
$expected = hash_hmac('sha256', $ts.':'.$rawBody, $secret);
|
$expected = hash_hmac('sha256', $ts.':'.$rawBody, $secret);
|
||||||
foreach ($signatures as $signature) if (hash_equals($expected,$signature)) return;
|
foreach ($signatures as $signature) {
|
||||||
|
if (hash_equals($expected, $signature)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
throw new RuntimeException('Paddle webhook signature is invalid.');
|
throw new RuntimeException('Paddle webhook signature is invalid.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,11 +16,13 @@ final class PurchaseProvisioner
|
|||||||
{
|
{
|
||||||
if ($provider === 'paddle') {
|
if ($provider === 'paddle') {
|
||||||
$this->provisionPaddle($event);
|
$this->provisionPaddle($event);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($provider === 'lemon-squeezy') {
|
if ($provider === 'lemon-squeezy') {
|
||||||
$this->provisionLemonSqueezy($event);
|
$this->provisionLemonSqueezy($event);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,11 +36,13 @@ final class PurchaseProvisioner
|
|||||||
|
|
||||||
if (str_starts_with($type, 'subscription.')) {
|
if (str_starts_with($type, 'subscription.')) {
|
||||||
$this->syncPaddleSubscription($type, $data);
|
$this->syncPaddleSubscription($type, $data);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($type === 'transaction.completed') {
|
if ($type === 'transaction.completed') {
|
||||||
$this->syncPaddleTransaction($data);
|
$this->syncPaddleTransaction($data);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,6 +140,7 @@ final class PurchaseProvisioner
|
|||||||
->where('external_subscription_id', $subscriptionID)
|
->where('external_subscription_id', $subscriptionID)
|
||||||
->whereNull('external_order_id')
|
->whereNull('external_order_id')
|
||||||
->update(['external_order_id' => $transactionID ?: null]);
|
->update(['external_order_id' => $transactionID ?: null]);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,6 +175,7 @@ final class PurchaseProvisioner
|
|||||||
->where('external_order_id', $order)
|
->where('external_order_id', $order)
|
||||||
->whereNull('revoked_at')
|
->whereNull('revoked_at')
|
||||||
->update(['revoked_at' => now(), 'revoke_reason' => 'Payment refunded or subscription expired']);
|
->update(['revoked_at' => now(), 'revoke_reason' => 'Payment refunded or subscription expired']);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,13 +230,17 @@ final class PurchaseProvisioner
|
|||||||
return $item;
|
return $item;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $items[0] ?? [];
|
return $items[0] ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function nullableString(mixed $value): ?string
|
private function nullableString(mixed $value): ?string
|
||||||
{
|
{
|
||||||
if (!is_scalar($value)) return null;
|
if (! is_scalar($value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
$value = trim((string) $value);
|
$value = trim((string) $value);
|
||||||
|
|
||||||
return $value === '' ? null : $value;
|
return $value === '' ? null : $value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Services\Payments;
|
namespace App\Services\Payments;
|
||||||
|
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
interface WebhookVerifier { public function verify(Request $request, string $rawBody): void; }
|
|
||||||
|
interface WebhookVerifier
|
||||||
|
{
|
||||||
|
public function verify(Request $request, string $rawBody): void;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Providers\RsyncStudioLicensingServiceProvider;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
App\Providers\RsyncStudioLicensingServiceProvider::class,
|
RsyncStudioLicensingServiceProvider::class,
|
||||||
];
|
];
|
||||||
|
|||||||
+2
-1
@@ -9,7 +9,8 @@
|
|||||||
"php": "^8.3",
|
"php": "^8.3",
|
||||||
"deployer/deployer": "^8.0",
|
"deployer/deployer": "^8.0",
|
||||||
"laravel/framework": "^13.17",
|
"laravel/framework": "^13.17",
|
||||||
"laravel/tinker": "^3.0"
|
"laravel/tinker": "^3.0",
|
||||||
|
"predis/predis": "^3.6"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"fakerphp/faker": "^1.23",
|
"fakerphp/faker": "^1.23",
|
||||||
|
|||||||
Generated
+64
-1
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "03c9ba92496a9b294eb87b214b3c0df4",
|
"content-hash": "1f5b5a47e5910fdf5b2c47e1bc45e1b0",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "brick/math",
|
"name": "brick/math",
|
||||||
@@ -2738,6 +2738,69 @@
|
|||||||
],
|
],
|
||||||
"time": "2026-08-24T00:54:40+00:00"
|
"time": "2026-08-24T00:54:40+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "predis/predis",
|
||||||
|
"version": "v3.6.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/predis/predis.git",
|
||||||
|
"reference": "2ff20c08bb63697245ffee3f198d1673086c302d"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/predis/predis/zipball/2ff20c08bb63697245ffee3f198d1673086c302d",
|
||||||
|
"reference": "2ff20c08bb63697245ffee3f198d1673086c302d",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": "^7.2 || ^8.0",
|
||||||
|
"psr/http-message": "^1.0|^2.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"friendsofphp/php-cs-fixer": "^3.3",
|
||||||
|
"phpstan/phpstan": "^1.9",
|
||||||
|
"phpunit/phpcov": "^6.0 || ^8.0",
|
||||||
|
"phpunit/phpunit": "^8.0 || ~9.4.4"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"ext-relay": "Faster connection with in-memory caching (>=0.6.2)"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Predis\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Till Krüss",
|
||||||
|
"homepage": "https://till.im",
|
||||||
|
"role": "Maintainer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "A flexible and feature-complete Redis/Valkey client for PHP.",
|
||||||
|
"homepage": "http://github.com/predis/predis",
|
||||||
|
"keywords": [
|
||||||
|
"nosql",
|
||||||
|
"predis",
|
||||||
|
"redis"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/predis/predis/issues",
|
||||||
|
"source": "https://github.com/predis/predis/tree/v3.6.0"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://github.com/sponsors/tillkruss",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2026-08-14T23:07:56+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "psr/clock",
|
"name": "psr/clock",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"require": {
|
|
||||||
"php": "^8.3",
|
|
||||||
"ext-sodium": "*",
|
|
||||||
"laravel/framework": "^13.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Binary file not shown.
@@ -4,7 +4,8 @@ use Illuminate\Database\Migrations\Migration;
|
|||||||
use Illuminate\Database\Schema\Blueprint;
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
use Illuminate\Support\Facades\Schema;
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
return new class extends Migration {
|
return new class extends Migration
|
||||||
|
{
|
||||||
public function up(): void
|
public function up(): void
|
||||||
{
|
{
|
||||||
Schema::create('license_products', function (Blueprint $table) {
|
Schema::create('license_products', function (Blueprint $table) {
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ class PaddleCatalogSeeder extends Seeder
|
|||||||
];
|
];
|
||||||
|
|
||||||
foreach ($products as $data) {
|
foreach ($products as $data) {
|
||||||
if (!$data['external_product_id'] || !$data['external_price_id']) continue;
|
if (! $data['external_product_id'] || ! $data['external_price_id']) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
LicenseProduct::updateOrCreate(
|
LicenseProduct::updateOrCreate(
|
||||||
['code' => $data['code']],
|
['code' => $data['code']],
|
||||||
$data + ['payment_provider' => 'paddle', 'max_major_version' => config('rsync-studio.default_max_major_version'), 'active' => true]
|
$data + ['payment_provider' => 'paddle', 'max_major_version' => config('rsync-studio.default_max_major_version'), 'active' => true]
|
||||||
|
|||||||
+10
-1
@@ -1,3 +1,12 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
require __DIR__.'/api.stage4.php';
|
use App\Http\Controllers\Api\LicenseActivationController;
|
||||||
|
use App\Http\Controllers\Api\PaymentWebhookController;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
|
Route::prefix('v1')->group(function () {
|
||||||
|
Route::post('/activate', [LicenseActivationController::class, 'activate'])->middleware('throttle:license-activation');
|
||||||
|
Route::post('/validate', [LicenseActivationController::class, 'validateInstallation'])->middleware('throttle:license-activation');
|
||||||
|
Route::post('/deactivate', [LicenseActivationController::class, 'deactivate'])->middleware('throttle:license-activation');
|
||||||
|
Route::post('/webhooks/{provider}', PaymentWebhookController::class)->whereIn('provider', ['paddle', 'lemon-squeezy']);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
use App\Http\Controllers\Api\LicenseActivationController;
|
|
||||||
use App\Http\Controllers\Api\PaymentWebhookController;
|
|
||||||
use Illuminate\Support\Facades\Route;
|
|
||||||
|
|
||||||
Route::prefix('v1')->group(function () {
|
|
||||||
Route::post('/activate', [LicenseActivationController::class, 'activate'])->middleware('throttle:license-activation');
|
|
||||||
Route::post('/validate', [LicenseActivationController::class, 'validateInstallation'])->middleware('throttle:license-activation');
|
|
||||||
Route::post('/deactivate', [LicenseActivationController::class, 'deactivate'])->middleware('throttle:license-activation');
|
|
||||||
Route::post('/webhooks/{provider}', PaymentWebhookController::class)->whereIn('provider', ['paddle','lemon-squeezy']);
|
|
||||||
});
|
|
||||||
+53
-1
@@ -1,3 +1,55 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
require __DIR__.'/web.stage5.php';
|
use App\Http\Controllers\Admin\AdminController;
|
||||||
|
use App\Http\Controllers\Admin\SupportTicketController;
|
||||||
|
use App\Http\Controllers\Auth\AuthenticatedSessionController;
|
||||||
|
use App\Http\Controllers\Auth\EmailVerificationController;
|
||||||
|
use App\Http\Controllers\Auth\PasswordController;
|
||||||
|
use App\Http\Controllers\Auth\RegisteredUserController;
|
||||||
|
use App\Http\Controllers\CheckoutController;
|
||||||
|
use App\Http\Controllers\Portal\PortalController;
|
||||||
|
use App\Http\Controllers\Support\SupportController;
|
||||||
|
use App\Http\Middleware\AdminOnly;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
|
Route::get('/', fn () => view('portal.welcome'))->name('home');
|
||||||
|
Route::get('/buy', CheckoutController::class)->name('buy');
|
||||||
|
Route::get('/support', [SupportController::class, 'create'])->name('support');
|
||||||
|
Route::post('/support', [SupportController::class, 'store'])->middleware('throttle:5,10')->name('support.store');
|
||||||
|
Route::view('/privacy', 'legal.privacy')->name('privacy');
|
||||||
|
Route::view('/terms', 'legal.terms')->name('terms');
|
||||||
|
Route::view('/cookies', 'legal.cookies')->name('cookies');
|
||||||
|
Route::middleware('guest')->group(function () {
|
||||||
|
Route::get('/login', [AuthenticatedSessionController::class, 'create'])->name('login');
|
||||||
|
Route::post('/login', [AuthenticatedSessionController::class, 'store'])->middleware('throttle:login');
|
||||||
|
Route::get('/register', [RegisteredUserController::class, 'create'])->name('register');
|
||||||
|
Route::post('/register', [RegisteredUserController::class, 'store'])->middleware('throttle:registration');
|
||||||
|
Route::get('/forgot-password', [PasswordController::class, 'requestForm'])->name('password.request');
|
||||||
|
Route::post('/forgot-password', [PasswordController::class, 'email'])->name('password.email')->middleware('throttle:5,1');
|
||||||
|
Route::get('/reset-password/{token}', [PasswordController::class, 'resetForm'])->name('password.reset');
|
||||||
|
Route::post('/reset-password', [PasswordController::class, 'reset'])->name('password.update');
|
||||||
|
});
|
||||||
|
Route::middleware('auth')->group(function () {
|
||||||
|
Route::post('/logout', [AuthenticatedSessionController::class, 'destroy'])->name('logout');
|
||||||
|
Route::get('/verify-email', [EmailVerificationController::class, 'notice'])->name('verification.notice');
|
||||||
|
Route::get('/verify-email/{id}/{hash}', [EmailVerificationController::class, 'verify'])->middleware(['signed', 'throttle:6,1'])->name('verification.verify');
|
||||||
|
Route::post('/email/verification-notification', [EmailVerificationController::class, 'resend'])->middleware('throttle:6,1')->name('verification.send');
|
||||||
|
});
|
||||||
|
Route::middleware(['auth', 'verified'])->prefix('portal')->name('portal.')->group(function () {
|
||||||
|
Route::get('/', [PortalController::class, 'index'])->name('index');
|
||||||
|
Route::get('/licences/{license}', [PortalController::class, 'show'])->name('show');
|
||||||
|
Route::post('/licences/{license}/upgrade', [PortalController::class, 'upgrade'])->middleware('throttle:5,1')->name('upgrade');
|
||||||
|
Route::delete('/licences/{license}/subscription', [PortalController::class, 'cancel'])->middleware('throttle:5,1')->name('cancel');
|
||||||
|
Route::post('/licences/{license}/resend', [PortalController::class, 'resend'])->middleware('throttle:3,10')->name('resend');
|
||||||
|
Route::delete('/licences/{license}/activations/{activation}', [PortalController::class, 'deactivate'])->name('deactivate');
|
||||||
|
});
|
||||||
|
Route::middleware(['auth', 'verified', AdminOnly::class])->prefix('admin')->name('admin.')->group(function () {
|
||||||
|
Route::get('/', [AdminController::class, 'index'])->name('index');
|
||||||
|
Route::get('/licences', [AdminController::class, 'licenses'])->name('licenses');
|
||||||
|
Route::get('/licences/{license}', [AdminController::class, 'show'])->name('license');
|
||||||
|
Route::post('/licences/{license}/revoke', [AdminController::class, 'revoke'])->name('revoke');
|
||||||
|
Route::post('/licences/{license}/restore', [AdminController::class, 'restore'])->name('restore');
|
||||||
|
Route::get('/support', [SupportTicketController::class, 'index'])->name('support.index');
|
||||||
|
Route::get('/support/{ticket}', [SupportTicketController::class, 'show'])->name('support.show');
|
||||||
|
Route::patch('/support/{ticket}',[SupportTicketController::class, 'update'])->name('support.update');
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
<?php
|
|
||||||
use App\Http\Controllers\Admin\AdminController;
|
|
||||||
use App\Http\Controllers\Admin\SupportTicketController;
|
|
||||||
use App\Http\Controllers\Support\SupportController;
|
|
||||||
use App\Http\Controllers\Auth\AuthenticatedSessionController;
|
|
||||||
use App\Http\Controllers\Auth\EmailVerificationController;
|
|
||||||
use App\Http\Controllers\Auth\PasswordController;
|
|
||||||
use App\Http\Controllers\Auth\RegisteredUserController;
|
|
||||||
use App\Http\Controllers\CheckoutController;
|
|
||||||
use App\Http\Controllers\Portal\PortalController;
|
|
||||||
use App\Http\Middleware\AdminOnly;
|
|
||||||
use Illuminate\Support\Facades\Route;
|
|
||||||
Route::get('/', fn()=>view('portal.welcome'))->name('home');
|
|
||||||
Route::get('/buy', CheckoutController::class)->name('buy');
|
|
||||||
Route::get('/support', [SupportController::class, 'create'])->name('support');
|
|
||||||
Route::post('/support', [SupportController::class, 'store'])->middleware('throttle:5,10')->name('support.store');
|
|
||||||
Route::view('/privacy', 'legal.privacy')->name('privacy');
|
|
||||||
Route::view('/terms', 'legal.terms')->name('terms');
|
|
||||||
Route::view('/cookies', 'legal.cookies')->name('cookies');
|
|
||||||
Route::middleware('guest')->group(function(){Route::get('/login',[AuthenticatedSessionController::class,'create'])->name('login');Route::post('/login',[AuthenticatedSessionController::class,'store'])->middleware('throttle:login');Route::get('/register',[RegisteredUserController::class,'create'])->name('register');Route::post('/register',[RegisteredUserController::class,'store'])->middleware('throttle:registration');Route::get('/forgot-password',[PasswordController::class,'requestForm'])->name('password.request');Route::post('/forgot-password',[PasswordController::class,'email'])->name('password.email')->middleware('throttle:5,1');Route::get('/reset-password/{token}',[PasswordController::class,'resetForm'])->name('password.reset');Route::post('/reset-password',[PasswordController::class,'reset'])->name('password.update');});
|
|
||||||
Route::middleware('auth')->group(function(){Route::post('/logout',[AuthenticatedSessionController::class,'destroy'])->name('logout');Route::get('/verify-email',[EmailVerificationController::class,'notice'])->name('verification.notice');Route::get('/verify-email/{id}/{hash}',[EmailVerificationController::class,'verify'])->middleware(['signed','throttle:6,1'])->name('verification.verify');Route::post('/email/verification-notification',[EmailVerificationController::class,'resend'])->middleware('throttle:6,1')->name('verification.send');});
|
|
||||||
Route::middleware(['auth','verified'])->prefix('portal')->name('portal.')->group(function(){Route::get('/',[PortalController::class,'index'])->name('index');Route::get('/licences/{license}',[PortalController::class,'show'])->name('show');Route::post('/licences/{license}/upgrade',[PortalController::class,'upgrade'])->middleware('throttle:5,1')->name('upgrade');Route::delete('/licences/{license}/subscription',[PortalController::class,'cancel'])->middleware('throttle:5,1')->name('cancel');Route::post('/licences/{license}/resend',[PortalController::class,'resend'])->middleware('throttle:3,10')->name('resend');Route::delete('/licences/{license}/activations/{activation}',[PortalController::class,'deactivate'])->name('deactivate');});
|
|
||||||
Route::middleware(['auth','verified',AdminOnly::class])->prefix('admin')->name('admin.')->group(function(){Route::get('/',[AdminController::class,'index'])->name('index');Route::get('/licences',[AdminController::class,'licenses'])->name('licenses');Route::get('/licences/{license}',[AdminController::class,'show'])->name('license');Route::post('/licences/{license}/revoke',[AdminController::class,'revoke'])->name('revoke');Route::post('/licences/{license}/restore',[AdminController::class,'restore'])->name('restore');Route::get('/support',[SupportTicketController::class,'index'])->name('support.index');Route::get('/support/{ticket}',[SupportTicketController::class,'show'])->name('support.show');Route::patch('/support/{ticket}',[SupportTicketController::class,'update'])->name('support.update');});
|
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Tests\Feature;
|
namespace Tests\Feature;
|
||||||
|
|
||||||
use App\Models\LicenseProduct;
|
|
||||||
use App\Models\LicenseActivation;
|
use App\Models\LicenseActivation;
|
||||||
|
use App\Models\LicenseProduct;
|
||||||
use App\Services\Licensing\LicenseIssuer;
|
use App\Services\Licensing\LicenseIssuer;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
@@ -10,6 +11,7 @@ use Tests\TestCase;
|
|||||||
class LicenseActivationTest extends TestCase
|
class LicenseActivationTest extends TestCase
|
||||||
{
|
{
|
||||||
use RefreshDatabase;
|
use RefreshDatabase;
|
||||||
|
|
||||||
public function test_activation_is_idempotent_and_enforces_machine_limit(): void
|
public function test_activation_is_idempotent_and_enforces_machine_limit(): void
|
||||||
{
|
{
|
||||||
$product = LicenseProduct::create(['code' => 'test', 'name' => 'Test', 'edition' => 'Professional', 'activation_limit' => 1, 'max_major_version' => 1, 'active' => true]);
|
$product = LicenseProduct::create(['code' => 'test', 'name' => 'Test', 'edition' => 'Professional', 'activation_limit' => 1, 'max_major_version' => 1, 'active' => true]);
|
||||||
@@ -19,6 +21,7 @@ class LicenseActivationTest extends TestCase
|
|||||||
$this->postJson('/api/v1/activate', $one)->assertOk();
|
$this->postJson('/api/v1/activate', $one)->assertOk();
|
||||||
$this->postJson('/api/v1/activate', array_merge($one, ['installationID' => '22222222-2222-4222-8222-222222222222']))->assertStatus(409);
|
$this->postJson('/api/v1/activate', array_merge($one, ['installationID' => '22222222-2222-4222-8222-222222222222']))->assertStatus(409);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_validation_refreshes_an_active_installation_without_calling_paddle(): void
|
public function test_validation_refreshes_an_active_installation_without_calling_paddle(): void
|
||||||
{
|
{
|
||||||
$product = LicenseProduct::create([
|
$product = LicenseProduct::create([
|
||||||
@@ -79,5 +82,4 @@ class LicenseActivationTest extends TestCase
|
|||||||
'message' => 'This Mac has been deactivated from the licence portal.',
|
'message' => 'This Mac has been deactivated from the licence portal.',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user