Add external subscription system

This commit is contained in:
2026-05-04 19:12:21 +02:00
parent 05d4ef1bdb
commit 2151d69791
10 changed files with 282 additions and 0 deletions

View File

@@ -95,6 +95,16 @@ class ProfileController extends Controller
]); ]);
} }
/**
* Display the user's subscription page.
*/
public function subscription(Request $request): View
{
return view('profile.subscription', [
'user' => $request->user(),
]);
}
/** /**
* Update user settings. * Update user settings.
*/ */

View File

@@ -0,0 +1,70 @@
<?php
namespace App\Livewire;
use App\Models\User;
use App\Services\SubscriptionService;
use Livewire\Component;
use Livewire\Attributes\Computed;
use Illuminate\Support\Facades\RateLimiter;
class UserSubscription extends Component
{
public $userId = 0;
public $subscriptionKey = '';
protected $rules = [
'subscriptionKey' => 'required|string|size:48',
];
public function mount(User $user)
{
$this->userId = $user ? $user->id : auth()->user()->id;
$this->subscriptionKey = $user->subscription_key ?? '';
}
public function applyKey(SubscriptionService $subscriptionService)
{
$this->validate();
$rateLimitKey = "apply-subscription:{$this->userId}";
$rateLimitMinutes = 60 * 5; // 5 minutes
// Rate Limit to prevent users trying random keys
if (RateLimiter::tooManyAttempts($rateLimitKey, 1)) {
$seconds = RateLimiter::availableIn($rateLimitKey);
$this->addError('subscriptionKey', "Too many attempts. Try again in {$seconds} seconds.");
return;
}
RateLimiter::hit($rateLimitKey, $rateLimitMinutes);
// Check if token is already being used
$alreadyUsed = User::where('subscription_key', $this->subscriptionKey)
->whereNot('id', $this->userId)
->exists();
if ($alreadyUsed) {
$this->addError('subscriptionKey', 'Key already used!');
return;
}
$user = User::where('id', $this->userId)->firstOrFail();
// Verify token
$success = $subscriptionService->checkSubscriptionStatus($user, $this->subscriptionKey);
if (!$success) {
$this->addError('subscriptionKey', 'Invalid Key! If you believe this is a bug, please report this to the admin!');
return;
}
$user->subscription_key = $this->subscriptionKey;
$user->save();
}
public function render()
{
return view('livewire.user-subscription');
}
}

View File

@@ -31,6 +31,7 @@ class User extends Authenticatable implements HasPasskeys
// Discord // Discord
'discord_id', 'discord_id',
'discord_avatar', 'discord_avatar',
'subscription_key',
]; ];
/** /**
@@ -41,6 +42,7 @@ class User extends Authenticatable implements HasPasskeys
protected $hidden = [ protected $hidden = [
'password', 'password',
'remember_token', 'remember_token',
'subscription_key',
]; ];
/** /**

View File

@@ -0,0 +1,79 @@
<?php
namespace App\Services;
use App\Enums\UserRole;
use App\Models\User;
use Exception;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
class SubscriptionService
{
private function generateEncryptedPayload(string $subscriptionKey): string
{
return base64_encode(Crypt::encryptString(json_encode([
'subscription_access_key' => $subscriptionKey,
'timestamp' => now()->timestamp,
'nonce' => Str::uuid()->toString(),
], JSON_THROW_ON_ERROR)));
}
/**
* Gets the subscription status from the subscription service.
*/
private function getSubscriptionStatus(string $subscriptionKey): array | null
{
try {
$payload = $this->generateEncryptedPayload($subscriptionKey);
$response = Http::post(config('services.subscription_service_host').'/api/membership/verify', [
'payload' => $payload,
]);
if (! $response->successful()) {
logger()->error('Subscription Service API error', [
'status' => $response->status(),
'body' => $response->body(),
]);
return null;
}
$encryptedResponse = $response->json('payload');
$json = json_decode(
Crypt::decryptString($encryptedResponse),
true,
flags: JSON_THROW_ON_ERROR
);
return $json;
} catch (Exception $e) {
logger()->error('getSubscriptionStatus Exception', [
'details' => $e,
]);
}
return null;
}
public function checkSubscriptionStatus(User $user, string $subscriptionKey): bool
{
$subscriptionStatus = $this->getSubscriptionStatus($subscriptionKey);
if (!$subscriptionStatus) {
return false;
}
if ($subscriptionStatus['valid'] === true &&
$subscriptionStatus['active'] === true) {
$user->addRole(UserRole::SUPPORTER);
return true;
}
$user->removeRole(UserRole::SUPPORTER);
return true;
}
}

