cod consolidation and style updates
This commit is contained in:
@@ -22,7 +22,9 @@ final class LicenseDeliveryService
|
||||
foreach ($pending as $outbox) {
|
||||
$plain = Crypt::decryptString($outbox->encrypted_license_key);
|
||||
$recipient = strtolower(trim((string) $outbox->recipient_email));
|
||||
if ($recipient === '') continue;
|
||||
if ($recipient === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
Mail::raw(
|
||||
"Thank you for choosing Rsync Studio.\n\nYour licence key is:\n\n{$plain}\n\nOpen Rsync Studio → Settings → Licence and enter this key to activate your Mac.\n\nKeep this key private. You can manage activations from the Rsync Studio licence portal.",
|
||||
|
||||
@@ -1,41 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Licensing;
|
||||
|
||||
use App\Models\License;
|
||||
use App\Models\LicenseProduct;
|
||||
use App\Models\LicenseDeliveryOutbox;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\Models\LicenseProduct;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
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) {
|
||||
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];
|
||||
$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];
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Licensing;
|
||||
|
||||
final class LicenseKey
|
||||
@@ -7,7 +8,10 @@ final class LicenseKey
|
||||
{
|
||||
$alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||
$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));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Licensing;
|
||||
|
||||
use App\Models\License;
|
||||
@@ -26,21 +27,28 @@ final class LicenseSigner
|
||||
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.');
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Licensing;
|
||||
|
||||
use RuntimeException;
|
||||
@@ -8,21 +9,32 @@ 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.');
|
||||
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.');
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Payments;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
@@ -10,8 +11,12 @@ final class LemonSqueezyWebhookVerifier implements WebhookVerifier
|
||||
{
|
||||
$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.');
|
||||
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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,12 +10,17 @@ final class PaddleClient
|
||||
{
|
||||
public function customer(?string $customerID): ?array
|
||||
{
|
||||
if (!$customerID || !config('rsync-studio.paddle.api_key')) return null;
|
||||
if (! $customerID || ! config('rsync-studio.paddle.api_key')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$response = $this->request()->get('/customers/'.$customerID);
|
||||
|
||||
if (!$response->successful()) return null;
|
||||
if (! $response->successful()) {
|
||||
return null;
|
||||
}
|
||||
$data = $response->json('data');
|
||||
|
||||
return is_array($data) ? $data : null;
|
||||
}
|
||||
|
||||
@@ -62,13 +67,13 @@ final class PaddleClient
|
||||
|
||||
private function subscriptionData(Response $response, string $fallback): array
|
||||
{
|
||||
if (!$response->successful()) {
|
||||
if (! $response->successful()) {
|
||||
$detail = trim((string) ($response->json('error.detail') ?? ''));
|
||||
throw new RuntimeException($detail !== '' ? $detail : $fallback);
|
||||
}
|
||||
|
||||
$data = $response->json('data');
|
||||
if (!is_array($data)) {
|
||||
if (! is_array($data)) {
|
||||
throw new RuntimeException($fallback);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Payments;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
@@ -10,15 +11,28 @@ final class PaddleWebhookVerifier implements WebhookVerifier
|
||||
{
|
||||
$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.');
|
||||
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.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,11 +16,13 @@ final class PurchaseProvisioner
|
||||
{
|
||||
if ($provider === 'paddle') {
|
||||
$this->provisionPaddle($event);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($provider === 'lemon-squeezy') {
|
||||
$this->provisionLemonSqueezy($event);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -34,11 +36,13 @@ final class PurchaseProvisioner
|
||||
|
||||
if (str_starts_with($type, 'subscription.')) {
|
||||
$this->syncPaddleSubscription($type, $data);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($type === 'transaction.completed') {
|
||||
$this->syncPaddleTransaction($data);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -71,7 +75,7 @@ final class PurchaseProvisioner
|
||||
->where('external_subscription_id', $subscriptionID)
|
||||
->first();
|
||||
|
||||
if (!$license) {
|
||||
if (! $license) {
|
||||
$product = $this->mappedProduct('paddle', $priceID, $productID);
|
||||
$custom = is_array($data['custom_data'] ?? null) ? $data['custom_data'] : [];
|
||||
$customer = $this->paddle->customer($this->nullableString($data['customer_id'] ?? null));
|
||||
@@ -136,6 +140,7 @@ final class PurchaseProvisioner
|
||||
->where('external_subscription_id', $subscriptionID)
|
||||
->whereNull('external_order_id')
|
||||
->update(['external_order_id' => $transactionID ?: null]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -170,10 +175,11 @@ final class PurchaseProvisioner
|
||||
->where('external_order_id', $order)
|
||||
->whereNull('revoked_at')
|
||||
->update(['revoked_at' => now(), 'revoke_reason' => 'Payment refunded or subscription expired']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!in_array($type, ['order_created', 'subscription_created'], true)) {
|
||||
if (! in_array($type, ['order_created', 'subscription_created'], true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -210,7 +216,7 @@ final class PurchaseProvisioner
|
||||
->first();
|
||||
}
|
||||
|
||||
if (!$product) {
|
||||
if (! $product) {
|
||||
throw new RuntimeException("No active licence product maps to {$provider} price {$priceID} / product {$productID}.");
|
||||
}
|
||||
|
||||
@@ -224,13 +230,17 @@ final class PurchaseProvisioner
|
||||
return $item;
|
||||
}
|
||||
}
|
||||
|
||||
return $items[0] ?? [];
|
||||
}
|
||||
|
||||
private function nullableString(mixed $value): ?string
|
||||
{
|
||||
if (!is_scalar($value)) return null;
|
||||
if (! is_scalar($value)) {
|
||||
return null;
|
||||
}
|
||||
$value = trim((string) $value);
|
||||
|
||||
return $value === '' ? null : $value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Payments;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
interface WebhookVerifier { public function verify(Request $request, string $rawBody): void; }
|
||||
|
||||
interface WebhookVerifier
|
||||
{
|
||||
public function verify(Request $request, string $rawBody): void;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user