New updates

This commit is contained in:
Mark Gregory
2026-09-09 08:23:43 +01:00
parent 868604b848
commit 5b9c19c52f
107 changed files with 11126 additions and 8 deletions
@@ -0,0 +1,42 @@
<?php
namespace App\Services\Licensing;
use App\Models\License;
use App\Models\LicenseProduct;
use App\Models\LicenseDeliveryOutbox;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Crypt;
final class LicenseIssuer
{
public function issue(LicenseProduct $product, ?string $name, ?string $email, ?string $provider = null, ?string $orderID = null, ?string $customerID = null, bool $queueDelivery = false): array
{
return DB::transaction(function () use ($product,$name,$email,$provider,$orderID,$customerID,$queueDelivery) {
if ($provider && $orderID) {
$existing = License::where('payment_provider',$provider)->where('external_order_id',$orderID)->first();
if ($existing) return [$existing, null];
}
$plain = LicenseKey::generate();
$license = License::create([
'license_product_id'=>$product->id,
'license_key_hash'=>LicenseKey::hash($plain),
'license_key_last4'=>substr(LicenseKey::normalize($plain), -4),
'customer_name'=>$name,
'customer_email'=>$email ? strtolower(trim($email)) : null,
'edition'=>$product->edition,
'activation_limit'=>$product->activation_limit,
'max_major_version'=>$product->max_major_version,
'payment_provider'=>$provider,
'external_order_id'=>$orderID,
'external_customer_id'=>$customerID,
]);
if ($queueDelivery) {
LicenseDeliveryOutbox::updateOrCreate(
['license_id'=>$license->id],
['encrypted_license_key'=>Crypt::encryptString($plain),'recipient_email'=>$license->customer_email,'delivered_at'=>null]
);
}
return [$license, $plain];
});
}
}
@@ -0,0 +1,23 @@
<?php
namespace App\Services\Licensing;
final class LicenseKey
{
public static function generate(): string
{
$alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
$raw = '';
for ($i=0; $i<20; $i++) $raw .= $alphabet[random_int(0, strlen($alphabet)-1)];
return implode('-', str_split($raw, 5));
}
public static function normalize(string $value): string
{
return strtoupper(preg_replace('/[^A-Z0-9]/i', '', trim($value)) ?? '');
}
public static function hash(string $value): string
{
return hash('sha256', self::normalize($value));
}
}
@@ -0,0 +1,51 @@
<?php
namespace App\Services\Licensing;
use App\Models\License;
use Carbon\CarbonInterface;
use RuntimeException;
final class LicenseSigner
{
public function sign(License $license, string $installationID, ?CarbonInterface $issuedAt = null): string
{
$seed = $this->privateSeed();
$pair = sodium_crypto_sign_seed_keypair($seed);
$secret = sodium_crypto_sign_secretkey($pair);
$payload = [
'customerEmail' => $license->customer_email,
'customerName' => $license->customer_name,
'edition' => $license->edition,
'expiresAt' => $license->expires_at?->utc()->format('Y-m-d\\TH:i:s\\Z'),
'installationID' => strtolower($installationID),
'issuedAt' => ($issuedAt ?? now())->utc()->format('Y-m-d\\TH:i:s\\Z'),
'licenseID' => strtolower((string) $license->id),
'maxMajorVersion' => $license->max_major_version,
];
ksort($payload, SORT_STRING);
$json = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
$sig = sodium_crypto_sign_detached($json, $secret);
return 'RS3.'.self::b64url($json).'.'.self::b64url($sig);
}
public function publicKeyBase64(): string
{
$pair = sodium_crypto_sign_seed_keypair($this->privateSeed());
return base64_encode(sodium_crypto_sign_publickey($pair));
}
private function privateSeed(): string
{
$path = config('rsync-studio.private_key_path');
if (!$path || !is_file($path)) throw new RuntimeException('RSYNC_STUDIO_LICENSE_PRIVATE_KEY_PATH is not configured or does not exist.');
$decoded = base64_decode(trim((string) file_get_contents($path)), true);
if ($decoded === false || strlen($decoded) !== SODIUM_CRYPTO_SIGN_SEEDBYTES) throw new RuntimeException('The Rsync Studio private key must be a Base64-encoded 32-byte Ed25519 seed.');
return $decoded;
}
private static function b64url(string $value): string
{
return rtrim(strtr(base64_encode($value), '+/', '-_'), '=');
}
}
@@ -0,0 +1,28 @@
<?php
namespace App\Services\Licensing;
use RuntimeException;
final class LicenseTokenVerifier
{
public function verify(string $token): array
{
$parts = explode('.', trim($token));
if (count($parts) !== 3 || $parts[0] !== 'RS3') throw new RuntimeException('Invalid licence token.');
$payload = $this->decode($parts[1]);
$signature = $this->decode($parts[2]);
$public = base64_decode((string) config('rsync-studio.public_key'), true);
if ($public === false || strlen($public) !== SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES) throw new RuntimeException('Server licence public key is invalid.');
if (!sodium_crypto_sign_verify_detached($signature, $payload, $public)) throw new RuntimeException('Licence token signature is invalid.');
$decoded = json_decode($payload, true, flags: JSON_THROW_ON_ERROR);
return is_array($decoded) ? $decoded : throw new RuntimeException('Licence token payload is invalid.');
}
private function decode(string $value): string
{
$value = strtr($value, '-_', '+/');
$value .= str_repeat('=', (4 - strlen($value) % 4) % 4);
$out = base64_decode($value, true);
if ($out === false) throw new RuntimeException('Invalid Base64URL value.');
return $out;
}
}
@@ -0,0 +1,17 @@
<?php
namespace App\Services\Payments;
use Illuminate\Http\Request;
use RuntimeException;
final class LemonSqueezyWebhookVerifier implements WebhookVerifier
{
public function verify(Request $request, string $rawBody): void
{
$secret = (string) config('rsync-studio.lemon_squeezy.webhook_secret');
$signature = (string) $request->header('X-Signature');
if ($secret === '' || $signature === '') throw new RuntimeException('Lemon Squeezy webhook verification is not configured.');
$expected = hash_hmac('sha256',$rawBody,$secret);
if (!hash_equals($expected,$signature)) throw new RuntimeException('Lemon Squeezy webhook signature is invalid.');
}
}
@@ -0,0 +1,24 @@
<?php
namespace App\Services\Payments;
use Illuminate\Http\Request;
use RuntimeException;
final class PaddleWebhookVerifier implements WebhookVerifier
{
public function verify(Request $request, string $rawBody): void
{
$secret = (string) config('rsync-studio.paddle.webhook_secret');
$header = (string) $request->header('Paddle-Signature');
if ($secret === '' || $header === '') throw new RuntimeException('Paddle webhook verification is not configured.');
$parts = [];
foreach (explode(';', $header) as $part) { [$k,$v] = array_pad(explode('=',trim($part),2),2,null); if ($k && $v) $parts[$k][]=$v; }
$ts = isset($parts['ts'][0]) ? (int)$parts['ts'][0] : 0;
$signatures = $parts['h1'] ?? [];
$tolerance = max(1,(int)config('rsync-studio.paddle.timestamp_tolerance',300));
if (!$ts || abs(time()-$ts)>$tolerance) throw new RuntimeException('Paddle webhook timestamp is outside the allowed tolerance.');
$expected = hash_hmac('sha256', $ts.':'.$rawBody, $secret);
foreach ($signatures as $signature) if (hash_equals($expected,$signature)) return;
throw new RuntimeException('Paddle webhook signature is invalid.');
}
}
@@ -0,0 +1,47 @@
<?php
namespace App\Services\Payments;
use App\Models\License;
use App\Models\LicenseProduct;
use App\Services\Licensing\LicenseIssuer;
use RuntimeException;
final class PurchaseProvisioner
{
public function __construct(private LicenseIssuer $issuer) {}
public function provision(string $provider, array $event): void
{
$normalized = match ($provider) {
'paddle' => $this->fromPaddle($event),
'lemon-squeezy' => $this->fromLemonSqueezy($event),
default => throw new RuntimeException('Unsupported payment provider.'),
};
if ($normalized['action'] === 'ignore') return;
if ($normalized['action'] === 'revoke') {
License::where('payment_provider',$provider)->where('external_order_id',$normalized['orderID'])->whereNull('revoked_at')->update(['revoked_at'=>now(),'revoke_reason'=>'Payment refunded or reversed']);
return;
}
$product = LicenseProduct::where('payment_provider',$provider)->where(function($q) use($normalized){ $q->where('external_price_id',$normalized['priceID'])->orWhere('external_product_id',$normalized['productID']); })->where('active',true)->first();
if (!$product) throw new RuntimeException('No active licence product maps to this payment item.');
$this->issuer->issue($product,$normalized['name'],$normalized['email'],$provider,$normalized['orderID'],$normalized['customerID'], true);
}
private function fromPaddle(array $e): array
{
$type=(string)($e['event_type']??''); $d=$e['data']??[];
if (in_array($type,['transaction.refunded','transaction.canceled'],true)) return ['action'=>'revoke','orderID'=>(string)($d['id']??''),'priceID'=>'','productID'=>'','name'=>null,'email'=>null,'customerID'=>null];
if ($type!=='transaction.completed') return ['action'=>'ignore'];
$item=$d['items'][0]??[]; $price=$item['price']??[]; $product=$price['product_id']??($item['product_id']??null);
return ['action'=>'issue','orderID'=>(string)($d['id']??''),'priceID'=>(string)($price['id']??($item['price_id']??'')),'productID'=>(string)($product??''),'name'=>$d['custom_data']['customer_name']??null,'email'=>$d['custom_data']['customer_email']??null,'customerID'=>isset($d['customer_id'])?(string)$d['customer_id']:null];
}
private function fromLemonSqueezy(array $e): array
{
$type=(string)($e['meta']['event_name']??''); $a=$e['data']['attributes']??[];
$order=(string)($e['data']['id']??($a['order_id']??''));
if (in_array($type,['order_refunded','subscription_expired'],true)) return ['action'=>'revoke','orderID'=>$order,'priceID'=>'','productID'=>'','name'=>null,'email'=>null,'customerID'=>null];
if (!in_array($type,['order_created','subscription_created'],true)) return ['action'=>'ignore'];
return ['action'=>'issue','orderID'=>$order,'priceID'=>(string)($a['variant_id']??''),'productID'=>(string)($a['product_id']??''),'name'=>$a['user_name']??null,'email'=>$a['user_email']??null,'customerID'=>isset($a['customer_id'])?(string)$a['customer_id']:null];
}
}
@@ -0,0 +1,4 @@
<?php
namespace App\Services\Payments;
use Illuminate\Http\Request;
interface WebhookVerifier { public function verify(Request $request, string $rawBody): void; }