View File

@@ -54,4 +54,9 @@ return [
'server' => env('MATRIX_SERVER'), 'server' => env('MATRIX_SERVER'),
'shared_secret' => env('MATRIX_SHARED_SECRET'), 'shared_secret' => env('MATRIX_SHARED_SECRET'),
], ],
/**
* Subscription Service
*/
'subscription_service_host' => env('SUBSCRIPTION_SERVICE_HOST'),
]; ];

View File

@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('subscription_key', 64)
->unique()
->nullable()
->after('roles');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('subscription_key');
});
}
};

View File

@@ -0,0 +1,58 @@
<div class="grid grid-cols-1 lg:grid-cols-3">
<!-- Subscription Card -->
<section class="lg:col-span-3 rounded-2xl border border-white/10 shadow-black/20 overflow-hidden p-4 sm:p-8 bg-white/40 dark:bg-neutral-950/40 backdrop-blur shadow sm:rounded-lg">
<div class="p-6 border-b border-white/10">
<div class="flex items-center justify-between gap-4">
<div>
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100">Subscription Status</h3>
<p class="p-2 text-sm dark:text-gray-200 text-gray-800">
Your current membership status for unlimited 4k Downloads.
</p>
</div>
@php
$isActive = auth()->user()->hasRole(\App\Enums\UserRole::SUPPORTER);
@endphp
<span class="inline-flex items-center gap-2 rounded-full px-3 py-1 text-sm font-medium border
{{ $isActive ? 'bg-green-500/10 text-green-300 border-green-500/20' : 'bg-red-500/10 text-red-300 border-red-500/20' }}">
<span class="h-2 w-2 rounded-full {{ $isActive ? 'bg-green-400' : 'bg-red-400' }}"></span>
{{ $isActive ? 'Active' : 'Inactive' }}
</span>
</div>
</div>
<!-- Subscription Access Key -->
<div class="lg:col-span-3 rounded-2xl border border-blue-400/20 bg-blue-500/[0.06] shadow-2xl shadow-blue-950/20 p-6 mt-4">
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-6">
<div>
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100">Subscription Access Key</h3>
<p class="p-2 text-sm dark:text-gray-200 text-gray-800">
Paste your subscription key to apply the membership status.
</p>
</div>
<div class="w-full lg:w-auto">
<div class="flex flex-col sm:flex-row gap-3">
<input
id="subscriptionKey"
type="text"
value="{{ $subscriptionKey }}"
wire:model="subscriptionKey"
class="w-full sm:w-[420px] rounded-xl border border-white/10 dark:bg-gray-950/80 px-4 py-3 font-mono text-sm text-blue-400 dark:text-blue-200 outline-none focus:border-blue-400/50"
>
<button
type="button"
wire:click="applyKey"
class="rounded-xl bg-rose-500 px-5 py-3 text-sm font-semibold text-white hover:bg-rose-400 transition shadow-lg shadow-rose-500/20"
>
Apply
</button>
</div>
@error('subscriptionKey') <span class="text-red-500 text-sm">{{ $message }}</span> @enderror
</div>
</div>
</div>
</section>
</div>

View File

