new app store assets ready for relese

This commit is contained in:
Mark Gregory
2026-11-05 09:02:29 +00:00
parent db6c3488f1
commit 95bf13a129
35 changed files with 1516 additions and 506 deletions
+1 -1
View File
@@ -1 +1 @@
{"version":2,"defects":{"Tests\\Feature\\CommerceTest::test_registration_cannot_grant_administration":7,"Tests\\Feature\\CommerceTest::test_checkout_uses_server_selected_product_and_customer":7,"Tests\\Feature\\CommerceTest::test_unconfigured_checkout_fails_closed":7,"Tests\\Feature\\CommerceTest::test_consent_records_explicit_choices":7,"Tests\\Feature\\CommerceTest::test_apple_verification_fails_closed_without_root":7},"times":{"Tests\\Unit\\ExampleTest::test_that_true_is_true":0.001,"Tests\\Feature\\CommerceTest::test_public_pages_and_authentication_views_render":0.021,"Tests\\Feature\\CommerceTest::test_registration_cannot_grant_administration":0.012,"Tests\\Feature\\CommerceTest::test_verified_payment_issues_only_one_license_on_retries":0.009,"Tests\\Feature\\CommerceTest::test_invalid_signature_and_stale_replay_never_grant_licenses":0.002,"Tests\\Feature\\CommerceTest::test_wrong_product_does_not_grant_license":0.001,"Tests\\Feature\\CommerceTest::test_unknown_customer_rolls_back_event_for_retry":0.002,"Tests\\Feature\\CommerceTest::test_activation_limits_validation_and_deactivation":0.005,"Tests\\Feature\\CommerceTest::test_refund_revokes_license_and_prevents_regrant":0.003,"Tests\\Feature\\CommerceTest::test_checkout_uses_server_selected_product_and_customer":0.017,"Tests\\Feature\\CommerceTest::test_unconfigured_checkout_fails_closed":0.003,"Tests\\Feature\\CommerceTest::test_downloads_require_license_signature_and_matching_customer":0.01,"Tests\\Feature\\CommerceTest::test_support_is_scoped_and_does_not_accept_another_ticket":0.007,"Tests\\Feature\\CommerceTest::test_customer_can_create_support_ticket":0.005,"Tests\\Feature\\CommerceTest::test_admin_requires_role_and_confirmed_two_factor":0.004,"Tests\\Feature\\CommerceTest::test_admin_can_edit_cms_and_mutations_are_audited":0.004,"Tests\\Feature\\CommerceTest::test_consent_records_explicit_choices":0.001,"Tests\\Feature\\CommerceTest::test_apple_verification_fails_closed_without_root":0.002,"Tests\\Feature\\CommerceTest::test_account_support_and_security_render_for_verified_customer":0.004,"Tests\\Feature\\CommerceTest::test_erasure_revokes_access_and_anonymises_profile":0.006,"Tests\\Feature\\ExampleTest::test_the_application_returns_a_successful_response":0.001}}
{"version":2,"defects":{"Tests\\Feature\\CommerceTest::test_registration_cannot_grant_administration":7,"Tests\\Feature\\CommerceTest::test_checkout_uses_server_selected_product_and_customer":7,"Tests\\Feature\\CommerceTest::test_unconfigured_checkout_fails_closed":7,"Tests\\Feature\\CommerceTest::test_consent_records_explicit_choices":7,"Tests\\Feature\\CommerceTest::test_apple_verification_fails_closed_without_root":7,"Tests\\Feature\\CommerceTest::test_unverified_accounts_cannot_checkout_or_open_portal":7},"times":{"Tests\\Unit\\ExampleTest::test_that_true_is_true":0,"Tests\\Feature\\CommerceTest::test_public_pages_and_authentication_views_render":0.034,"Tests\\Feature\\CommerceTest::test_registration_cannot_grant_administration":0.015,"Tests\\Feature\\CommerceTest::test_verified_payment_issues_only_one_license_on_retries":0.014,"Tests\\Feature\\CommerceTest::test_invalid_signature_and_stale_replay_never_grant_licenses":0.006,"Tests\\Feature\\CommerceTest::test_wrong_product_does_not_grant_license":0.004,"Tests\\Feature\\CommerceTest::test_unknown_customer_rolls_back_event_for_retry":0.005,"Tests\\Feature\\CommerceTest::test_activation_limits_validation_and_deactivation":0.015,"Tests\\Feature\\CommerceTest::test_refund_revokes_license_and_prevents_regrant":0.012,"Tests\\Feature\\CommerceTest::test_checkout_uses_server_selected_product_and_customer":0.014,"Tests\\Feature\\CommerceTest::test_unconfigured_checkout_fails_closed":0.004,"Tests\\Feature\\CommerceTest::test_downloads_require_license_signature_and_matching_customer":0.021,"Tests\\Feature\\CommerceTest::test_support_is_scoped_and_does_not_accept_another_ticket":0.012,"Tests\\Feature\\CommerceTest::test_customer_can_create_support_ticket":0.012,"Tests\\Feature\\CommerceTest::test_admin_requires_role_and_confirmed_two_factor":0.009,"Tests\\Feature\\CommerceTest::test_admin_can_edit_cms_and_mutations_are_audited":0.011,"Tests\\Feature\\CommerceTest::test_consent_records_explicit_choices":0.003,"Tests\\Feature\\CommerceTest::test_apple_verification_fails_closed_without_root":0.005,"Tests\\Feature\\CommerceTest::test_account_support_and_security_render_for_verified_customer":0.01,"Tests\\Feature\\CommerceTest::test_erasure_revokes_access_and_anonymises_profile":0.015,"Tests\\Feature\\ExampleTest::test_the_application_returns_a_successful_response":0.008,"Tests\\Feature\\CommerceTest::test_refund_before_purchase_prevents_late_grant":0.006,"Tests\\Feature\\CommerceTest::test_subscription_events_revoke_and_restore_and_ignore_stale_events":0.013,"Tests\\Feature\\CommerceTest::test_admin_can_manage_navigation_and_reject_external_redirects":0.026,"Tests\\Feature\\CommerceTest::test_published_page_html_is_escaped":0.004,"Tests\\Feature\\CommerceTest::test_apple_notification_fails_closed_without_configuration":0.003,"Tests\\Feature\\CommerceTest::test_erased_customer_cannot_use_an_old_activation_token":0.02,"Tests\\Feature\\CommerceTest::test_unverified_accounts_cannot_checkout_or_open_portal":0.007,"Tests\\Feature\\CommerceTest::test_private_media_is_safely_served":0.003}}
@@ -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);
}
}
+181 -48
View File
@@ -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()]);
}
}
+89 -28
View File
@@ -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]);
}
}
+14 -2
View File
@@ -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);
+89 -26
View File
@@ -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);
}
}
+5 -2
View File
@@ -1,6 +1,9 @@
<?php
use App\Providers\AppServiceProvider;
use App\Providers\FortifyServiceProvider;
return [
App\Providers\AppServiceProvider::class,
App\Providers\FortifyServiceProvider::class,
AppServiceProvider::class,
FortifyServiceProvider::class,
];
+4 -3
View File
@@ -1,6 +1,7 @@
<?php
return [
'paddle' => ['environment'=>env('PADDLE_ENVIRONMENT','sandbox'),'api_key'=>env('PADDLE_API_KEY'),'client_token'=>env('PADDLE_CLIENT_TOKEN'),'price_id'=>env('PADDLE_PRICE_ID'),'webhook_secret'=>env('PADDLE_WEBHOOK_SECRET')],
'apple'=>['url'=>env('APPLE_STORE_URL'),'bundle_id'=>env('APPLE_BUNDLE_ID'),'product_id'=>env('APPLE_PRODUCT_ID'),'environment'=>env('APPLE_ENVIRONMENT','Sandbox'),'root_certificate'=>env('APPLE_ROOT_CERTIFICATE')],
'support_email'=>env('SUPPORT_EMAIL'),'consent_version'=>'2026-11-04',
'paddle' => ['environment' => env('PADDLE_ENVIRONMENT', 'sandbox'), 'api_key' => env('PADDLE_API_KEY'), 'client_token' => env('PADDLE_CLIENT_TOKEN'), 'price_id' => env('PADDLE_PRICE_ID'), 'webhook_secret' => env('PADDLE_WEBHOOK_SECRET')],
'apple' => ['url' => env('APPLE_STORE_URL'), 'bundle_id' => env('APPLE_BUNDLE_ID'), 'product_id' => env('APPLE_PRODUCT_ID'), 'environment' => env('APPLE_ENVIRONMENT', 'Sandbox'), 'root_certificate' => env('APPLE_ROOT_CERTIFICATE')],
'support_email' => env('SUPPORT_EMAIL'), 'consent_version' => '2026-11-04',
];
@@ -1,22 +1,123 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void {
Schema::table('users',function(Blueprint $t){$t->boolean('is_admin')->default(false);$t->uuid('account_token')->nullable()->unique();});
Schema::create('pages',function(Blueprint $t){$t->id();$t->string('slug')->unique();$t->string('title');$t->text('body');$t->string('description')->nullable();$t->boolean('published')->default(false);$t->timestamps();});
Schema::create('settings',function(Blueprint $t){$t->string('key')->primary();$t->text('value')->nullable();});
Schema::create('releases',function(Blueprint $t){$t->id();$t->string('version')->unique();$t->string('channel')->default('stable');$t->string('platform')->default('macOS');$t->text('notes');$t->string('path');$t->string('sha256',64);$t->unsignedBigInteger('bytes');$t->boolean('published')->default(false);$t->timestamps();});
Schema::create('purchases',function(Blueprint $t){$t->id();$t->foreignId('user_id')->constrained()->restrictOnDelete();$t->string('provider');$t->string('transaction_id')->unique();$t->string('subscription_id')->nullable()->index();$t->string('status');$t->unsignedBigInteger('total')->default(0);$t->string('currency',3)->default('USD');$t->timestamp('provider_updated_at')->nullable();$t->timestamps();});
Schema::create('licenses',function(Blueprint $t){$t->id();$t->foreignId('user_id')->constrained()->cascadeOnDelete();$t->foreignId('purchase_id')->unique()->constrained()->cascadeOnDelete();$t->string('key_hash',64)->unique();$t->text('key_encrypted');$t->string('status')->default('active');$t->unsignedInteger('device_limit')->default(2);$t->timestamps();});
Schema::create('activations',function(Blueprint $t){$t->id();$t->foreignId('license_id')->constrained()->cascadeOnDelete();$t->string('device_hash',64);$t->string('token_hash',64)->unique();$t->timestamp('last_seen_at');$t->timestamps();$t->unique(['license_id','device_hash']);});
Schema::create('webhook_events',function(Blueprint $t){$t->string('id')->primary();$t->string('type');$t->timestamp('processed_at');});
Schema::create('tickets',function(Blueprint $t){$t->id();$t->foreignId('user_id')->constrained()->cascadeOnDelete();$t->string('subject');$t->string('status')->default('open');$t->timestamps();});
Schema::create('ticket_messages',function(Blueprint $t){$t->id();$t->foreignId('ticket_id')->constrained()->cascadeOnDelete();$t->foreignId('user_id')->constrained()->cascadeOnDelete();$t->text('body');$t->timestamps();});
Schema::create('consents',function(Blueprint $t){$t->id();$t->uuid('visitor_id')->index();$t->foreignId('user_id')->nullable()->constrained()->nullOnDelete();$t->string('policy_version');$t->boolean('analytics');$t->boolean('marketing');$t->timestamps();});
Schema::create('privacy_requests',function(Blueprint $t){$t->id();$t->foreignId('user_id')->constrained()->cascadeOnDelete();$t->string('type');$t->string('status')->default('pending');$t->timestamps();});
Schema::create('audit_logs',function(Blueprint $t){$t->id();$t->foreignId('user_id')->nullable()->constrained()->nullOnDelete();$t->string('action');$t->string('resource');$t->timestamps();});
}
public function down(): void {foreach(['audit_logs','privacy_requests','consents','ticket_messages','tickets','webhook_events','activations','licenses','purchases','releases','settings','pages'] as $table){Schema::dropIfExists($table);}Schema::table('users',function(Blueprint $t){$t->dropColumn(['is_admin','account_token']);});}
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $t) {
$t->boolean('is_admin')->default(false);
$t->uuid('account_token')->nullable()->unique();
});
Schema::create('pages', function (Blueprint $t) {
$t->id();
$t->string('slug')->unique();
$t->string('title');
$t->text('body');
$t->string('description')->nullable();
$t->boolean('published')->default(false);
$t->timestamps();
});
Schema::create('settings', function (Blueprint $t) {
$t->string('key')->primary();
$t->text('value')->nullable();
});
Schema::create('releases', function (Blueprint $t) {
$t->id();
$t->string('version')->unique();
$t->string('channel')->default('stable');
$t->string('platform')->default('macOS');
$t->text('notes');
$t->string('path');
$t->string('sha256', 64);
$t->unsignedBigInteger('bytes');
$t->boolean('published')->default(false);
$t->timestamps();
});
Schema::create('purchases', function (Blueprint $t) {
$t->id();
$t->foreignId('user_id')->constrained()->restrictOnDelete();
$t->string('provider');
$t->string('transaction_id')->unique();
$t->string('subscription_id')->nullable()->index();
$t->string('status');
$t->unsignedBigInteger('total')->default(0);
$t->string('currency', 3)->default('USD');
$t->timestamp('provider_updated_at')->nullable();
$t->timestamps();
});
Schema::create('licenses', function (Blueprint $t) {
$t->id();
$t->foreignId('user_id')->constrained()->cascadeOnDelete();
$t->foreignId('purchase_id')->unique()->constrained()->cascadeOnDelete();
$t->string('key_hash', 64)->unique();
$t->text('key_encrypted');
$t->string('status')->default('active');
$t->unsignedInteger('device_limit')->default(2);
$t->timestamps();
});
Schema::create('activations', function (Blueprint $t) {
$t->id();
$t->foreignId('license_id')->constrained()->cascadeOnDelete();
$t->string('device_hash', 64);
$t->string('token_hash', 64)->unique();
$t->timestamp('last_seen_at');
$t->timestamps();
$t->unique(['license_id', 'device_hash']);
});
Schema::create('webhook_events', function (Blueprint $t) {
$t->string('id')->primary();
$t->string('type');
$t->timestamp('processed_at');
});
Schema::create('tickets', function (Blueprint $t) {
$t->id();
$t->foreignId('user_id')->constrained()->cascadeOnDelete();
$t->string('subject');
$t->string('status')->default('open');
$t->timestamps();
});
Schema::create('ticket_messages', function (Blueprint $t) {
$t->id();
$t->foreignId('ticket_id')->constrained()->cascadeOnDelete();
$t->foreignId('user_id')->constrained()->cascadeOnDelete();
$t->text('body');
$t->timestamps();
});
Schema::create('consents', function (Blueprint $t) {
$t->id();
$t->uuid('visitor_id')->index();
$t->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
$t->string('policy_version');
$t->boolean('analytics');
$t->boolean('marketing');
$t->timestamps();
});
Schema::create('privacy_requests', function (Blueprint $t) {
$t->id();
$t->foreignId('user_id')->constrained()->cascadeOnDelete();
$t->string('type');
$t->string('status')->default('pending');
$t->timestamps();
});
Schema::create('audit_logs', function (Blueprint $t) {
$t->id();
$t->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
$t->string('action');
$t->string('resource');
$t->timestamps();
});
}
public function down(): void
{
foreach (['audit_logs', 'privacy_requests', 'consents', 'ticket_messages', 'tickets', 'webhook_events', 'activations', 'licenses', 'purchases', 'releases', 'settings', 'pages'] as $table) {
Schema::dropIfExists($table);
}Schema::table('users', function (Blueprint $t) {
$t->dropColumn(['is_admin', 'account_token']);
});
}
};
@@ -6,19 +6,56 @@ use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
//
Schema::create('payment_locks', function (Blueprint $t) {
$t->string('id')->primary();
});
Schema::create('refund_blocks', function (Blueprint $t) {
$t->string('transaction_id')->primary();
$t->timestamp('occurred_at');
});
Schema::create('subscription_states', function (Blueprint $t) {
$t->string('id')->primary();
$t->string('status');
$t->timestamp('occurred_at');
});
Schema::create('navigation', function (Blueprint $t) {
$t->id();
$t->string('label');
$t->string('path');
$t->unsignedInteger('position')->default(0);
$t->boolean('published')->default(true);
$t->timestamps();
});
Schema::create('media', function (Blueprint $t) {
$t->id();
$t->string('title');
$t->string('alt');
$t->string('path');
$t->string('mime');
$t->unsignedBigInteger('bytes');
$t->timestamps();
});
Schema::create('redirects', function (Blueprint $t) {
$t->id();
$t->string('source')->unique();
$t->string('destination');
$t->timestamps();
});
Schema::create('email_templates', function (Blueprint $t) {
$t->id();
$t->string('slug')->unique();
$t->string('subject');
$t->text('body');
$t->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
//
foreach (['payment_locks', 'email_templates', 'redirects', 'media', 'navigation', 'subscription_states', 'refund_blocks'] as $table) {
Schema::dropIfExists($table);
}
}
};
@@ -1,17 +1,26 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
class DatabaseSeeder extends Seeder {
public function run(): void {
$pages=[
'features'=>['Built around your server','Tarnax is a native remote for your own qBittorrent server. Monitor transfers, organise your library and control file priorities. Requires the qBittorrent Web UI API and a trusted HTTPS connection. Credentials are managed by the native app.'],
'privacy'=>['Privacy notice — template','DRAFT — replace and review before publishing.\n\nController: [legal entity, address, contact]. We process account details, purchase references, support messages, device identifier hashes and consent choices to deliver software, secure accounts and provide support. Specify the lawful basis for each purpose.\n\nPaddle processes direct payments as merchant of record. Apple handles App Store purchases. Identify hosting, mail and other processors, international transfers and safeguards. We do not receive payment card details.\n\nSet retention periods for accounts, support, security and billing records. Explain access, correction, erasure, restriction, portability, objection and complaint rights, and name the supervisory authority. Use your account to export data or request deletion. Specify response deadlines and contact.'],
'terms'=>['Terms of use — template','DRAFT — legal review required.\n\nProvider: [legal name and address]. Describe supported software, system requirements, price, tax, license scope, device limit, permitted uses, updates, support and termination. Use only content you are authorised to access and share.\n\nDirect purchases are processed by Paddle. App Store purchases are subject to Apple’s applicable terms. Distribution entitlements are separate. State applicable consumer rights, warranties, liability limits, governing law and dispute process without excluding mandatory rights.'],
'refunds'=>['Refund policy — template','DRAFT — legal review required.\n\nFor direct purchases contact [support address] with your transaction reference. State the applicable refund period, eligibility and process in agreement with Paddle merchant terms and mandatory consumer rights. Explain digital-content cancellation consent where required. Approved refunds revoke direct licenses. App Store refunds are requested through Apple.'],
'cookies'=>['Cookie policy — template','DRAFT — review cookie inventory and retention periods.\n\nEssential session and CSRF cookies protect your account. Tarnax preference and visitor cookies remember choices and record consent for up to one year. Optional analytics and marketing are disabled by default. No analytics or advertising scripts ship in this project. Change or withdraw choices at any time using Cookie preferences. Document any trackers before enabling them.']
];
foreach($pages as $slug=>[$title,$body]){DB::table('pages')->insertOrIgnore(['slug'=>$slug,'title'=>$title,'body'=>str_replace('\\n',"\n",$body),'description'=>$title,'published'=>true,'created_at'=>now(),'updated_at'=>now()]);}
foreach(['headline'=>'Your server. Your control.','tagline'=>'A native iPhone and iPad remote for your own qBittorrent server. Follow live transfers, organise your library and manage the details from wherever you are.','price_label'=>'Pricing coming soon'] as $key=>$value){DB::table('settings')->insertOrIgnore(compact('key','value'));}
}
class DatabaseSeeder extends Seeder
{
public function run(): void
{
$pages = [
'features' => ['Built around your server', 'Tarnax is a native remote for your own qBittorrent server. Monitor transfers, organise your library and control file priorities. Requires the qBittorrent Web UI API and a trusted HTTPS connection. Credentials are managed by the native app.'],
'privacy' => ['Privacy notice — template', 'DRAFT — replace and review before publishing.\n\nController: [legal entity, address, contact]. We process account details, purchase references, support messages, device identifier hashes and consent choices to deliver software, secure accounts and provide support. Specify the lawful basis for each purpose.\n\nPaddle processes direct payments as merchant of record. Apple handles App Store purchases. Identify hosting, mail and other processors, international transfers and safeguards. We do not receive payment card details.\n\nSet retention periods for accounts, support, security and billing records. Explain access, correction, erasure, restriction, portability, objection and complaint rights, and name the supervisory authority. Use your account to export data or request deletion. Specify response deadlines and contact.'],
'terms' => ['Terms of use — template', 'DRAFT — legal review required.\n\nProvider: [legal name and address]. Describe supported software, system requirements, price, tax, license scope, device limit, permitted uses, updates, support and termination. Use only content you are authorised to access and share.\n\nDirect purchases are processed by Paddle. App Store purchases are subject to Apple’s applicable terms. Distribution entitlements are separate. State applicable consumer rights, warranties, liability limits, governing law and dispute process without excluding mandatory rights.'],
'refunds' => ['Refund policy — template', 'DRAFT — legal review required.\n\nFor direct purchases contact [support address] with your transaction reference. State the applicable refund period, eligibility and process in agreement with Paddle merchant terms and mandatory consumer rights. Explain digital-content cancellation consent where required. Approved refunds revoke direct licenses. App Store refunds are requested through Apple.'],
'cookies' => ['Cookie policy — template', 'DRAFT — review cookie inventory and retention periods.\n\nEssential session and CSRF cookies protect your account. Tarnax preference and visitor cookies remember choices and record consent for up to one year. Optional analytics and marketing are disabled by default. No analytics or advertising scripts ship in this project. Change or withdraw choices at any time using Cookie preferences. Document any trackers before enabling them.'],
];
foreach ($pages as $slug => [$title,$body]) {
DB::table('pages')->insertOrIgnore(['slug' => $slug, 'title' => $title, 'body' => str_replace('\\n', "\n", $body), 'description' => $title, 'published' => true, 'created_at' => now(), 'updated_at' => now()]);
}
foreach (['headline' => 'Your server. Your control.', 'tagline' => 'A native iPhone and iPad remote for your own qBittorrent server. Follow live transfers, organise your library and manage the details from wherever you are.', 'price_label' => 'Pricing coming soon'] as $key => $value) {
DB::table('settings')->insertOrIgnore(compact('key', 'value'));
}
}
}
+1
View File
@@ -18,6 +18,7 @@
</include>
</source>
<php>
<server force="true" name="APP_ENV" value="testing"/>
<env force="true" name="APP_ENV" value="testing"/>
<env force="true" name="APP_MAINTENANCE_DRIVER" value="file"/>
<env force="true" name="BCRYPT_ROUNDS" value="4"/>
@@ -1,6 +1,6 @@
@props(['title'=>'Tarnax — Your server. Your control.','description'=>'A native remote for your qBittorrent server.'])
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="csrf-token" content="{{ csrf_token() }}"><meta name="description" content="{{ $description }}"><title>{{ $title }}</title><link rel="icon" href="/brand/favicon.png">@vite(['resources/css/app.css','resources/js/app.js']) @livewireStyles</head>
<body><header class="border-b border-slate-800"><div class="max-w-7xl mx-auto px-6 py-5 flex flex-wrap items-center justify-between gap-5"><a href="{{ route('home') }}" class="flex items-center gap-3 text-xl font-bold text-white"><img src="/brand/icon.png" width="36" height="36" class="rounded-xl" alt="">tarnax<span class="text-xs font-normal text-slate-500">/ software, considered.</span></a><nav aria-label="Main navigation" class="flex flex-wrap gap-6 text-sm"><a href="{{ route('releases') }}">Downloads</a><a href="{{ route('page','features') }}">Features</a><a href="{{ route('support') }}">Support</a>@auth<a href="{{ route('account') }}">My account</a>@if(auth()->user()->is_admin)<a href="{{ route('admin') }}">Admin</a>@endif<form method="post" action="/logout">@csrf<button>Sign out</button></form>@else<a href="/login">Sign in ↗</a>@endauth</nav></div></header>
<body><header class="border-b border-slate-800"><div class="max-w-7xl mx-auto px-6 py-5 flex flex-wrap items-center justify-between gap-5"><a href="{{ route('home') }}" class="flex items-center gap-3 text-xl font-bold text-white"><img src="/brand/icon.png" width="36" height="36" class="rounded-xl" alt="">tarnax<span class="text-xs font-normal text-slate-500">/ software, considered.</span></a><nav aria-label="Main navigation" class="flex flex-wrap gap-6 text-sm">@foreach(\Illuminate\Support\Facades\DB::table('navigation')->where('published',true)->orderBy('position')->get() as $link)<a href="{{ $link->path }}">{{ $link->label }}</a>@endforeach<a href="{{ route('releases') }}">Downloads</a><a href="{{ route('page','features') }}">Features</a><a href="{{ route('support') }}">Support</a>@auth<a href="{{ route('account') }}">My account</a>@if(auth()->user()->is_admin)<a href="{{ route('admin') }}">Admin</a>@endif<form method="post" action="/logout">@csrf<button>Sign out</button></form>@else<a href="/login">Sign in ↗</a>@endauth</nav></div></header>
<main class="max-w-7xl mx-auto px-6 py-12 min-h-[70vh]">@if(session('status'))<div role="status" class="card mb-6 text-cyan-200">{{ session('status') }}</div>@endif @if($errors->any())<div role="alert" class="card mb-6 text-rose-300">{{ $errors->first() }}</div>@endif {{ $slot }}</main>
<footer class="border-t border-slate-800"><div class="max-w-7xl mx-auto p-6 flex flex-wrap gap-5 text-sm text-slate-400"><span class="mr-auto">© {{ date('Y') }} Tarnax · Independent software</span>@foreach(['privacy','terms','refunds','cookies'] as $slug)<a href="{{ route('page',$slug) }}">{{ ucfirst($slug) }}</a>@endforeach<button onclick="window.dispatchEvent(new Event('cookie-preferences'))">Cookie preferences</button></div><p class="max-w-7xl mx-auto px-6 pb-6 text-xs text-slate-500">Requires your own qBittorrent server. Use only content you are authorised to access and share. Not affiliated with the qBittorrent project.</p></footer>
<div x-data="{open: !document.cookie.includes('tarnax_consent='), analytics:false, marketing:false, error:'', async save(a,m){try{let r=await fetch('{{ route('consent') }}',{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-TOKEN':document.querySelector('meta[name=csrf-token]').content,'Accept':'application/json'},body:JSON.stringify({analytics:a,marketing:m})});if(!r.ok)throw Error();this.open=false;window.dispatchEvent(new Event('consent-changed'));}catch(e){this.error='Could not save preferences. Please try again.'}}}" @cookie-preferences.window="open=true" x-show="open" x-cloak role="dialog" aria-label="Cookie preferences" class="fixed bottom-4 left-4 right-4 md:left-auto md:w-[520px] card shadow-2xl z-50"><h3>Your privacy, your choice</h3><p class="muted text-sm my-3">Essential cookies keep your account secure. Optional cookies stay off until you choose them. No optional trackers are installed in this build.</p><label><input type="checkbox" x-model="analytics"> Analytics</label><label><input type="checkbox" x-model="marketing"> Marketing</label><div class="flex flex-wrap gap-2 mt-4"><button class="secondary" @click="save(false,false)">Reject optional</button><button class="secondary" @click="save(analytics,marketing)">Save choices</button><button class="button" @click="save(true,true)">Accept all</button></div><p x-text="error" role="alert"></p></div>
@@ -1,4 +1,12 @@
<div><div class="flex flex-wrap gap-4 mb-6"><label class="min-w-56">Manage<select wire:model.live="section">@foreach($sections as $name)<option value="{{ $name }}">{{ ucwords(str_replace('_',' ',$name)) }}</option>@endforeach</select></label><div class="card flex-1 text-sm muted">Protected by administrator access and confirmed 2FA. Showing the latest 100 records per section. Mutations are audited.</div></div>@if(session('status'))<p role="status" class="text-cyan-200 mb-4">{{ session('status') }}</p>@endif @if($errors->any())<p role="alert" class="text-rose-300 mb-4">{{ $errors->first() }}</p>@endif
@if(in_array($section,['pages','releases']))<form wire:submit="save" class="card grid md:grid-cols-2 gap-4 mb-8"><h2>{{ $editing?'Edit':'Create' }} {{ rtrim($section,'s') }}</h2>@foreach($section==='pages'?['slug','title','description']:['version','channel','platform'] as $field)<label>{{ ucfirst($field) }}<input wire:model="form.{{ $field }}"></label>@endforeach<label class="md:col-span-2">{{ $section==='pages'?'Body (plain text, safe by default)':'Release notes' }}<textarea rows="6" wire:model="form.{{ $section==='pages'?'body':'notes' }}"></textarea></label>@if($section==='releases')<label>Private binary (ZIP, DMG or PKG)<input type="file" wire:model="upload" accept=".zip,.dmg,.pkg"></label>@endif<label><input type="checkbox" wire:model="form.published"> Published</label><button class="button" wire:loading.attr="disabled">Save</button><button type="button" class="secondary" wire:click="newRecord">New record</button></form>@endif
@if($section==='settings')<form wire:submit="saveSettings" class="card mb-8 space-y-4"><h2>Storefront settings</h2>@foreach(['headline','tagline','price_label'] as $field)<label>{{ ucwords(str_replace('_',' ',$field)) }}<input wire:model="form.{{ $field }}" required></label>@endforeach<button class="button">Save settings</button></form>@endif
<div class="card overflow-x-auto"><table class="w-full text-left text-sm"><thead><tr>@if($rows->isNotEmpty())@foreach(array_keys((array)$rows->first()) as $key)<th class="p-3 text-slate-400">{{ $key }}</th>@endforeach<th>Actions</th>@endif</tr></thead><tbody>@forelse($rows as $row)<tr class="border-t border-slate-800">@foreach((array)$row as $key=>$value)<td class="p-3 max-w-64 break-words">{{ \Illuminate\Support\Str::limit((string)$value,160) }}</td>@endforeach<td class="p-3">@if(in_array($section,['pages','releases']))<button class="text-cyan-200" wire:click="edit({{ $row->id }})">Edit</button>@elseif($section==='licenses')<button class="text-rose-200" wire:confirm="Revoke this license?" wire:click="revoke({{ $row->id }})">Revoke</button>@elseif($section==='privacy_requests' && $row->status==='pending')<button wire:confirm="Erase customer profile and support data, revoke licenses, and retain billing records?" wire:click="fulfillDeletion({{ $row->id }})">Fulfill deletion</button>@endif</td></tr>@empty<tr><td class="p-5 muted">No records yet.</td></tr>@endforelse</tbody></table></div></div>
@if(in_array($section,['navigation','media','redirects','email_templates']))
<form wire:submit="saveResource" class="card mb-6 grid md:grid-cols-2 gap-4"><h2 class="md:col-span-2">Add {{ $section }}</h2>
@foreach(match($section){'navigation'=>['label','path','position'],'media'=>['title','alt'],'redirects'=>['source','destination'],'email_templates'=>['slug','subject','body']} as $field)
<label>{{ ucfirst($field) }}@if($field==='body')<textarea wire:model="form.{{ $field }}" required></textarea>@else<input wire:model="form.{{ $field }}" required>@endif</label>@endforeach
@if($section==='media')<label>Image<input type="file" wire:model="upload" accept="image/png,image/jpeg,image/webp"></label>@endif
@if($section==='navigation')<label><input type="checkbox" wire:model="form.published"> Published</label>@endif
<button class="button" wire:loading.attr="disabled">Add</button></form>@endif
@if($section==='licenses')<label class="max-w-xs">Device limit to apply<input type="number" wire:model="form.device_limit" min="1" max="100"></label>@endif
<div class="card overflow-x-auto"><table class="w-full text-left text-sm"><thead><tr>@if($rows->isNotEmpty())@foreach(array_keys((array)$rows->first()) as $key)<th class="p-3 text-slate-400">{{ $key }}</th>@endforeach<th>Actions</th>@endif</tr></thead><tbody>@forelse($rows as $row)<tr class="border-t border-slate-800">@foreach((array)$row as $key=>$value)<td class="p-3 max-w-64 break-words">{{ \Illuminate\Support\Str::limit((string)$value,160) }}</td>@endforeach<td class="p-3">@if(in_array($section,['pages','releases']))<button class="text-cyan-200" wire:click="edit({{ $row->id }})">Edit</button>@elseif(in_array($section,['navigation','media','redirects','email_templates']))<button wire:confirm="Delete this record?" wire:click="remove({{ $row->id }})">Delete</button>@elseif($section==='licenses')<button wire:click="setDeviceLimit({{ $row->id }})">Set limit</button> <button class="text-rose-200" wire:confirm="Revoke this license?" wire:click="revoke({{ $row->id }})">Revoke</button>@elseif($section==='privacy_requests' && $row->status==='pending')<button wire:confirm="Erase customer profile and support data, revoke licenses, and retain billing records?" wire:click="fulfillDeletion({{ $row->id }})">Fulfill deletion</button>@endif</td></tr>@empty<tr><td class="p-5 muted">No records yet.</td></tr>@endforelse</tbody></table></div></div>
+11 -6
View File
@@ -1,10 +1,15 @@
<?php
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\AppleNotificationController;
use App\Http\Controllers\LicenseController;
use App\Http\Controllers\PaddleController;
Route::post('/webhooks/paddle',[PaddleController::class,'webhook'])->middleware('throttle:120,1');
Route::prefix('v1/licenses')->middleware('throttle:license')->group(function(){
Route::post('activate',[LicenseController::class,'activate']);
Route::post('validate',[LicenseController::class,'validateLicense']);
Route::post('deactivate',[LicenseController::class,'deactivate']);
use Illuminate\Support\Facades\Route;
Route::post('/webhooks/paddle', [PaddleController::class, 'webhook'])->middleware('throttle:120,1');
Route::prefix('v1/licenses')->middleware('throttle:license')->group(function () {
Route::post('activate', [LicenseController::class, 'activate']);
Route::post('validate', [LicenseController::class, 'validateLicense']);
Route::post('deactivate', [LicenseController::class, 'deactivate']);
});
Route::post('/webhooks/apple', AppleNotificationController::class)->middleware('throttle:120,1');
+19 -5
View File
@@ -1,8 +1,22 @@
<?php
use Illuminate\Support\Facades\Artisan;
use App\Models\User;
Artisan::command('tarnax:admin {email}',function(){
$user=User::where('email',$this->argument('email'))->first();if(!$user){$this->error('Register and verify this account first.');return 1;}
if(!$user->hasVerifiedEmail()){$this->error('Verify the email address first.');return 1;}
$user->forceFill(['is_admin'=>true])->save();$this->info('Administrator granted. Confirm 2FA in account security before opening /admin.');return 0;
use Illuminate\Support\Facades\Artisan;
Artisan::command('tarnax:admin {email}', function () {
$user = User::where('email', $this->argument('email'))->first();
if (! $user) {
$this->error('Register and verify this account first.');
return 1;
}
if (! $user->hasVerifiedEmail()) {
$this->error('Verify the email address first.');
return 1;
}
$user->forceFill(['is_admin' => true])->save();
$this->info('Administrator granted. Confirm 2FA in account security before opening /admin.');
return 0;
})->purpose('Promote a verified customer to administrator');
+35 -16
View File
@@ -1,20 +1,39 @@
<?php
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\CommerceController as Shop;
use App\Http\Controllers\PaddleController;
Route::get('/',[Shop::class,'home'])->name('home');
Route::get('/pages/{slug}',[Shop::class,'page'])->name('page');
Route::get('/releases',[Shop::class,'releases'])->name('releases');
Route::post('/consent',[Shop::class,'consent'])->middleware('throttle:30,1')->name('consent');
Route::middleware(['auth','verified'])->group(function(){
Route::get('/account',[Shop::class,'account'])->name('account');
Route::view('/account/security','security')->name('security');
Route::get('/support',fn()=>view('support'))->name('support');
Route::post('/checkout',[PaddleController::class,'checkout'])->middleware('throttle:10,1')->name('checkout');
Route::post('/account/apple',[Shop::class,'apple'])->middleware('throttle:10,1')->name('apple.verify');
Route::get('/downloads/{release}',[Shop::class,'downloadLink'])->name('download.link');
Route::get('/download/{release}/{user}',[Shop::class,'download'])->middleware('signed')->name('download.file');
Route::get('/account/export',[Shop::class,'export'])->middleware('password.confirm')->name('privacy.export');
Route::post('/account/deletion',[Shop::class,'deletion'])->middleware('password.confirm')->name('privacy.delete');
Route::get('/admin',fn()=>view('admin'))->middleware([\App\Http\Middleware\RequireAdmin::class,'password.confirm'])->name('admin');
use App\Http\Middleware\RequireAdmin;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Storage;
Route::get('/', [Shop::class, 'home'])->name('home');
Route::get('/pages/{slug}', [Shop::class, 'page'])->name('page');
Route::get('/releases', [Shop::class, 'releases'])->name('releases');
Route::post('/consent', [Shop::class, 'consent'])->middleware('throttle:30,1')->name('consent');
Route::middleware(['auth', 'verified'])->group(function () {
Route::get('/account', [Shop::class, 'account'])->name('account');
Route::view('/account/security', 'security')->middleware('password.confirm')->name('security');
Route::get('/support', fn () => view('support'))->name('support');
Route::post('/checkout', [PaddleController::class, 'checkout'])->middleware('throttle:10,1')->name('checkout');
Route::post('/account/apple', [Shop::class, 'apple'])->middleware('throttle:10,1')->name('apple.verify');
Route::get('/downloads/{release}', [Shop::class, 'downloadLink'])->name('download.link');
Route::get('/download/{release}/{user}', [Shop::class, 'download'])->middleware('signed')->name('download.file');
Route::get('/account/export', [Shop::class, 'export'])->middleware('password.confirm')->name('privacy.export');
Route::post('/account/deletion', [Shop::class, 'deletion'])->middleware('password.confirm')->name('privacy.delete');
Route::get('/admin', fn () => view('admin'))->middleware([RequireAdmin::class, 'password.confirm'])->name('admin');
});
Route::get('/media/{id}', function (int $id) {
$file = DB::table('media')->find($id);
abort_unless($file, 404);
return Storage::disk('local')->response($file->path, null, ['Content-Type' => $file->mime, 'X-Content-Type-Options' => 'nosniff']);
})->name('media');
Route::fallback(function (Request $r) {
$redirect = DB::table('redirects')->where('source', '/'.$r->path())->first();
abort_unless($redirect, 404);
return redirect($redirect->destination,301);
});
+292 -57
View File
@@ -1,63 +1,298 @@
<?php
namespace Tests\Feature;
use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use App\Livewire\Admin\Cms;
use App\Livewire\SupportDesk;
use App\Models\User;
use App\Services\LicenseService;
use App\Livewire\SupportDesk;
use App\Livewire\Admin\Cms;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\Str;
use Livewire\Livewire;
class CommerceTest extends TestCase {
use RefreshDatabase;
protected function setUp(): void {parent::setUp();$this->seed();Notification::fake();config(['tarnax.paddle.webhook_secret'=>'test-secret','tarnax.paddle.price_id'=>'pri_test']);}
private function customer(array $attributes=[]): User {return User::factory()->create(['account_token'=>(string)Str::uuid(),...$attributes]);}
private function event(User $user,string $id='evt_1'): array {return ['event_id'=>$id,'event_type'=>'transaction.completed','occurred_at'=>now()->toISOString(),'data'=>['id'=>'txn_1','status'=>'completed','custom_data'=>['account_token'=>$user->account_token],'items'=>[['price'=>['id'=>'pri_test'],'quantity'=>1]],'details'=>['totals'=>['grand_total'=>'4900']],'currency_code'=>'USD']];}
private function deliver(array $event,?int $timestamp=null,?string $signature=null) {
$body=json_encode($event);$ts=$timestamp??time();$sig=$signature??hash_hmac('sha256',$ts.':'.$body,'test-secret');
return $this->call('POST','/api/webhooks/paddle',[],[],[],['CONTENT_TYPE'=>'application/json','HTTP_ACCEPT'=>'application/json','HTTP_PADDLE_SIGNATURE'=>'ts='.$ts.';h1='.$sig],$body);
}
private function license(User $user): object {$this->deliver($this->event($user))->assertOk();return DB::table('licenses')->first();}
public function test_public_pages_and_authentication_views_render(): void {foreach(['/','/releases','/pages/privacy','/pages/terms','/pages/features','/login','/register','/forgot-password'] as $url){$this->get($url)->assertOk();}$this->get('/account')->assertRedirect('/login');}
public function test_registration_cannot_grant_administration(): void {$this->post('/register',['name'=>'Customer','email'=>'new@example.com','password'=>'StrongPass123!','password_confirmation'=>'StrongPass123!','is_admin'=>true])->assertRedirect();$this->assertFalse(User::where('email','new@example.com')->first()->is_admin);}
public function test_verified_payment_issues_only_one_license_on_retries(): void {$user=$this->customer();$event=$this->event($user);$this->deliver($event)->assertOk();$this->deliver($event)->assertOk();$event['event_id']='evt_2';$this->deliver($event)->assertOk();$this->assertDatabaseCount('purchases',1);$this->assertDatabaseCount('licenses',1);}
public function test_invalid_signature_and_stale_replay_never_grant_licenses(): void {$user=$this->customer();$this->deliver($this->event($user),null,'invalid')->assertUnauthorized();$this->deliver($this->event($user),time()-600)->assertUnauthorized();$this->assertDatabaseCount('licenses',0);}
public function test_wrong_product_does_not_grant_license(): void {$event=$this->event($this->customer());$event['data']['items'][0]['price']['id']='pri_other';$this->deliver($event)->assertOk();$this->assertDatabaseCount('licenses',0);}
public function test_unknown_customer_rolls_back_event_for_retry(): void {$event=$this->event($this->customer());$event['data']['custom_data']['account_token']=(string)Str::uuid();$this->deliver($event)->assertUnprocessable();$this->assertDatabaseCount('webhook_events',0);}
public function test_activation_limits_validation_and_deactivation(): void {
$license=$this->license($this->customer());$key=Crypt::decryptString($license->key_encrypted);
$token=$this->postJson('/api/v1/licenses/activate',['license_key'=>$key,'device_id'=>str_repeat('a',20)])->assertOk()->json('activation_token');
$this->postJson('/api/v1/licenses/activate',['license_key'=>$key,'device_id'=>str_repeat('b',20)])->assertOk();
$this->postJson('/api/v1/licenses/activate',['license_key'=>$key,'device_id'=>str_repeat('c',20)])->assertConflict();
$this->withToken($token)->postJson('/api/v1/licenses/validate')->assertOk()->assertJson(['valid'=>true]);
$this->withToken($token)->postJson('/api/v1/licenses/deactivate')->assertOk();
$this->withToken($token)->postJson('/api/v1/licenses/validate')->assertForbidden();
}
public function test_refund_revokes_license_and_prevents_regrant(): void {
$user=$this->customer();$license=$this->license($user);$key=Crypt::decryptString($license->key_encrypted);$token=app(LicenseService::class)->activate($key,str_repeat('a',20))['activation_token'];
$refund=['event_id'=>'evt_refund','event_type'=>'adjustment.updated','occurred_at'=>now()->addSecond()->toISOString(),'data'=>['status'=>'approved','action'=>'refund','transaction_id'=>'txn_1']];$this->deliver($refund)->assertOk();
$this->withToken($token)->postJson('/api/v1/licenses/validate')->assertForbidden();$this->deliver($this->event($user,'evt_late'))->assertOk();$this->assertDatabaseHas('licenses',['status'=>'revoked']);
}
public function test_checkout_uses_server_selected_product_and_customer(): void {$user=$this->customer();config(['tarnax.paddle.api_key'=>'secret','tarnax.paddle.client_token'=>'token']);Http::fake(['*'=>Http::response(['data'=>['id'=>'txn_checkout']])]);$this->actingAs($user)->postJson('/checkout',['price_id'=>'attacker','custom_data'=>['account_token'=>'other']])->assertOk()->assertJson(['transaction_id'=>'txn_checkout']);Http::assertSent(fn($r)=>$r['items'][0]['price_id']==='pri_test' && $r['custom_data']['account_token']===$user->account_token);}
public function test_unconfigured_checkout_fails_closed(): void {config(['tarnax.paddle.api_key'=>null]);$this->actingAs($this->customer())->postJson('/checkout')->assertStatus(503);}
public function test_downloads_require_license_signature_and_matching_customer(): void {
Storage::fake('local');Storage::disk('local')->put('releases/app.zip','binary');$release=DB::table('releases')->insertGetId(['version'=>'1.0.0','channel'=>'stable','platform'=>'macOS','notes'=>'Release','path'=>'releases/app.zip','sha256'=>hash('sha256','binary'),'bytes'=>6,'published'=>true,'created_at'=>now(),'updated_at'=>now()]);
$user=$this->customer();$other=$this->customer();$this->actingAs($user)->get('/downloads/'.$release)->assertForbidden();$this->license($user);
$this->get('/downloads/'.$release)->assertRedirect();$url=URL::temporarySignedRoute('download.file',now()->addMinutes(5),['release'=>$release,'user'=>$user->id]);$this->get($url)->assertOk();$this->actingAs($other)->get($url)->assertForbidden();
$expired=URL::temporarySignedRoute('download.file',now()->subMinute(),['release'=>$release,'user'=>$user->id]);$this->actingAs($user)->get($expired)->assertForbidden();
}
public function test_support_is_scoped_and_does_not_accept_another_ticket(): void {$owner=$this->customer();$other=$this->customer();$ticket=DB::table('tickets')->insertGetId(['user_id'=>$owner->id,'subject'=>'Private conversation','status'=>'open','created_at'=>now(),'updated_at'=>now()]);Livewire::actingAs($other)->test(SupportDesk::class)->assertDontSee('Private conversation')->call('select',$ticket)->assertForbidden();}
public function test_customer_can_create_support_ticket(): void {Livewire::actingAs($this->customer())->test(SupportDesk::class)->set('subject','Connection help')->set('body','Please help me connect my server.')->call('create')->assertHasNoErrors();$this->assertDatabaseCount('tickets',1);$this->assertDatabaseCount('ticket_messages',1);}
public function test_admin_requires_role_and_confirmed_two_factor(): void {$this->actingAs($this->customer())->get('/admin')->assertForbidden();$admin=$this->customer(['is_admin'=>true]);$this->actingAs($admin)->get('/admin')->assertRedirect('/account/security');Livewire::actingAs($admin)->test(Cms::class)->assertForbidden();}
public function test_admin_can_edit_cms_and_mutations_are_audited(): void {$admin=$this->customer(['is_admin'=>true,'two_factor_confirmed_at'=>now()]);Livewire::actingAs($admin)->test(Cms::class)->set('form',['slug'=>'new-page','title'=>'New page','body'=>'Safe content','published'=>true])->call('save')->assertHasNoErrors();$this->assertDatabaseHas('pages',['slug'=>'new-page']);$this->assertDatabaseCount('audit_logs',1);}
public function test_consent_records_explicit_choices(): void {$this->postJson('/consent',['analytics'=>false,'marketing'=>false])->assertOk();$this->assertDatabaseHas('consents',['analytics'=>false,'marketing'=>false]);}
public function test_apple_verification_fails_closed_without_root(): void {config(['tarnax.apple.root_certificate'=>null]);$this->actingAs($this->customer())->postJson('/account/apple',['signed_transaction'=>'untrusted.payload.signature'])->assertStatus(503);$this->assertDatabaseCount('purchases',0);}
public function test_account_support_and_security_render_for_verified_customer(): void {$this->actingAs($this->customer());foreach(['/account','/support','/account/security'] as $url){$this->get($url)->assertOk();}}
public function test_erasure_revokes_access_and_anonymises_profile(): void {$user=$this->customer();$this->license($user);$id=DB::table('privacy_requests')->insertGetId(['user_id'=>$user->id,'type'=>'deletion','status'=>'pending','created_at'=>now(),'updated_at'=>now()]);$admin=$this->customer(['is_admin'=>true,'two_factor_confirmed_at'=>now()]);Livewire::actingAs($admin)->test(Cms::class)->set('section','privacy_requests')->call('fulfillDeletion',$id)->assertHasNoErrors();$this->assertDatabaseHas('users',['id'=>$user->id,'name'=>'Deleted customer']);$this->assertDatabaseHas('licenses',['user_id'=>$user->id,'status'=>'revoked']);$this->assertDatabaseHas('privacy_requests',['id'=>$id,'status'=>'fulfilled']);}
use Tests\TestCase;
class CommerceTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->seed();
Notification::fake();
config(['tarnax.paddle.webhook_secret' => 'test-secret', 'tarnax.paddle.price_id' => 'pri_test']);
}
private function customer(array $attributes = []): User
{
return User::factory()->create(['account_token' => (string) Str::uuid(), ...$attributes]);
}
private function event(User $user, string $id = 'evt_1'): array
{
return ['event_id' => $id, 'event_type' => 'transaction.completed', 'occurred_at' => now()->toISOString(), 'data' => ['id' => 'txn_1', 'status' => 'completed', 'custom_data' => ['account_token' => $user->account_token], 'items' => [['price' => ['id' => 'pri_test'], 'quantity' => 1]], 'details' => ['totals' => ['grand_total' => '4900']], 'currency_code' => 'USD']];
}
private function deliver(array $event, ?int $timestamp = null, ?string $signature = null)
{
$body = json_encode($event);
$ts = $timestamp ?? time();
$sig = $signature ?? hash_hmac('sha256', $ts.':'.$body, 'test-secret');
return $this->call('POST', '/api/webhooks/paddle', [], [], [], ['CONTENT_TYPE' => 'application/json', 'HTTP_ACCEPT' => 'application/json', 'HTTP_PADDLE_SIGNATURE' => 'ts='.$ts.';h1='.$sig], $body);
}
private function license(User $user): object
{
$this->deliver($this->event($user))->assertOk();
return DB::table('licenses')->first();
}
public function test_public_pages_and_authentication_views_render(): void
{
foreach (['/', '/releases', '/pages/privacy', '/pages/terms', '/pages/features', '/login', '/register', '/forgot-password'] as $url) {
$this->get($url)->assertOk();
}$this->get('/account')->assertRedirect('/login');
}
public function test_registration_cannot_grant_administration(): void
{
$this->post('/register', ['name' => 'Customer', 'email' => 'new@example.com', 'password' => 'StrongPass123!', 'password_confirmation' => 'StrongPass123!', 'is_admin' => true])->assertRedirect();
$this->assertFalse(User::where('email', 'new@example.com')->first()->is_admin);
}
public function test_verified_payment_issues_only_one_license_on_retries(): void
{
$user = $this->customer();
$event = $this->event($user);
$this->deliver($event)->assertOk();
$this->deliver($event)->assertOk();
$event['event_id'] = 'evt_2';
$this->deliver($event)->assertOk();
$this->assertDatabaseCount('purchases', 1);
$this->assertDatabaseCount('licenses', 1);
}
public function test_invalid_signature_and_stale_replay_never_grant_licenses(): void
{
$user = $this->customer();
$this->deliver($this->event($user), null, 'invalid')->assertUnauthorized();
$this->deliver($this->event($user), time() - 600)->assertUnauthorized();
$this->assertDatabaseCount('licenses', 0);
}
public function test_wrong_product_does_not_grant_license(): void
{
$event = $this->event($this->customer());
$event['data']['items'][0]['price']['id'] = 'pri_other';
$this->deliver($event)->assertOk();
$this->assertDatabaseCount('licenses', 0);
}
public function test_unknown_customer_rolls_back_event_for_retry(): void
{
$event = $this->event($this->customer());
$event['data']['custom_data']['account_token'] = (string) Str::uuid();
$this->deliver($event)->assertUnprocessable();
$this->assertDatabaseCount('webhook_events', 0);
}
public function test_activation_limits_validation_and_deactivation(): void
{
$license = $this->license($this->customer());
$key = Crypt::decryptString($license->key_encrypted);
$token = $this->postJson('/api/v1/licenses/activate', ['license_key' => $key, 'device_id' => str_repeat('a', 20)])->assertOk()->json('activation_token');
$this->postJson('/api/v1/licenses/activate', ['license_key' => $key, 'device_id' => str_repeat('b', 20)])->assertOk();
$this->postJson('/api/v1/licenses/activate', ['license_key' => $key, 'device_id' => str_repeat('c', 20)])->assertConflict();
$this->withToken($token)->postJson('/api/v1/licenses/validate')->assertOk()->assertJson(['valid' => true]);
$this->withToken($token)->postJson('/api/v1/licenses/deactivate')->assertOk();
$this->withToken($token)->postJson('/api/v1/licenses/validate')->assertForbidden();
}
public function test_refund_revokes_license_and_prevents_regrant(): void
{
$user = $this->customer();
$license = $this->license($user);
$key = Crypt::decryptString($license->key_encrypted);
$token = app(LicenseService::class)->activate($key, str_repeat('a', 20))['activation_token'];
$refund = ['event_id' => 'evt_refund', 'event_type' => 'adjustment.updated', 'occurred_at' => now()->addSecond()->toISOString(), 'data' => ['status' => 'approved', 'action' => 'refund', 'transaction_id' => 'txn_1']];
$this->deliver($refund)->assertOk();
$this->withToken($token)->postJson('/api/v1/licenses/validate')->assertForbidden();
$this->deliver($this->event($user, 'evt_late'))->assertOk();
$this->assertDatabaseHas('licenses', ['status' => 'revoked']);
}
public function test_checkout_uses_server_selected_product_and_customer(): void
{
$user = $this->customer();
config(['tarnax.paddle.api_key' => 'secret', 'tarnax.paddle.client_token' => 'token']);
Http::fake(['*' => Http::response(['data' => ['id' => 'txn_checkout']])]);
$this->actingAs($user)->postJson('/checkout', ['price_id' => 'attacker', 'custom_data' => ['account_token' => 'other']])->assertOk()->assertJson(['transaction_id' => 'txn_checkout']);
Http::assertSent(fn ($r) => $r['items'][0]['price_id'] === 'pri_test' && $r['custom_data']['account_token'] === $user->account_token);
}
public function test_unconfigured_checkout_fails_closed(): void
{
config(['tarnax.paddle.api_key' => null]);
$this->actingAs($this->customer())->postJson('/checkout')->assertStatus(503);
}
public function test_downloads_require_license_signature_and_matching_customer(): void
{
Storage::fake('local');
Storage::disk('local')->put('releases/app.zip', 'binary');
$release = DB::table('releases')->insertGetId(['version' => '1.0.0', 'channel' => 'stable', 'platform' => 'macOS', 'notes' => 'Release', 'path' => 'releases/app.zip', 'sha256' => hash('sha256', 'binary'), 'bytes' => 6, 'published' => true, 'created_at' => now(), 'updated_at' => now()]);
$user = $this->customer();
$other = $this->customer();
$this->actingAs($user)->get('/downloads/'.$release)->assertForbidden();
$this->license($user);
$this->get('/downloads/'.$release)->assertRedirect();
$url = URL::temporarySignedRoute('download.file', now()->addMinutes(5), ['release' => $release, 'user' => $user->id]);
$this->get($url)->assertOk();
$this->actingAs($other)->get($url)->assertForbidden();
$expired = URL::temporarySignedRoute('download.file', now()->subMinute(), ['release' => $release, 'user' => $user->id]);
$this->actingAs($user)->get($expired)->assertForbidden();
}
public function test_support_is_scoped_and_does_not_accept_another_ticket(): void
{
$owner = $this->customer();
$other = $this->customer();
$ticket = DB::table('tickets')->insertGetId(['user_id' => $owner->id, 'subject' => 'Private conversation', 'status' => 'open', 'created_at' => now(), 'updated_at' => now()]);
Livewire::actingAs($other)->test(SupportDesk::class)->assertDontSee('Private conversation')->call('select', $ticket)->assertForbidden();
}
public function test_customer_can_create_support_ticket(): void
{
Livewire::actingAs($this->customer())->test(SupportDesk::class)->set('subject', 'Connection help')->set('body', 'Please help me connect my server.')->call('create')->assertHasNoErrors();
$this->assertDatabaseCount('tickets', 1);
$this->assertDatabaseCount('ticket_messages', 1);
}
public function test_admin_requires_role_and_confirmed_two_factor(): void
{
$this->actingAs($this->customer())->get('/admin')->assertForbidden();
$admin = $this->customer(['is_admin' => true]);
$this->actingAs($admin)->get('/admin')->assertRedirect('/account/security');
Livewire::actingAs($admin)->test(Cms::class)->assertForbidden();
}
public function test_admin_can_edit_cms_and_mutations_are_audited(): void
{
$admin = $this->customer(['is_admin' => true, 'two_factor_confirmed_at' => now()]);
Livewire::actingAs($admin)->test(Cms::class)->set('form', ['slug' => 'new-page', 'title' => 'New page', 'body' => 'Safe content', 'published' => true])->call('save')->assertHasNoErrors();
$this->assertDatabaseHas('pages', ['slug' => 'new-page']);
$this->assertDatabaseCount('audit_logs', 1);
}
public function test_consent_records_explicit_choices(): void
{
$this->postJson('/consent', ['analytics' => false, 'marketing' => false])->assertOk();
$this->assertDatabaseHas('consents', ['analytics' => false, 'marketing' => false]);
}
public function test_apple_verification_fails_closed_without_root(): void
{
config(['tarnax.apple.root_certificate' => null]);
$this->actingAs($this->customer())->postJson('/account/apple', ['signed_transaction' => 'untrusted.payload.signature'])->assertStatus(503);
$this->assertDatabaseCount('purchases', 0);
}
public function test_account_support_and_security_render_for_verified_customer(): void
{
$this->actingAs($this->customer())->withSession(['auth.password_confirmed_at' => time()]);
foreach (['/account', '/support', '/account/security'] as $url) {
$this->get($url)->assertOk();
}
}
public function test_erasure_revokes_access_and_anonymises_profile(): void
{
$user = $this->customer();
$this->license($user);
$id = DB::table('privacy_requests')->insertGetId(['user_id' => $user->id, 'type' => 'deletion', 'status' => 'pending', 'created_at' => now(), 'updated_at' => now()]);
$admin = $this->customer(['is_admin' => true, 'two_factor_confirmed_at' => now()]);
Livewire::actingAs($admin)->test(Cms::class)->set('section', 'privacy_requests')->call('fulfillDeletion', $id)->assertHasNoErrors();
$this->assertDatabaseHas('users', ['id' => $user->id, 'name' => 'Deleted customer']);
$this->assertDatabaseHas('licenses', ['user_id' => $user->id, 'status' => 'revoked']);
$this->assertDatabaseHas('privacy_requests', ['id' => $id, 'status' => 'fulfilled']);
}
public function test_refund_before_purchase_prevents_late_grant(): void
{
$user = $this->customer();
$this->deliver(['event_id' => 'early_refund', 'event_type' => 'adjustment.created', 'occurred_at' => now()->toISOString(), 'data' => ['status' => 'approved', 'action' => 'refund', 'transaction_id' => 'txn_1']])->assertOk();
$this->deliver($this->event($user))->assertOk();
$this->assertDatabaseCount('licenses', 0);
}
public function test_subscription_events_revoke_and_restore_and_ignore_stale_events(): void
{
$user = $this->customer();
$event = $this->event($user);
$event['data']['subscription_id'] = 'sub_1';
$this->deliver($event)->assertOk();
$send = function ($id, $status, $seconds) {
return $this->deliver(['event_id' => $id, 'event_type' => 'subscription.updated', 'occurred_at' => now()->addSeconds($seconds)->toISOString(), 'data' => ['id' => 'sub_1', 'status' => $status]]);
};
$send('pause', 'paused', 10)->assertOk();
$this->assertDatabaseHas('licenses', ['status' => 'revoked']);
$send('resume', 'active', 20)->assertOk();
$this->assertDatabaseHas('licenses', ['status' => 'active']);
$send('old_pause', 'paused', 5)->assertOk();
$this->assertDatabaseHas('licenses', ['status' => 'active']);
}
public function test_admin_can_manage_navigation_and_reject_external_redirects(): void
{
$admin = $this->customer(['is_admin' => true, 'two_factor_confirmed_at' => now()]);
Livewire::actingAs($admin)->test(Cms::class)->set('section', 'navigation')->set('form', ['label' => 'Features', 'path' => '/pages/features', 'position' => 1, 'published' => true])->call('saveResource')->assertHasNoErrors();
$this->assertDatabaseHas('navigation', ['label' => 'Features']);
Livewire::actingAs($admin)->test(Cms::class)->set('section', 'redirects')->set('form', ['source' => '/old', 'destination' => '//evil.example'])->call('saveResource')->assertHasErrors('form.destination');
}
public function test_published_page_html_is_escaped(): void
{
DB::table('pages')->where('slug', 'features')->update(['body' => '<script>alert(1)</script>']);
$this->get('/pages/features')->assertSee('&lt;script&gt;', false)->assertDontSee('<script>alert(1)</script>', false);
}
public function test_apple_notification_fails_closed_without_configuration(): void
{
config(['tarnax.apple.root_certificate' => null]);
$this->postJson('/api/webhooks/apple', ['signedPayload' => 'untrusted.payload.signature'])->assertStatus(503);
}
public function test_erased_customer_cannot_use_an_old_activation_token(): void
{
$user = $this->customer();
$license = $this->license($user);
$token = app(LicenseService::class)->activate(Crypt::decryptString($license->key_encrypted), str_repeat('x', 20))['activation_token'];
$id = DB::table('privacy_requests')->insertGetId(['user_id' => $user->id, 'type' => 'deletion', 'status' => 'pending', 'created_at' => now(), 'updated_at' => now()]);
$admin = $this->customer(['is_admin' => true, 'two_factor_confirmed_at' => now()]);
Livewire::actingAs($admin)->test(Cms::class)->set('section', 'privacy_requests')->call('fulfillDeletion', $id);
$this->withToken($token)->postJson('/api/v1/licenses/validate')->assertForbidden();
}
public function test_unverified_accounts_cannot_checkout_or_open_portal(): void
{
$user = $this->customer(['email_verified_at' => null]);
$this->actingAs($user)->get('/account')->assertRedirect('/email/verify');
$this->postJson('/checkout')->assertForbidden();
}
public function test_private_media_is_safely_served(): void
{
Storage::fake('local');
Storage::disk('local')->put('media/test.png','test image');
$id = DB::table('media')->insertGetId(['title' => 'App', 'alt' => 'Icon', 'path' => 'media/test.png', 'mime' => 'image/png', 'bytes' => 10, 'created_at' => now(), 'updated_at' => now()]);
$this->get('/media/'.$id)->assertOk()->assertHeader('X-Content-Type-Options','nosniff');
}
}
@@ -3,11 +3,13 @@
namespace Tests\Feature;
// use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ExampleTest extends TestCase
{
use \Illuminate\Foundation\Testing\RefreshDatabase;
use RefreshDatabase;
/**
* A basic test example.
*/