new app store assets ready for relese
This commit is contained in:
@@ -2,9 +2,32 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\AppleVerifier;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class AppleNotificationController extends Controller
|
||||
{
|
||||
//
|
||||
public function __invoke(Request $r, AppleVerifier $verifier)
|
||||
{
|
||||
$v = $r->validate(['signedPayload' => 'required|string|max:60000']);
|
||||
$event = $verifier->envelope($v['signedPayload']);
|
||||
abort_unless(isset($event['notificationUUID'],$event['notificationType']) && ($event['data']['bundleId'] ?? '') === config('tarnax.apple.bundle_id') && ($event['data']['environment'] ?? '') === config('tarnax.apple.environment'), 422);
|
||||
DB::transaction(function () use ($event, $verifier) {
|
||||
if (! DB::table('webhook_events')->insertOrIgnore(['id' => 'apple:'.$event['notificationUUID'], 'type' => $event['notificationType'], 'processed_at' => now()])) {
|
||||
return;
|
||||
}
|
||||
if (empty($event['data']['signedTransactionInfo'])) {
|
||||
return;
|
||||
}
|
||||
$claims = $verifier->transaction($event['data']['signedTransactionInfo']);
|
||||
$status = isset($claims['revocationDate']) ? 'revoked' : ((($claims['expiresDate'] ?? PHP_INT_MAX) < time() * 1000) ? 'expired' : 'verified');
|
||||
DB::table('purchases')->where('provider', 'apple')->where('transaction_id', 'apple:'.$claims['transactionId'])->where(function ($q) use ($claims) {
|
||||
$q->whereNull('provider_updated_at')->orWhere('provider_updated_at', '<', Carbon::createFromTimestampMs($claims['signedDate']));
|
||||
})->update(['status' => $status, 'provider_updated_at' => Carbon::createFromTimestampMs($claims['signedDate']), 'updated_at' => now()]);
|
||||
});
|
||||
|
||||
return response()->json(['received' => true]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,45 +1,98 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\AppleVerifier;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Illuminate\Support\Str;
|
||||
use App\Services\AppleVerifier;
|
||||
class CommerceController extends Controller {
|
||||
public function home() {return view('home',['features'=>DB::table('pages')->where('published',true)->where('slug','features')->first()]);}
|
||||
public function page(string $slug) {return view('page',['page'=>DB::table('pages')->where('slug',$slug)->where('published',true)->firstOrFail()]);}
|
||||
public function releases() {return view('releases',['releases'=>DB::table('releases')->where('published',true)->latest()->paginate(15)]);}
|
||||
public function account(Request $r) {
|
||||
if(!$r->user()->account_token){$r->user()->forceFill(['account_token'=>(string)Str::uuid()])->save();}
|
||||
return view('account',['licenses'=>DB::table('licenses')->where('user_id',$r->user()->id)->get(),'purchases'=>DB::table('purchases')->where('user_id',$r->user()->id)->latest()->get(),'releases'=>DB::table('releases')->where('published',true)->latest()->get()]);
|
||||
}
|
||||
public function downloadLink(Request $r,int $release) {
|
||||
abort_unless(DB::table('licenses')->where('user_id',$r->user()->id)->where('status','active')->exists(),403);
|
||||
abort_unless(DB::table('releases')->where('id',$release)->where('published',true)->exists(),404);
|
||||
return redirect(URL::temporarySignedRoute('download.file',now()->addMinutes(5),['release'=>$release,'user'=>$r->user()->id]));
|
||||
}
|
||||
public function download(Request $r,int $release,int $user) {
|
||||
abort_unless($r->user()->id===$user && DB::table('licenses')->where('user_id',$user)->where('status','active')->exists(),403);
|
||||
$file=DB::table('releases')->where('id',$release)->where('published',true)->firstOrFail();abort_unless(Storage::disk('local')->exists($file->path),404);
|
||||
return Storage::disk('local')->download($file->path,'Tarnax-'.$file->version.'.'.pathinfo($file->path,PATHINFO_EXTENSION));
|
||||
}
|
||||
public function consent(Request $r) {
|
||||
$v=$r->validate(['analytics'=>'required|boolean','marketing'=>'required|boolean']);$visitor=$r->cookie('tarnax_visitor');if(!Str::isUuid($visitor??'')){$visitor=(string)Str::uuid();}
|
||||
DB::table('consents')->insert([...$v,'visitor_id'=>$visitor,'user_id'=>$r->user()?->id,'policy_version'=>config('tarnax.consent_version'),'created_at'=>now(),'updated_at'=>now()]);
|
||||
return response()->json(['saved'=>true])->cookie('tarnax_visitor',$visitor,525600,'/',null,$r->isSecure(),true,false,'lax')->cookie('tarnax_consent',json_encode([...$v,'version'=>config('tarnax.consent_version')]),525600,'/',null,$r->isSecure(),false,false,'lax');
|
||||
}
|
||||
public function export(Request $r) {
|
||||
$data=['profile'=>$r->user()->only('name','email','created_at'),'purchases'=>DB::table('purchases')->where('user_id',$r->user()->id)->get(),'support'=>DB::table('ticket_messages')->where('user_id',$r->user()->id)->get(),'consent'=>DB::table('consents')->where('user_id',$r->user()->id)->get(),'privacy_requests'=>DB::table('privacy_requests')->where('user_id',$r->user()->id)->get()];
|
||||
return response()->streamDownload(fn()=>print(json_encode($data,JSON_PRETTY_PRINT)),'tarnax-personal-data.json',['Content-Type'=>'application/json']);
|
||||
}
|
||||
public function deletion(Request $r) {
|
||||
DB::table('privacy_requests')->updateOrInsert(['user_id'=>$r->user()->id,'type'=>'deletion','status'=>'pending'],['created_at'=>now(),'updated_at'=>now()]);
|
||||
return back()->with('status','Deletion requested. Support will review retained billing records and respond within the applicable deadline.');
|
||||
}
|
||||
public function apple(Request $r,AppleVerifier $verifier) {
|
||||
$v=$r->validate(['signed_transaction'=>'required|string|max:25000']);$claims=$verifier->verify($v['signed_transaction'],$r->user()->account_token??'');
|
||||
DB::transaction(function()use($r,$claims){$id='apple:'.$claims['transactionId'];$purchase=DB::table('purchases')->where('transaction_id',$id)->lockForUpdate()->first();abort_if($purchase && $purchase->user_id!==$r->user()->id,403);if(!$purchase){DB::table('purchases')->insert(['user_id'=>$r->user()->id,'provider'=>'apple','transaction_id'=>$id,'status'=>'verified','created_at'=>now(),'updated_at'=>now()]);}});
|
||||
return back()->with('status','Apple purchase verified. App Store entitlements remain separate from direct licenses.');
|
||||
}
|
||||
|
||||
class CommerceController extends Controller
|
||||
{
|
||||
public function home()
|
||||
{
|
||||
return view('home', ['features' => DB::table('pages')->where('published', true)->where('slug', 'features')->first()]);
|
||||
}
|
||||
|
||||
public function page(string $slug)
|
||||
{
|
||||
return view('page', ['page' => DB::table('pages')->where('slug', $slug)->where('published', true)->firstOrFail()]);
|
||||
}
|
||||
|
||||
public function releases()
|
||||
{
|
||||
return view('releases', ['releases' => DB::table('releases')->where('published', true)->latest()->paginate(15)]);
|
||||
}
|
||||
|
||||
public function account(Request $r)
|
||||
{
|
||||
if (! $r->user()->account_token) {
|
||||
$r->user()->forceFill(['account_token' => (string) Str::uuid()])->save();
|
||||
}
|
||||
|
||||
return view('account', ['licenses' => DB::table('licenses')->where('user_id', $r->user()->id)->get(), 'purchases' => DB::table('purchases')->where('user_id', $r->user()->id)->latest()->get(), 'releases' => DB::table('releases')->where('published', true)->latest()->get()]);
|
||||
}
|
||||
|
||||
public function downloadLink(Request $r, int $release)
|
||||
{
|
||||
abort_unless(DB::table('licenses')->where('user_id', $r->user()->id)->where('status', 'active')->exists(), 403);
|
||||
abort_unless(DB::table('releases')->where('id', $release)->where('published', true)->exists(), 404);
|
||||
|
||||
return redirect(URL::temporarySignedRoute('download.file', now()->addMinutes(5), ['release' => $release, 'user' => $r->user()->id]));
|
||||
}
|
||||
|
||||
public function download(Request $r, int $release, int $user)
|
||||
{
|
||||
abort_unless($r->user()->id === $user && DB::table('licenses')->where('user_id', $user)->where('status', 'active')->exists(), 403);
|
||||
$file = DB::table('releases')->where('id', $release)->where('published', true)->firstOrFail();
|
||||
abort_unless(Storage::disk('local')->exists($file->path), 404);
|
||||
|
||||
return Storage::disk('local')->download($file->path, 'Tarnax-'.$file->version.'.'.pathinfo($file->path, PATHINFO_EXTENSION));
|
||||
}
|
||||
|
||||
public function consent(Request $r)
|
||||
{
|
||||
$v = $r->validate(['analytics' => 'required|boolean', 'marketing' => 'required|boolean']);
|
||||
$visitor = $r->cookie('tarnax_visitor');
|
||||
if (! Str::isUuid($visitor ?? '')) {
|
||||
$visitor = (string) Str::uuid();
|
||||
}
|
||||
DB::table('consents')->insert([...$v, 'visitor_id' => $visitor, 'user_id' => $r->user()?->id, 'policy_version' => config('tarnax.consent_version'), 'created_at' => now(), 'updated_at' => now()]);
|
||||
|
||||
return response()->json(['saved' => true])->cookie('tarnax_visitor', $visitor, 525600, '/', null, $r->isSecure(), true, false, 'lax')->cookie('tarnax_consent', json_encode([...$v, 'version' => config('tarnax.consent_version')]), 525600, '/', null, $r->isSecure(), false, false, 'lax');
|
||||
}
|
||||
|
||||
public function export(Request $r)
|
||||
{
|
||||
$data = ['profile' => $r->user()->only('name', 'email', 'created_at'), 'purchases' => DB::table('purchases')->where('user_id', $r->user()->id)->get(), 'support' => DB::table('ticket_messages')->where('user_id', $r->user()->id)->get(), 'consent' => DB::table('consents')->where('user_id', $r->user()->id)->get(), 'privacy_requests' => DB::table('privacy_requests')->where('user_id', $r->user()->id)->get()];
|
||||
|
||||
return response()->streamDownload(fn () => print (json_encode($data, JSON_PRETTY_PRINT)), 'tarnax-personal-data.json', ['Content-Type' => 'application/json']);
|
||||
}
|
||||
|
||||
public function deletion(Request $r)
|
||||
{
|
||||
DB::table('privacy_requests')->updateOrInsert(['user_id' => $r->user()->id, 'type' => 'deletion', 'status' => 'pending'], ['created_at' => now(), 'updated_at' => now()]);
|
||||
|
||||
return back()->with('status', 'Deletion requested. Support will review retained billing records and respond within the applicable deadline.');
|
||||
}
|
||||
|
||||
public function apple(Request $r, AppleVerifier $verifier)
|
||||
{
|
||||
$v = $r->validate(['signed_transaction' => 'required|string|max:25000']);
|
||||
$claims = $verifier->verify($v['signed_transaction'], $r->user()->account_token ?? '');
|
||||
DB::transaction(function () use ($r, $claims) {
|
||||
$id = 'apple:'.$claims['transactionId'];
|
||||
$purchase = DB::table('purchases')->where('transaction_id', $id)->lockForUpdate()->first();
|
||||
abort_if($purchase && $purchase->user_id !== $r->user()->id, 403);
|
||||
abort_if($purchase && in_array($purchase->status, ['revoked', 'expired']), 403, 'Purchase is no longer valid.');
|
||||
if (! $purchase) {
|
||||
DB::table('purchases')->insert(['user_id' => $r->user()->id, 'provider' => 'apple', 'transaction_id' => $id, 'status' => 'verified', 'provider_updated_at' => Carbon::createFromTimestampMs($claims['signedDate']), 'created_at' => now(), 'updated_at' => now()]);
|
||||
}
|
||||
});
|
||||
|
||||
return back()->with('status','Apple purchase verified. App Store entitlements remain separate from direct licenses.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
use App\Services\LicenseService;
|
||||
class LicenseController extends Controller {
|
||||
public function activate(Request $r,LicenseService $service): JsonResponse {$v=$r->validate(['license_key'=>'required|string|max:100','device_id'=>'required|string|min:16|max:255']);return response()->json($service->activate($v['license_key'],$v['device_id']));}
|
||||
public function validateLicense(Request $r,LicenseService $service): JsonResponse {return response()->json($service->check($r->bearerToken()??''));}
|
||||
public function deactivate(Request $r,LicenseService $service): JsonResponse {return response()->json($service->check($r->bearerToken()??'',true));}
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class LicenseController extends Controller
|
||||
{
|
||||
public function activate(Request $r, LicenseService $service): JsonResponse
|
||||
{
|
||||
$v = $r->validate(['license_key' => 'required|string|max:100', 'device_id' => 'required|string|min:16|max:255']);
|
||||
|
||||
return response()->json($service->activate($v['license_key'], $v['device_id']));
|
||||
}
|
||||
|
||||
public function validateLicense(Request $r, LicenseService $service): JsonResponse
|
||||
{
|
||||
return response()->json($service->check($r->bearerToken() ?? ''));
|
||||
}
|
||||
|
||||
public function deactivate(Request $r, LicenseService $service): JsonResponse
|
||||
{
|
||||
return response()->json($service->check($r->bearerToken() ?? '', true));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,52 +1,118 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\LicenseService;
|
||||
use App\Notifications\AccountUpdate;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Services\LicenseService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Str;
|
||||
use Carbon\Carbon;
|
||||
class PaddleController extends Controller {
|
||||
public function checkout(Request $r): JsonResponse {
|
||||
$config=config('tarnax.paddle');abort_unless($config['api_key'] && $config['price_id'] && $config['client_token'],503,'Checkout is awaiting merchant configuration.');
|
||||
$user=$r->user();if(!$user->account_token){$user->forceFill(['account_token'=>(string)Str::uuid()])->save();}
|
||||
$base=$config['environment']==='production'?'https://api.paddle.com':'https://sandbox-api.paddle.com';
|
||||
$response=Http::withToken($config['api_key'])->timeout(15)->post($base.'/transactions',['items'=>[['price_id'=>$config['price_id'],'quantity'=>1]],'collection_mode'=>'automatic','custom_data'=>['account_token'=>$user->account_token]]);
|
||||
abort_unless($response->successful(),502,'Payment provider unavailable.');
|
||||
return response()->json(['transaction_id'=>$response->json('data.id')]);
|
||||
}
|
||||
public function webhook(Request $r,LicenseService $licenses): JsonResponse {
|
||||
$secret=config('tarnax.paddle.webhook_secret');abort_unless($secret,503);
|
||||
$parts=[];foreach(explode(';',$r->header('Paddle-Signature','')) as $part){$pair=explode('=',trim($part),2);if(count($pair)===2){$parts[$pair[0]][]=$pair[1];}}
|
||||
$ts=$parts['ts'][0]??'';abort_unless(ctype_digit($ts) && abs(time()-(int)$ts)<=300,401);
|
||||
$expected=hash_hmac('sha256',$ts.':'.$r->getContent(),$secret);$valid=false;foreach($parts['h1']??[] as $signature){$valid=$valid||hash_equals($expected,$signature);}abort_unless($valid,401);
|
||||
$event=$r->validate(['event_id'=>'required|string|max:100','event_type'=>'required|string|max:100','occurred_at'=>'required|date','data'=>'required|array']);
|
||||
DB::transaction(function()use($event,$licenses){
|
||||
$inserted=DB::table('webhook_events')->insertOrIgnore(['id'=>$event['event_id'],'type'=>$event['event_type'],'processed_at'=>now()]);if(!$inserted){return;}
|
||||
$d=$event['data'];$type=$event['event_type'];$at=Carbon::parse($event['occurred_at']);
|
||||
if($type==='transaction.completed'){
|
||||
abort_unless(($d['status']??'')==='completed',422);
|
||||
$matching=collect($d['items']??[])->contains(fn($i)=>($i['price']['id']??null)===config('tarnax.paddle.price_id') && ($i['quantity']??0)>=1);if(!$matching){return;}
|
||||
$user=User::where('account_token',$d['custom_data']['account_token']??'')->first();abort_unless($user,422,'Unknown customer.');
|
||||
$purchase=DB::table('purchases')->where('transaction_id',$d['id'])->lockForUpdate()->first();
|
||||
if($purchase && ($purchase->status==='refunded' || Carbon::parse($purchase->provider_updated_at)->gte($at))){return;}
|
||||
$id=$purchase?->id??DB::table('purchases')->insertGetId(['user_id'=>$user->id,'provider'=>'paddle','transaction_id'=>$d['id'],'subscription_id'=>$d['subscription_id']??null,'status'=>'completed','total'=>(int)($d['details']['totals']['grand_total']??0),'currency'=>$d['currency_code']??'USD','provider_updated_at'=>$at,'created_at'=>now(),'updated_at'=>now()]);
|
||||
$licenses->issue($user->id,$id);
|
||||
$user->notify(new AccountUpdate('Your Tarnax license is ready','Your purchase has been confirmed. Your license and downloads are in your account.',url('/account')));
|
||||
}
|
||||
if($type==='adjustment.updated' || $type==='adjustment.created'){
|
||||
if(($d['status']??'')!=='approved' || !in_array($d['action']??'',['refund','chargeback','chargeback_warning'])){return;}
|
||||
$purchase=DB::table('purchases')->where('transaction_id',$d['transaction_id']??'')->lockForUpdate()->first();
|
||||
if($purchase){DB::table('purchases')->where('id',$purchase->id)->update(['status'=>'refunded','provider_updated_at'=>$at,'updated_at'=>now()]);DB::table('licenses')->where('purchase_id',$purchase->id)->update(['status'=>'revoked','updated_at'=>now()]);}
|
||||
}
|
||||
if(in_array($type,['subscription.canceled','subscription.paused','subscription.past_due'])){
|
||||
$purchases=DB::table('purchases')->where('subscription_id',$d['id']??'')->where('provider_updated_at','<',$at)->get();
|
||||
foreach($purchases as $purchase){DB::table('licenses')->where('purchase_id',$purchase->id)->update(['status'=>'revoked','updated_at'=>now()]);DB::table('purchases')->where('id',$purchase->id)->update(['status'=>$d['status'],'provider_updated_at'=>$at]);}
|
||||
}
|
||||
},3);
|
||||
return response()->json(['received'=>true]);
|
||||
}
|
||||
|
||||
class PaddleController extends Controller
|
||||
{
|
||||
public function checkout(Request $r): JsonResponse
|
||||
{
|
||||
$config = config('tarnax.paddle');
|
||||
abort_unless($config['api_key'] && $config['price_id'] && $config['client_token'], 503, 'Checkout is awaiting merchant configuration.');
|
||||
$user = $r->user();
|
||||
if (! $user->account_token) {
|
||||
$user->forceFill(['account_token' => (string) Str::uuid()])->save();
|
||||
}
|
||||
$base = $config['environment'] === 'production' ? 'https://api.paddle.com' : 'https://sandbox-api.paddle.com';
|
||||
$response = Http::withToken($config['api_key'])->timeout(15)->post($base.'/transactions', ['items' => [['price_id' => $config['price_id'], 'quantity' => 1]], 'collection_mode' => 'automatic', 'custom_data' => ['account_token' => $user->account_token]]);
|
||||
abort_unless($response->successful(), 502, 'Payment provider unavailable.');
|
||||
|
||||
return response()->json(['transaction_id' => $response->json('data.id')]);
|
||||
}
|
||||
|
||||
public function webhook(Request $r, LicenseService $licenses): JsonResponse
|
||||
{
|
||||
$secret = config('tarnax.paddle.webhook_secret');
|
||||
abort_unless($secret, 503);
|
||||
$parts = [];
|
||||
foreach (explode(';', $r->header('Paddle-Signature', '')) as $part) {
|
||||
$pair = explode('=', trim($part), 2);
|
||||
if (count($pair) === 2) {
|
||||
$parts[$pair[0]][] = $pair[1];
|
||||
}
|
||||
}
|
||||
$ts = $parts['ts'][0] ?? '';
|
||||
abort_unless(ctype_digit($ts) && abs(time() - (int) $ts) <= 300, 401);
|
||||
$expected = hash_hmac('sha256', $ts.':'.$r->getContent(), $secret);
|
||||
$valid = false;
|
||||
foreach ($parts['h1'] ?? [] as $signature) {
|
||||
$valid = $valid || hash_equals($expected, $signature);
|
||||
}abort_unless($valid, 401);
|
||||
$event = $r->validate(['event_id' => 'required|string|max:100', 'event_type' => 'required|string|max:100', 'occurred_at' => 'required|date', 'data' => 'required|array']);
|
||||
DB::transaction(function () use ($event, $licenses) {
|
||||
$inserted = DB::table('webhook_events')->insertOrIgnore(['id' => $event['event_id'], 'type' => $event['event_type'], 'processed_at' => now()]);
|
||||
if (! $inserted) {
|
||||
return;
|
||||
}
|
||||
$d = $event['data'];
|
||||
$type = $event['event_type'];
|
||||
$at = Carbon::parse($event['occurred_at']);
|
||||
if ($type === 'transaction.completed') {
|
||||
abort_unless(($d['status'] ?? '') === 'completed' && ! empty($d['id']), 422);
|
||||
DB::table('payment_locks')->insertOrIgnore(['id' => $d['id']]);
|
||||
DB::table('payment_locks')->where('id', $d['id'])->lockForUpdate()->first();
|
||||
abort_unless(config('tarnax.paddle.price_id'), 503);
|
||||
$matching = collect($d['items'] ?? [])->contains(fn ($i) => ($i['price']['id'] ?? null) === config('tarnax.paddle.price_id') && ($i['quantity'] ?? 0) >= 1);
|
||||
if (! $matching) {
|
||||
return;
|
||||
}
|
||||
if (DB::table('refund_blocks')->where('transaction_id', $d['id'])->exists()) {
|
||||
return;
|
||||
}
|
||||
$user = User::where('account_token', $d['custom_data']['account_token'] ?? '')->first();
|
||||
abort_unless($user, 422, 'Unknown customer.');
|
||||
$purchase = DB::table('purchases')->where('transaction_id', $d['id'])->lockForUpdate()->first();
|
||||
if ($purchase && ($purchase->status === 'refunded' || Carbon::parse($purchase->provider_updated_at)->gte($at))) {
|
||||
return;
|
||||
}
|
||||
$id = $purchase?->id ?? DB::table('purchases')->insertGetId(['user_id' => $user->id, 'provider' => 'paddle', 'transaction_id' => $d['id'], 'subscription_id' => $d['subscription_id'] ?? null, 'status' => 'completed', 'total' => (int) ($d['details']['totals']['grand_total'] ?? 0), 'currency' => $d['currency_code'] ?? 'USD', 'provider_updated_at' => $at, 'created_at' => now(), 'updated_at' => now()]);
|
||||
$licenses->issue($user->id, $id);
|
||||
if (! empty($d['subscription_id'])) {
|
||||
$state = DB::table('subscription_states')->where('id', $d['subscription_id'])->first();
|
||||
if ($state && ! in_array($state->status, ['active', 'trialing'])) {
|
||||
DB::table('licenses')->where('purchase_id', $id)->update(['status' => 'revoked']);
|
||||
}
|
||||
}
|
||||
$user->notify(new AccountUpdate('Your Tarnax license is ready', 'Your purchase has been confirmed. Your license and downloads are in your account.', url('/account'), 'purchase-confirmed'));
|
||||
}
|
||||
if ($type === 'adjustment.updated' || $type === 'adjustment.created') {
|
||||
if (($d['status'] ?? '') !== 'approved' || ! in_array($d['action'] ?? '', ['refund', 'chargeback', 'chargeback_warning'])) {
|
||||
return;
|
||||
}
|
||||
abort_unless(! empty($d['transaction_id']), 422);
|
||||
DB::table('payment_locks')->insertOrIgnore(['id' => $d['transaction_id']]);
|
||||
DB::table('payment_locks')->where('id', $d['transaction_id'])->lockForUpdate()->first();
|
||||
DB::table('refund_blocks')->updateOrInsert(['transaction_id' => $d['transaction_id']], ['occurred_at' => $at]);
|
||||
$purchase = DB::table('purchases')->where('transaction_id', $d['transaction_id'])->lockForUpdate()->first();
|
||||
if ($purchase) {
|
||||
DB::table('purchases')->where('id', $purchase->id)->update(['status' => 'refunded', 'provider_updated_at' => $at, 'updated_at' => now()]);
|
||||
DB::table('licenses')->where('purchase_id', $purchase->id)->update(['status' => 'revoked', 'updated_at' => now()]);
|
||||
}
|
||||
}
|
||||
if (str_starts_with($type, 'subscription.') && ! empty($d['id']) && in_array($d['status'] ?? '', ['active', 'trialing', 'canceled', 'paused', 'past_due'])) {
|
||||
DB::table('subscription_states')->insertOrIgnore(['id' => $d['id'], 'status' => $d['status'], 'occurred_at' => $at]);
|
||||
$state = DB::table('subscription_states')->where('id', $d['id'])->lockForUpdate()->first();
|
||||
if (Carbon::parse($state->occurred_at)->gt($at)) {
|
||||
return;
|
||||
}
|
||||
DB::table('subscription_states')->where('id', $d['id'])->update(['status' => $d['status'], 'occurred_at' => $at]);
|
||||
$purchases = DB::table('purchases')->where('subscription_id', $d['id'])->where('status', '!=', 'refunded')->lockForUpdate()->get();
|
||||
foreach ($purchases as $purchase) {
|
||||
DB::table('licenses')->where('purchase_id', $purchase->id)->update(['status' => in_array($d['status'], ['active', 'trialing']) ? 'active' : 'revoked', 'updated_at' => now()]);
|
||||
DB::table('purchases')->where('id', $purchase->id)->update(['status' => $d['status'], 'provider_updated_at' => $at, 'updated_at' => now()]);
|
||||
}
|
||||
}
|
||||
}, 3);
|
||||
|
||||
return response()->json(['received' => true]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
class RequireAdmin {
|
||||
public function handle(Request $request,Closure $next): Response {
|
||||
abort_unless($request->user()?->is_admin,403);
|
||||
if(!$request->user()->two_factor_confirmed_at){return redirect('/account/security')->with('status','Enable and confirm two-factor authentication before entering administration.');}
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
class RequireAdmin
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
abort_unless($request->user()?->is_admin, 403);
|
||||
if (! $request->user()->two_factor_confirmed_at) {
|
||||
return redirect('/account/security')->with('status', 'Enable and confirm two-factor authentication before entering administration.');
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,53 +1,186 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Admin;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithFileUploads;
|
||||
use Livewire\Attributes\Locked;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
class Cms extends Component {
|
||||
use WithFileUploads;
|
||||
public string $section='pages';public array $form=[];public $upload;
|
||||
#[Locked] public ?int $editing=null;
|
||||
private const TABLES=['pages','releases','users','purchases','licenses','tickets','privacy_requests','consents','audit_logs','settings'];
|
||||
public function boot(): void {abort_unless(auth()->user()?->is_admin && auth()->user()->two_factor_confirmed_at,403);}
|
||||
private function section(): string {abort_unless(in_array($this->section,self::TABLES),404);return $this->section;}
|
||||
public function newRecord(): void {$this->reset('form','editing','upload');}
|
||||
public function updatedSection(): void {$this->reset('form','editing','upload');}
|
||||
private function audit(string $action,string $resource): void {DB::table('audit_logs')->insert(['user_id'=>auth()->id(),'action'=>$action,'resource'=>$resource,'created_at'=>now(),'updated_at'=>now()]);}
|
||||
public function edit(int $id): void {
|
||||
$section=$this->section();abort_unless(in_array($section,['pages','releases']),403);
|
||||
$row=DB::table($section)->find($id);abort_unless($row,404);$this->editing=$id;
|
||||
$this->form=array_intersect_key((array)$row,array_flip($section==='pages'?['slug','title','body','description','published']:['version','channel','platform','notes','published']));
|
||||
}
|
||||
public function save(): void {
|
||||
$section=$this->section();abort_unless(in_array($section,['pages','releases']),403);
|
||||
if($section==='pages'){$data=$this->validate(['form.slug'=>'required|alpha_dash|max:100|unique:pages,slug,'.($this->editing??'NULL'),'form.title'=>'required|string|max:200','form.body'=>'required|string|max:100000','form.description'=>'nullable|string|max:255','form.published'=>'boolean'])['form'];}
|
||||
else {
|
||||
$data=$this->validate(['form.version'=>'required|regex:/^[0-9]+\.[0-9]+\.[0-9]+(?:-[a-zA-Z0-9.]+)?$/|max:50|unique:releases,version,'.($this->editing??'NULL'),'form.channel'=>'required|in:stable,beta','form.platform'=>'required|string|max:50','form.notes'=>'required|string|max:20000','form.published'=>'boolean'])['form'];
|
||||
$this->validate(['upload'=>($this->editing?'nullable':'required').'|file|extensions:zip,dmg,pkg|max:1048576']);
|
||||
if($this->upload){$path=$this->upload->store('releases','local');$data+=['path'=>$path,'sha256'=>hash_file('sha256',Storage::disk('local')->path($path)),'bytes'=>Storage::disk('local')->size($path)];}
|
||||
}
|
||||
$data['published']=$data['published']??false;$data['updated_at']=now();
|
||||
if($this->editing){DB::table($section)->where('id',$this->editing)->update($data);$id=$this->editing;}else{$data['created_at']=now();$id=DB::table($section)->insertGetId($data);}
|
||||
$this->audit('saved',$section.':'.$id);$this->reset('form','editing','upload');session()->flash('status','Saved successfully.');
|
||||
}
|
||||
public function revoke(int $id): void {abort_unless($this->section()==='licenses',403);DB::table('licenses')->where('id',$id)->update(['status'=>'revoked','updated_at'=>now()]);$this->audit('revoked','license:'.$id);}
|
||||
public function saveSettings(): void {
|
||||
abort_unless($this->section()==='settings',403);$v=$this->validate(['form.headline'=>'required|string|max:120','form.tagline'=>'required|string|max:500','form.price_label'=>'required|string|max:100'])['form'];foreach($v as $key=>$value){DB::table('settings')->updateOrInsert(['key'=>$key],['value'=>$value]);}$this->audit('saved','settings');session()->flash('status','Settings saved.');
|
||||
}
|
||||
public function fulfillDeletion(int $id): void {
|
||||
abort_unless($this->section()==='privacy_requests',403);
|
||||
DB::transaction(function()use($id){$request=DB::table('privacy_requests')->where('id',$id)->lockForUpdate()->first();abort_unless($request && $request->type==='deletion' && $request->status==='pending',422);abort_if($request->user_id===auth()->id(),422,'Cannot erase your own administrator account.');
|
||||
$user=\App\Models\User::findOrFail($request->user_id);abort_if($user->is_admin,422,'Remove administrator access before erasure.');
|
||||
DB::table('licenses')->where('user_id',$user->id)->update(['status'=>'revoked','updated_at'=>now()]);DB::table('activations')->whereIn('license_id',DB::table('licenses')->where('user_id',$user->id)->select('id'))->delete();
|
||||
DB::table('tickets')->where('user_id',$user->id)->delete();DB::table('ticket_messages')->where('user_id',$user->id)->delete();DB::table('consents')->where('user_id',$user->id)->delete();DB::table('sessions')->where('user_id',$user->id)->delete();DB::table('password_reset_tokens')->where('email',$user->email)->delete();
|
||||
$user->forceFill(['name'=>'Deleted customer','email'=>'deleted-'.$user->id.'@invalid.example','password'=>bin2hex(random_bytes(32)),'remember_token'=>null,'account_token'=>null,'two_factor_secret'=>null,'two_factor_recovery_codes'=>null])->save();
|
||||
DB::table('privacy_requests')->where('id',$id)->update(['status'=>'fulfilled','updated_at'=>now()]);$this->audit('privacy erasure fulfilled; billing records retained','user:'.$user->id);
|
||||
});
|
||||
}
|
||||
public function render() {
|
||||
$section=$this->section();$query=DB::table($section);if($section==='users'){$query->select('id','name','email','is_admin','email_verified_at');}elseif($section==='licenses'){$query->select('id','user_id','purchase_id','status','device_limit');}
|
||||
return view('livewire.admin.cms',['sections'=>self::TABLES,'rows'=>$query->orderBy($section==='settings'?'key':'id','desc')->limit(100)->get()]);
|
||||
}
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithFileUploads;
|
||||
|
||||
class Cms extends Component
|
||||
{
|
||||
use WithFileUploads;
|
||||
|
||||
public string $section = 'pages';
|
||||
|
||||
public array $form = [];
|
||||
|
||||
public $upload;
|
||||
|
||||
#[Locked]
|
||||
public ?int $editing = null;
|
||||
|
||||
private const TABLES = ['pages', 'releases', 'users', 'purchases', 'licenses', 'tickets', 'privacy_requests', 'consents', 'audit_logs', 'settings', 'navigation', 'media', 'redirects', 'email_templates'];
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
abort_unless(auth()->user()?->is_admin && auth()->user()->two_factor_confirmed_at && auth()->user()->hasVerifiedEmail(), 403);
|
||||
}
|
||||
|
||||
private function section(): string
|
||||
{
|
||||
abort_unless(in_array($this->section, self::TABLES), 404);
|
||||
|
||||
return $this->section;
|
||||
}
|
||||
|
||||
public function newRecord(): void
|
||||
{
|
||||
$this->reset('form', 'editing', 'upload');
|
||||
}
|
||||
|
||||
public function updatedSection(): void
|
||||
{
|
||||
$this->reset('form', 'editing', 'upload');
|
||||
}
|
||||
|
||||
private function audit(string $action, string $resource): void
|
||||
{
|
||||
DB::table('audit_logs')->insert(['user_id' => auth()->id(), 'action' => $action, 'resource' => $resource, 'created_at' => now(), 'updated_at' => now()]);
|
||||
}
|
||||
|
||||
public function edit(int $id): void
|
||||
{
|
||||
$section = $this->section();
|
||||
abort_unless(in_array($section, ['pages', 'releases']), 403);
|
||||
$row = DB::table($section)->find($id);
|
||||
abort_unless($row, 404);
|
||||
$this->editing = $id;
|
||||
$this->form = array_intersect_key((array) $row, array_flip($section === 'pages' ? ['slug', 'title', 'body', 'description', 'published'] : ['version', 'channel', 'platform', 'notes', 'published']));
|
||||
}
|
||||
|
||||
public function save(): void
|
||||
{
|
||||
$section = $this->section();
|
||||
abort_unless(in_array($section, ['pages', 'releases']), 403);
|
||||
if ($section === 'pages') {
|
||||
$data = $this->validate(['form.slug' => 'required|alpha_dash|max:100|unique:pages,slug,'.($this->editing ?? 'NULL'), 'form.title' => 'required|string|max:200', 'form.body' => 'required|string|max:100000', 'form.description' => 'nullable|string|max:255', 'form.published' => 'boolean'])['form'];
|
||||
} else {
|
||||
$data = $this->validate(['form.version' => 'required|regex:/^[0-9]+\.[0-9]+\.[0-9]+(?:-[a-zA-Z0-9.]+)?$/|max:50|unique:releases,version,'.($this->editing ?? 'NULL'), 'form.channel' => 'required|in:stable,beta', 'form.platform' => 'required|string|max:50', 'form.notes' => 'required|string|max:20000', 'form.published' => 'boolean'])['form'];
|
||||
$this->validate(['upload' => ($this->editing ? 'nullable' : 'required').'|file|extensions:zip,dmg,pkg|max:1048576']);
|
||||
if ($this->upload) {
|
||||
$path = $this->upload->store('releases', 'local');
|
||||
$data += ['path' => $path, 'sha256' => hash_file('sha256', Storage::disk('local')->path($path)), 'bytes' => Storage::disk('local')->size($path)];
|
||||
}
|
||||
}
|
||||
$data['published'] = $data['published'] ?? false;
|
||||
$data['updated_at'] = now();
|
||||
if ($this->editing) {
|
||||
DB::table($section)->where('id', $this->editing)->update($data);
|
||||
$id = $this->editing;
|
||||
} else {
|
||||
$data['created_at'] = now();
|
||||
$id = DB::table($section)->insertGetId($data);
|
||||
}
|
||||
$this->audit('saved', $section.':'.$id);
|
||||
$this->reset('form', 'editing', 'upload');
|
||||
session()->flash('status', 'Saved successfully.');
|
||||
}
|
||||
|
||||
public function revoke(int $id): void
|
||||
{
|
||||
abort_unless($this->section() === 'licenses', 403);
|
||||
DB::table('licenses')->where('id', $id)->update(['status' => 'revoked', 'updated_at' => now()]);
|
||||
$this->audit('revoked', 'license:'.$id);
|
||||
}
|
||||
|
||||
public function saveResource(): void
|
||||
{
|
||||
$section = $this->section();
|
||||
abort_unless(in_array($section, ['navigation', 'media', 'redirects', 'email_templates']), 403);
|
||||
$rules = match ($section) {
|
||||
'navigation' => ['form.label' => 'required|string|max:80', 'form.path' => ['required', 'string', 'regex:~^/(?!/)[a-zA-Z0-9/_?.=&%#-]*$~'], 'form.position' => 'required|integer|min:0|max:1000', 'form.published' => 'boolean'],
|
||||
'redirects' => ['form.source' => ['required', 'string', 'max:200', 'regex:~^/(?!/)[a-zA-Z0-9/_-]+$~', 'unique:redirects,source'], 'form.destination' => ['required', 'string', 'max:200', 'different:form.source', 'regex:~^/(?!/)[a-zA-Z0-9/_-]+$~']],
|
||||
'media' => ['form.title' => 'required|string|max:150', 'form.alt' => 'required|string|max:255', 'upload' => 'required|image|mimes:png,jpg,jpeg,webp|dimensions:max_width=6000,max_height=6000|max:10240'],
|
||||
'email_templates' => ['form.slug' => 'required|alpha_dash|max:80|unique:email_templates,slug', 'form.subject' => 'required|string|max:200', 'form.body' => 'required|string|max:10000'],
|
||||
};
|
||||
$validated = $this->validate($rules);
|
||||
$data = $validated['form'];
|
||||
if ($section === 'media') {
|
||||
$data += ['path' => $this->upload->store('media', 'local'), 'mime' => $this->upload->getMimeType(), 'bytes' => $this->upload->getSize()];
|
||||
}
|
||||
if ($section === 'navigation') {
|
||||
$data['published'] = $data['published'] ?? false;
|
||||
}
|
||||
DB::table($section)->insert([...$data, 'created_at' => now(), 'updated_at' => now()]);
|
||||
$this->audit('created', $section);
|
||||
$this->reset('form', 'upload');
|
||||
}
|
||||
|
||||
public function remove(int $id): void
|
||||
{
|
||||
$section = $this->section();
|
||||
abort_unless(in_array($section, ['navigation', 'media', 'redirects', 'email_templates']), 403);
|
||||
$row = DB::table($section)->find($id);
|
||||
abort_unless($row, 404);
|
||||
DB::table($section)->where('id', $id)->delete();
|
||||
if ($section === 'media') {
|
||||
Storage::disk('local')->delete($row->path);
|
||||
}$this->audit('deleted', $section.':'.$id);
|
||||
}
|
||||
|
||||
public function setDeviceLimit(int $id): void
|
||||
{
|
||||
abort_unless($this->section() === 'licenses', 403);
|
||||
$v = $this->validate(['form.device_limit' => 'required|integer|min:1|max:100']);
|
||||
DB::table('licenses')->where('id', $id)->update(['device_limit' => $v['form']['device_limit'], 'updated_at' => now()]);
|
||||
$this->audit('device limit updated', 'license:'.$id);
|
||||
}
|
||||
|
||||
public function saveSettings(): void
|
||||
{
|
||||
abort_unless($this->section() === 'settings', 403);
|
||||
$v = $this->validate(['form.headline' => 'required|string|max:120', 'form.tagline' => 'required|string|max:500', 'form.price_label' => 'required|string|max:100'])['form'];
|
||||
foreach ($v as $key => $value) {
|
||||
DB::table('settings')->updateOrInsert(['key' => $key], ['value' => $value]);
|
||||
}$this->audit('saved', 'settings');
|
||||
session()->flash('status', 'Settings saved.');
|
||||
}
|
||||
|
||||
public function fulfillDeletion(int $id): void
|
||||
{
|
||||
abort_unless($this->section() === 'privacy_requests', 403);
|
||||
DB::transaction(function () use ($id) {
|
||||
$request = DB::table('privacy_requests')->where('id', $id)->lockForUpdate()->first();
|
||||
abort_unless($request && $request->type === 'deletion' && $request->status === 'pending', 422);
|
||||
abort_if($request->user_id === auth()->id(), 422, 'Cannot erase your own administrator account.');
|
||||
$user = User::findOrFail($request->user_id);
|
||||
abort_if($user->is_admin, 422, 'Remove administrator access before erasure.');
|
||||
DB::table('licenses')->where('user_id', $user->id)->update(['status' => 'revoked', 'updated_at' => now()]);
|
||||
DB::table('activations')->whereIn('license_id', DB::table('licenses')->where('user_id', $user->id)->select('id'))->delete();
|
||||
DB::table('tickets')->where('user_id', $user->id)->delete();
|
||||
DB::table('ticket_messages')->where('user_id', $user->id)->delete();
|
||||
DB::table('consents')->where('user_id', $user->id)->delete();
|
||||
DB::table('sessions')->where('user_id', $user->id)->delete();
|
||||
DB::table('password_reset_tokens')->where('email', $user->email)->delete();
|
||||
$user->forceFill(['name' => 'Deleted customer', 'email' => 'deleted-'.$user->id.'@invalid.example', 'password' => bin2hex(random_bytes(32)), 'remember_token' => null, 'account_token' => null, 'two_factor_secret' => null, 'two_factor_recovery_codes' => null])->save();
|
||||
DB::table('privacy_requests')->where('id', $id)->update(['status' => 'fulfilled', 'updated_at' => now()]);
|
||||
$this->audit('privacy erasure fulfilled; billing records retained', 'user:'.$user->id);
|
||||
});
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$section = $this->section();
|
||||
$query = DB::table($section);
|
||||
if ($section === 'users') {
|
||||
$query->select('id', 'name', 'email', 'is_admin', 'email_verified_at');
|
||||
} elseif ($section === 'licenses') {
|
||||
$query->select('id','user_id','purchase_id','status','device_limit');
|
||||
}
|
||||
|
||||
return view('livewire.admin.cms',['sections' => self::TABLES, 'rows' => $query->orderBy($section === 'settings' ? 'key' : 'id','desc')->limit(100)->get()]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,34 +1,95 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
use Livewire\Component;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Notifications\AccountUpdate;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
class SupportDesk extends Component {
|
||||
public string $subject='';public string $body='';public string $reply='';
|
||||
#[Locked] public ?int $ticketId=null;
|
||||
private function ticket() { $ticket=DB::table('tickets')->find($this->ticketId);abort_unless($ticket && (auth()->user()->is_admin || $ticket->user_id===auth()->id()),403);return $ticket; }
|
||||
public function select(int $id): void {$this->ticketId=$id;$this->ticket();}
|
||||
public function create(): void {
|
||||
$this->validate(['subject'=>'required|string|max:200','body'=>'required|string|min:10|max:10000']);
|
||||
$this->ticketId=DB::transaction(function(){ $id=DB::table('tickets')->insertGetId(['user_id'=>auth()->id(),'subject'=>$this->subject,'status'=>'open','created_at'=>now(),'updated_at'=>now()]);DB::table('ticket_messages')->insert(['ticket_id'=>$id,'user_id'=>auth()->id(),'body'=>$this->body,'created_at'=>now(),'updated_at'=>now()]);return $id;});
|
||||
if(config('tarnax.support_email')){Notification::route('mail',config('tarnax.support_email'))->notify(new AccountUpdate('New Tarnax support ticket',$this->subject,url('/support')));}
|
||||
$this->reset('subject','body');
|
||||
}
|
||||
public function send(): void {
|
||||
$ticket=$this->ticket();$this->validate(['reply'=>'required|string|min:2|max:10000']);
|
||||
DB::table('ticket_messages')->insert(['ticket_id'=>$ticket->id,'user_id'=>auth()->id(),'body'=>$this->reply,'created_at'=>now(),'updated_at'=>now()]);
|
||||
DB::table('tickets')->where('id',$ticket->id)->update(['status'=>'open','updated_at'=>now()]);
|
||||
$notification=new AccountUpdate('Your Tarnax support ticket has a reply','Sign in to read the reply.',url('/support'));
|
||||
if(auth()->user()->is_admin){User::findOrFail($ticket->user_id)->notify($notification);}elseif(config('tarnax.support_email')){Notification::route('mail',config('tarnax.support_email'))->notify($notification);}
|
||||
$this->reply='';
|
||||
}
|
||||
public function close(): void {$ticket=$this->ticket();DB::table('tickets')->where('id',$ticket->id)->update(['status'=>'closed','updated_at'=>now()]);}
|
||||
public function render() {
|
||||
$query=DB::table('tickets');if(!auth()->user()->is_admin){$query->where('user_id',auth()->id());}
|
||||
$messages=collect();if($this->ticketId){$this->ticket();$messages=DB::table('ticket_messages')->join('users','users.id','=','ticket_messages.user_id')->where('ticket_id',$this->ticketId)->select('ticket_messages.*','users.name')->orderBy('ticket_messages.id')->get();}
|
||||
return view('livewire.support-desk',['tickets'=>$query->latest('updated_at')->limit(100)->get(),'messages'=>$messages]);
|
||||
}
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Component;
|
||||
|
||||
class SupportDesk extends Component
|
||||
{
|
||||
public function boot(): void
|
||||
{
|
||||
abort_unless(auth()->check() && auth()->user()->hasVerifiedEmail(), 403);
|
||||
if (auth()->user()->is_admin) {
|
||||
abort_unless(auth()->user()->two_factor_confirmed_at, 403);
|
||||
}
|
||||
}
|
||||
|
||||
public string $subject = '';
|
||||
|
||||
public string $body = '';
|
||||
|
||||
public string $reply = '';
|
||||
|
||||
#[Locked]
|
||||
public ?int $ticketId = null;
|
||||
|
||||
private function ticket()
|
||||
{
|
||||
$ticket = DB::table('tickets')->find($this->ticketId);
|
||||
abort_unless($ticket && (auth()->user()->is_admin || $ticket->user_id === auth()->id()), 403);
|
||||
|
||||
return $ticket;
|
||||
}
|
||||
|
||||
public function select(int $id): void
|
||||
{
|
||||
$this->ticketId = $id;
|
||||
$this->ticket();
|
||||
}
|
||||
|
||||
public function create(): void
|
||||
{
|
||||
$this->validate(['subject' => 'required|string|max:200', 'body' => 'required|string|min:10|max:10000']);
|
||||
$this->ticketId = DB::transaction(function () {
|
||||
$id = DB::table('tickets')->insertGetId(['user_id' => auth()->id(), 'subject' => $this->subject, 'status' => 'open', 'created_at' => now(), 'updated_at' => now()]);
|
||||
DB::table('ticket_messages')->insert(['ticket_id' => $id, 'user_id' => auth()->id(), 'body' => $this->body, 'created_at' => now(), 'updated_at' => now()]);
|
||||
|
||||
return $id;
|
||||
});
|
||||
if (config('tarnax.support_email')) {
|
||||
Notification::route('mail', config('tarnax.support_email'))->notify(new AccountUpdate('New Tarnax support ticket', $this->subject, url('/support'), 'support-update'));
|
||||
}
|
||||
$this->reset('subject', 'body');
|
||||
}
|
||||
|
||||
public function send(): void
|
||||
{
|
||||
$ticket = $this->ticket();
|
||||
$this->validate(['reply' => 'required|string|min:2|max:10000']);
|
||||
DB::table('ticket_messages')->insert(['ticket_id' => $ticket->id, 'user_id' => auth()->id(), 'body' => $this->reply, 'created_at' => now(), 'updated_at' => now()]);
|
||||
DB::table('tickets')->where('id', $ticket->id)->update(['status' => 'open', 'updated_at' => now()]);
|
||||
$notification = new AccountUpdate('Your Tarnax support ticket has a reply', 'Sign in to read the reply.', url('/support'), 'support-update');
|
||||
if (auth()->user()->is_admin) {
|
||||
User::findOrFail($ticket->user_id)->notify($notification);
|
||||
} elseif (config('tarnax.support_email')) {
|
||||
Notification::route('mail', config('tarnax.support_email'))->notify($notification);
|
||||
}
|
||||
$this->reply = '';
|
||||
}
|
||||
|
||||
public function close(): void
|
||||
{
|
||||
$ticket = $this->ticket();
|
||||
DB::table('tickets')->where('id', $ticket->id)->update(['status' => 'closed', 'updated_at' => now()]);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$query = DB::table('tickets');
|
||||
if (! auth()->user()->is_admin) {
|
||||
$query->where('user_id', auth()->id());
|
||||
}
|
||||
$messages = collect();
|
||||
if ($this->ticketId) {
|
||||
$this->ticket();
|
||||
$messages = DB::table('ticket_messages')->join('users', 'users.id', '=', 'ticket_messages.user_id')->where('ticket_id', $this->ticketId)->select('ticket_messages.*', 'users.name')->orderBy('ticket_messages.id')->get();
|
||||
}
|
||||
|
||||
return view('livewire.support-desk', ['tickets' => $query->latest('updated_at')->limit(100)->get(), 'messages' => $messages]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,14 +2,16 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Laravel\Fortify\TwoFactorAuthenticatable;
|
||||
use App\Notifications\QueuedResetPassword;
|
||||
use App\Notifications\QueuedVerifyEmail;
|
||||
use Database\Factories\UserFactory;
|
||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Laravel\Fortify\TwoFactorAuthenticatable;
|
||||
|
||||
#[Fillable(['name', 'email', 'password'])]
|
||||
#[Hidden(['password', 'remember_token', 'two_factor_secret', 'two_factor_recovery_codes'])]
|
||||
@@ -23,6 +25,16 @@ class User extends Authenticatable implements MustVerifyEmail
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function sendEmailVerificationNotification(): void
|
||||
{
|
||||
$this->notify(new QueuedVerifyEmail);
|
||||
}
|
||||
|
||||
public function sendPasswordResetNotification($token): void
|
||||
{
|
||||
$this->notify(new QueuedResetPassword($token));
|
||||
}
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
|
||||
@@ -1,12 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Notification;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
class AccountUpdate extends Notification implements ShouldQueue {
|
||||
use Queueable;
|
||||
public function __construct(public string $subject, public string $message, public string $url) { $this->afterCommit(); }
|
||||
public function via(object $notifiable): array {return ['mail'];}
|
||||
public function toMail(object $notifiable): MailMessage {return (new MailMessage)->subject($this->subject)->line($this->message)->action('Open your account',$this->url);}
|
||||
use Illuminate\Notifications\Notification;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class AccountUpdate extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(public string $subject, public string $message, public string $url, public ?string $template = null)
|
||||
{
|
||||
$this->afterCommit();
|
||||
}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['mail'];
|
||||
}
|
||||
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
$template = $this->template ? DB::table('email_templates')->where('slug', $this->template)->first() : null;
|
||||
|
||||
return (new MailMessage)->subject($template?->subject ?? $this->subject)->line($template?->body ?? $this->message)->action('Open your account', $this->url);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,53 +2,11 @@
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use Illuminate\Auth\Notifications\ResetPassword;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class QueuedResetPassword extends Notification
|
||||
class QueuedResetPassword extends ResetPassword implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
/**
|
||||
* Create a new notification instance.
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the notification's delivery channels.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['mail'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the mail representation of the notification.
|
||||
*/
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
return (new MailMessage)
|
||||
->line('The introduction to the notification.')
|
||||
->action('Notification Action', url('/'))
|
||||
->line('Thank you for using our application!');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the array representation of the notification.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(object $notifiable): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,53 +2,11 @@
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use Illuminate\Auth\Notifications\VerifyEmail;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class QueuedVerifyEmail extends Notification
|
||||
class QueuedVerifyEmail extends VerifyEmail implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
/**
|
||||
* Create a new notification instance.
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the notification's delivery channels.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['mail'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the mail representation of the notification.
|
||||
*/
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
return (new MailMessage)
|
||||
->line('The introduction to the notification.')
|
||||
->action('Notification Action', url('/'))
|
||||
->line('Thank you for using our application!');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the array representation of the notification.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(object $notifiable): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
@@ -19,7 +21,7 @@ class AppServiceProvider extends ServiceProvider
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
\Illuminate\Support\Facades\RateLimiter::for('license', fn ($request) => \Illuminate\Cache\RateLimiting\Limit::perMinute(30)->by($request->ip()));
|
||||
RateLimiter::for('license', fn ($request) => Limit::perMinute(30)->by($request->ip()));
|
||||
//
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,13 +29,13 @@ class FortifyServiceProvider extends ServiceProvider
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
Fortify::loginView(fn () => view('auth.form', ['mode'=>'login']));
|
||||
Fortify::registerView(fn () => view('auth.form', ['mode'=>'register']));
|
||||
Fortify::requestPasswordResetLinkView(fn () => view('auth.form', ['mode'=>'forgot-password']));
|
||||
Fortify::resetPasswordView(fn ($request) => view('auth.form', ['mode'=>'reset-password', 'request'=>$request]));
|
||||
Fortify::loginView(fn () => view('auth.form', ['mode' => 'login']));
|
||||
Fortify::registerView(fn () => view('auth.form', ['mode' => 'register']));
|
||||
Fortify::requestPasswordResetLinkView(fn () => view('auth.form', ['mode' => 'forgot-password']));
|
||||
Fortify::resetPasswordView(fn ($request) => view('auth.form', ['mode' => 'reset-password', 'request' => $request]));
|
||||
Fortify::verifyEmailView(fn () => view('auth.verify'));
|
||||
Fortify::confirmPasswordView(fn () => view('auth.form', ['mode'=>'confirm-password']));
|
||||
Fortify::twoFactorChallengeView(fn () => view('auth.form', ['mode'=>'two-factor-challenge']));
|
||||
Fortify::confirmPasswordView(fn () => view('auth.form', ['mode' => 'confirm-password']));
|
||||
Fortify::twoFactorChallengeView(fn () => view('auth.form', ['mode' => 'two-factor-challenge']));
|
||||
Fortify::createUsersUsing(CreateNewUser::class);
|
||||
Fortify::updateUserProfileInformationUsing(UpdateUserProfileInformation::class);
|
||||
Fortify::updateUserPasswordsUsing(UpdateUserPassword::class);
|
||||
|
||||
@@ -1,30 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
/** Verifies StoreKit 2 transaction JWS against a locally pinned Apple root. */
|
||||
class AppleVerifier {
|
||||
private function decode(string $data): string { $result=base64_decode(strtr($data,'-_','+/'),true);abort_unless($result!==false,422);return $result; }
|
||||
private function pem(string $cert): string {return "-----BEGIN CERTIFICATE-----\n".chunk_split($cert,64,"\n")."-----END CERTIFICATE-----\n";}
|
||||
public function verify(string $jws,string $accountToken): array {
|
||||
$rootPath=config('tarnax.apple.root_certificate');abort_unless($rootPath && is_file($rootPath),503,'Apple verification is not configured.');
|
||||
$parts=explode('.',$jws);abort_unless(count($parts)===3,422);
|
||||
$header=json_decode($this->decode($parts[0]),true,512,JSON_THROW_ON_ERROR);
|
||||
abort_unless(($header['alg']??'')==='ES256' && count($header['x5c']??[])>=2 && count($header['x5c'])<=3,422);
|
||||
$certs=array_map(fn($c)=>$this->pem($c),$header['x5c']);$root=file_get_contents($rootPath);
|
||||
foreach($certs as $index=>$cert){
|
||||
$parsed=openssl_x509_parse($cert);abort_unless($parsed && $parsed['validFrom_time_t']<=time() && $parsed['validTo_time_t']>=time(),422);
|
||||
$issuer=$certs[$index+1]??$root;
|
||||
abort_unless(openssl_x509_verify($cert,openssl_pkey_get_public($issuer))===1,422,'Invalid Apple certificate chain.');
|
||||
}
|
||||
$leaf=openssl_x509_parse($certs[0]);abort_unless(isset($leaf['extensions']['1.2.840.113635.100.6.11.1']),422,'Not an Apple transaction signing certificate.');
|
||||
$sig=$this->decode($parts[2]);abort_unless(strlen($sig)===64,422);
|
||||
$der='';foreach([substr($sig,0,32),substr($sig,32)] as $integer){$integer=ltrim($integer,"\0");if($integer==='' || (ord($integer[0])&128)){$integer="\0".$integer;}$der.="\x02".chr(strlen($integer)).$integer;}
|
||||
$der="\x30".chr(strlen($der)).$der;
|
||||
abort_unless(openssl_verify($parts[0].'.'.$parts[1],$der,$certs[0],OPENSSL_ALGO_SHA256)===1,422,'Invalid transaction signature.');
|
||||
$claims=json_decode($this->decode($parts[1]),true,512,JSON_THROW_ON_ERROR);
|
||||
abort_unless(config('tarnax.apple.bundle_id') && config('tarnax.apple.product_id'),503);
|
||||
abort_unless(($claims['bundleId']??'')===config('tarnax.apple.bundle_id') && ($claims['productId']??'')===config('tarnax.apple.product_id') && ($claims['environment']??'')===config('tarnax.apple.environment') && strtolower($claims['appAccountToken']??'')===strtolower($accountToken),422,'Transaction does not match this account or product.');
|
||||
abort_if(isset($claims['revocationDate']) || (($claims['expiresDate']??PHP_INT_MAX)<time()*1000),403,'Purchase is no longer valid.');
|
||||
abort_unless(isset($claims['transactionId'],$claims['signedDate']) && $claims['signedDate']<=time()*1000+300000 && $claims['signedDate']>=time()*1000-86400000,422,'A recent signed transaction is required.');
|
||||
return $claims;
|
||||
}
|
||||
class AppleVerifier
|
||||
{
|
||||
private function json(string $data): array
|
||||
{
|
||||
try {
|
||||
$result = json_decode($data, true, 512, JSON_THROW_ON_ERROR);
|
||||
} catch (\JsonException) {
|
||||
abort(422, 'Invalid signed JSON.');
|
||||
}abort_unless(is_array($result), 422);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function decode(string $data): string
|
||||
{
|
||||
$result = base64_decode(strtr($data, '-_', '+/'), true);
|
||||
abort_unless($result !== false, 422);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function pem(string $cert): string
|
||||
{
|
||||
return "-----BEGIN CERTIFICATE-----\n".chunk_split($cert, 64, "\n")."-----END CERTIFICATE-----\n";
|
||||
}
|
||||
|
||||
public function envelope(string $jws): array
|
||||
{
|
||||
$rootPath = config('tarnax.apple.root_certificate');
|
||||
abort_unless($rootPath && is_file($rootPath), 503, 'Apple verification is not configured.');
|
||||
$parts = explode('.', $jws);
|
||||
abort_unless(count($parts) === 3, 422);
|
||||
$header = $this->json($this->decode($parts[0]));
|
||||
abort_unless(($header['alg'] ?? '') === 'ES256' && count($header['x5c'] ?? []) >= 2 && count($header['x5c']) <= 3, 422);
|
||||
$certs = array_map(fn ($c) => $this->pem($c), $header['x5c']);
|
||||
$root = file_get_contents($rootPath);
|
||||
foreach ($certs as $index => $cert) {
|
||||
$parsed = openssl_x509_parse($cert);
|
||||
abort_unless($parsed && $parsed['validFrom_time_t'] <= time() && $parsed['validTo_time_t'] >= time(), 422);
|
||||
$issuer = $certs[$index + 1] ?? $root;
|
||||
abort_unless(openssl_x509_verify($cert, openssl_pkey_get_public($issuer)) === 1, 422, 'Invalid Apple certificate chain.');
|
||||
}
|
||||
$untrusted = tempnam(sys_get_temp_dir(), 'tarnax-apple-');
|
||||
try {
|
||||
file_put_contents($untrusted, implode("\n", array_slice($certs, 1)));
|
||||
$trusted = openssl_x509_checkpurpose($certs[0], X509_PURPOSE_ANY, [$rootPath], $untrusted);
|
||||
abort_unless($trusted === true || $trusted === 1, 422, 'Invalid certificate constraints.');
|
||||
} finally {
|
||||
unlink($untrusted);
|
||||
}
|
||||
$leaf = openssl_x509_parse($certs[0]);
|
||||
abort_unless(isset($leaf['extensions']['1.2.840.113635.100.6.11.1']), 422, 'Not an Apple transaction signing certificate.');
|
||||
$sig = $this->decode($parts[2]);
|
||||
abort_unless(strlen($sig) === 64, 422);
|
||||
$der = '';
|
||||
foreach ([substr($sig, 0, 32), substr($sig, 32)] as $integer) {
|
||||
$integer = ltrim($integer, "\0");
|
||||
if ($integer === '' || (ord($integer[0]) & 128)) {
|
||||
$integer = "\0".$integer;
|
||||
}$der .= "\x02".chr(strlen($integer)).$integer;
|
||||
}
|
||||
$der = "\x30".chr(strlen($der)).$der;
|
||||
abort_unless(openssl_verify($parts[0].'.'.$parts[1], $der, $certs[0], OPENSSL_ALGO_SHA256) === 1, 422, 'Invalid transaction signature.');
|
||||
$claims = $this->json($this->decode($parts[1]));
|
||||
abort_unless(isset($claims['signedDate']) && $claims['signedDate'] <= time() * 1000 + 300000, 422);
|
||||
|
||||
return $claims;
|
||||
}
|
||||
|
||||
public function transaction(string $jws): array
|
||||
{
|
||||
$claims = $this->envelope($jws);
|
||||
abort_unless(config('tarnax.apple.bundle_id') && config('tarnax.apple.product_id'), 503);
|
||||
abort_unless(($claims['bundleId'] ?? '') === config('tarnax.apple.bundle_id') && ($claims['productId'] ?? '') === config('tarnax.apple.product_id') && ($claims['environment'] ?? '') === config('tarnax.apple.environment') && isset($claims['transactionId']), 422, 'Transaction does not match this app.');
|
||||
|
||||
return $claims;
|
||||
}
|
||||
|
||||
public function verify(string $jws, string $accountToken): array
|
||||
{
|
||||
$claims = $this->transaction($jws);
|
||||
abort_unless($accountToken !== '' && strtolower($claims['appAccountToken'] ?? '') === strtolower($accountToken), 422, 'Transaction does not match this account.');
|
||||
abort_if(isset($claims['revocationDate']) || (($claims['expiresDate'] ?? PHP_INT_MAX) < time() * 1000), 403, 'Purchase is no longer valid.');
|
||||
abort_unless($claims['signedDate'] >= time() * 1000 - 86400000,422,'A recent signed transaction is required.');
|
||||
|
||||
return $claims;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,34 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
class LicenseService {
|
||||
public function issue(int $userId,int $purchaseId): void {
|
||||
if(DB::table('licenses')->where('purchase_id',$purchaseId)->exists()){return;}
|
||||
$key='TNX-'.Str::upper(bin2hex(random_bytes(20)));
|
||||
DB::table('licenses')->insert(['user_id'=>$userId,'purchase_id'=>$purchaseId,'key_hash'=>hash('sha256',$key),'key_encrypted'=>Crypt::encryptString($key),'status'=>'active','device_limit'=>2,'created_at'=>now(),'updated_at'=>now()]);
|
||||
}
|
||||
public function activate(string $key,string $device): array {
|
||||
return DB::transaction(function()use($key,$device){
|
||||
$license=DB::table('licenses')->where('key_hash',hash('sha256',$key))->lockForUpdate()->first();
|
||||
abort_unless($license && $license->status==='active',403,'Invalid or revoked license.');
|
||||
$deviceHash=hash('sha256',$device);$existing=DB::table('activations')->where('license_id',$license->id)->where('device_hash',$deviceHash)->first();
|
||||
abort_if(!$existing && DB::table('activations')->where('license_id',$license->id)->count()>=$license->device_limit,409,'Device limit reached.');
|
||||
$token=bin2hex(random_bytes(32));
|
||||
DB::table('activations')->updateOrInsert(['license_id'=>$license->id,'device_hash'=>$deviceHash],['token_hash'=>hash('sha256',$token),'last_seen_at'=>now(),'created_at'=>now(),'updated_at'=>now()]);
|
||||
return ['activation_token'=>$token,'valid'=>true,'check_again_in'=>86400];
|
||||
},3);
|
||||
}
|
||||
public function check(string $token,bool $deactivate=false): array {
|
||||
return DB::transaction(function()use($token,$deactivate){
|
||||
$activation=DB::table('activations')->where('token_hash',hash('sha256',$token))->first();
|
||||
abort_unless($activation,403,'Invalid activation.');
|
||||
$license=DB::table('licenses')->where('id',$activation->license_id)->lockForUpdate()->first();
|
||||
if($deactivate){DB::table('activations')->where('id',$activation->id)->delete();return ['deactivated'=>true];}
|
||||
abort_unless($license->status==='active',403,'License revoked.');
|
||||
DB::table('activations')->where('id',$activation->id)->update(['last_seen_at'=>now()]);
|
||||
return ['valid'=>true,'check_again_in'=>86400];
|
||||
},3);
|
||||
}
|
||||
|
||||
class LicenseService
|
||||
{
|
||||
public function issue(int $userId, int $purchaseId): void
|
||||
{
|
||||
if (DB::table('licenses')->where('purchase_id', $purchaseId)->exists()) {
|
||||
return;
|
||||
}
|
||||
$key = 'TNX-'.Str::upper(bin2hex(random_bytes(20)));
|
||||
DB::table('licenses')->insert(['user_id' => $userId, 'purchase_id' => $purchaseId, 'key_hash' => hash('sha256', $key), 'key_encrypted' => Crypt::encryptString($key), 'status' => 'active', 'device_limit' => 2, 'created_at' => now(), 'updated_at' => now()]);
|
||||
}
|
||||
|
||||
public function activate(string $key, string $device): array
|
||||
{
|
||||
return DB::transaction(function () use ($key, $device) {
|
||||
$license = DB::table('licenses')->where('key_hash', hash('sha256', $key))->lockForUpdate()->first();
|
||||
abort_unless($license && $license->status === 'active', 403, 'Invalid or revoked license.');
|
||||
$deviceHash = hash('sha256', $device);
|
||||
$existing = DB::table('activations')->where('license_id', $license->id)->where('device_hash', $deviceHash)->first();
|
||||
abort_if(! $existing && DB::table('activations')->where('license_id', $license->id)->count() >= $license->device_limit, 409, 'Device limit reached.');
|
||||
$token = bin2hex(random_bytes(32));
|
||||
DB::table('activations')->updateOrInsert(['license_id' => $license->id, 'device_hash' => $deviceHash], ['token_hash' => hash('sha256', $token), 'last_seen_at' => now(), 'created_at' => now(), 'updated_at' => now()]);
|
||||
|
||||
return ['activation_token' => $token, 'valid' => true, 'check_again_in' => 86400];
|
||||
}, 3);
|
||||
}
|
||||
|
||||
public function check(string $token, bool $deactivate = false): array
|
||||
{
|
||||
return DB::transaction(function () use ($token, $deactivate) {
|
||||
$activation = DB::table('activations')->where('token_hash', hash('sha256', $token))->first();
|
||||
abort_unless($activation, 403, 'Invalid activation.');
|
||||
$license = DB::table('licenses')->where('id', $activation->license_id)->lockForUpdate()->first();
|
||||
if ($deactivate) {
|
||||
DB::table('activations')->where('id', $activation->id)->delete();
|
||||
|
||||
return ['deactivated' => true];
|
||||
}
|
||||
abort_unless($license->status === 'active', 403, 'License revoked.');
|
||||
DB::table('activations')->where('id', $activation->id)->update(['last_seen_at' => now()]);
|
||||
|
||||
return ['valid' => true, 'check_again_in' => 86400];
|
||||
}, 3);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user