@@ -2,6 +2,10 @@
<div <div
class="overflow-hidden mt-5 relative max-w-sm min-w-80 mx-auto bg-white/40 shadow-lg ring-1 ring-black/5 rounded-xl items-center gap-6 dark:bg-neutral-950/40 backdrop-blur dark:highlight-white/5"> class="overflow-hidden mt-5 relative max-w-sm min-w-80 mx-auto bg-white/40 shadow-lg ring-1 ring-black/5 rounded-xl items-center gap-6 dark:bg-neutral-950/40 backdrop-blur dark:highlight-white/5">
<div class="flex flex-col p-2"> <div class="flex flex-col p-2">
<a class="block w-full px-4 py-2 rounded-lg text-left text-lg leading-5 text-gray-700 dark:text-gray-300 @if(request()->routeIs('profile.subscription')) bg-rose-900/40 @endif hover:bg-neutral-100/60 dark:hover:bg-neutral-900/60 focus:outline-none focus:bg-neutral-100 dark:focus:bg-neutral-800 transition duration-150 ease-in-out"
href="{{ route('profile.subscription') }}"><i class="fa-solid fa-hand-holding-dollar pr-4"></i></i>
Subscription</a>
<a class="block w-full px-4 py-2 rounded-lg text-left text-lg leading-5 text-gray-700 dark:text-gray-300 @if(request()->routeIs('profile.settings')) bg-rose-900/40 @endif hover:bg-neutral-100/60 dark:hover:bg-neutral-900/60 focus:outline-none focus:bg-neutral-100 dark:focus:bg-neutral-800 transition duration-150 ease-in-out" <a class="block w-full px-4 py-2 rounded-lg text-left text-lg leading-5 text-gray-700 dark:text-gray-300 @if(request()->routeIs('profile.settings')) bg-rose-900/40 @endif hover:bg-neutral-100/60 dark:hover:bg-neutral-900/60 focus:outline-none focus:bg-neutral-100 dark:focus:bg-neutral-800 transition duration-150 ease-in-out"
href="{{ route('profile.settings') }}"><i class="fa-solid fa-gear pr-4"></i> href="{{ route('profile.settings') }}"><i class="fa-solid fa-gear pr-4"></i>
Settings</a> Settings</a>

View File

@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="scroll-smooth">
@include('partials.head')
<body class="font-sans antialiased">
<div class="flex flex-col min-h-screen bg-gray-100 dark:bg-neutral-900">
@include('layouts.navigation')
<!-- Page Content -->
<main>
@include('partials.background')
<div class="relative max-w-[120rem] mx-auto sm:px-6 lg:px-8 space-y-6 pt-20 mt-[65px] flex flex-row">
<div class="flex flex-col md:flex-row">
@include('profile.partials.sidebar')
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 mt-8 md:mt-0 space-y-6">
@livewire('user-subscription', ['user' => $user])
</div>
</div>
</div>
</main>
@include('layouts.footer')
</div>
</body>
</html>

View File

@@ -25,6 +25,7 @@ Route::middleware('auth')->group(function () {
Route::get('/user/comments', [ProfileController::class, 'comments'])->name('profile.comments'); Route::get('/user/comments', [ProfileController::class, 'comments'])->name('profile.comments');
Route::get('/user/likes', [ProfileController::class, 'likes'])->name('profile.likes'); Route::get('/user/likes', [ProfileController::class, 'likes'])->name('profile.likes');
Route::get('/user/watched', [ProfileController::class, 'watched'])->name('user.watched'); Route::get('/user/watched', [ProfileController::class, 'watched'])->name('user.watched');
Route::get('/user/subscription', [ProfileController::class, 'subscription'])->name('profile.subscription');
// Notifications // Notifications
Route::get('/user/notifications', [NotificationController::class, 'index'])->name('profile.notifications'); Route::get('/user/notifications', [NotificationController::class, 'index'])->name('profile.notifications');