Remove external subscription system
This commit is contained in:
@@ -1,109 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Console\Commands;
|
|
||||||
|
|
||||||
use App\Enums\UserRole;
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Console\Command;
|
|
||||||
use Illuminate\Support\Facades\Crypt;
|
|
||||||
use Illuminate\Support\Facades\Http;
|
|
||||||
use Illuminate\Contracts\Encryption\DecryptException;
|
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
|
|
||||||
class SyncSubscriptionKeys extends Command
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* The name and signature of the console command.
|
|
||||||
*
|
|
||||||
* @var string
|
|
||||||
*/
|
|
||||||
protected $signature = 'app:sync-subscription-keys';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The console command description.
|
|
||||||
*
|
|
||||||
* @var string
|
|
||||||
*/
|
|
||||||
protected $description = 'Sync local users against active subscription keys';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute the console command.
|
|
||||||
*/
|
|
||||||
public function handle()
|
|
||||||
{
|
|
||||||
$endpoint = config('services.subscription_service_host');
|
|
||||||
if (!$endpoint) {
|
|
||||||
$this->error('Missing endpoint.');
|
|
||||||
return self::FAILURE;
|
|
||||||
}
|
|
||||||
|
|
||||||
$response = Http::asJson()
|
|
||||||
->acceptJson()
|
|
||||||
->timeout(20)
|
|
||||||
->retry(3, 1000)
|
|
||||||
->post($endpoint.'/api/membership/keys', [
|
|
||||||
'payload' => base64_encode(Crypt::encryptString('get-active-keys')),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (! $response->successful()) {
|
|
||||||
$this->error('Subscription API request failed: HTTP ' . $response->status());
|
|
||||||
return self::FAILURE;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$decrypted = Crypt::decryptString(base64_decode($response->json('payload')));
|
|
||||||
$activeKeys = json_decode($decrypted, true, flags: JSON_THROW_ON_ERROR);
|
|
||||||
} catch (DecryptException $e) {
|
|
||||||
$this->error('Could not decrypt API response.');
|
|
||||||
return self::FAILURE;
|
|
||||||
} catch (\JsonException $e) {
|
|
||||||
$this->error('API returned invalid JSON.');
|
|
||||||
return self::FAILURE;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! is_array($activeKeys)) {
|
|
||||||
$this->error('API response payload was not an array.');
|
|
||||||
return self::FAILURE;
|
|
||||||
}
|
|
||||||
|
|
||||||
$activeKeys = collect($activeKeys)
|
|
||||||
->filter(fn ($key) => is_string($key) && $key !== '')
|
|
||||||
->values()
|
|
||||||
->all();
|
|
||||||
|
|
||||||
$this->markInactiveUsers($activeKeys);
|
|
||||||
$this->markActiveUsers($activeKeys);
|
|
||||||
|
|
||||||
return self::SUCCESS;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function markInactiveUsers(array $activeKeys)
|
|
||||||
{
|
|
||||||
User::query()
|
|
||||||
->whereNotNull('subscription_key')
|
|
||||||
->whereNotIn('subscription_key', $activeKeys)
|
|
||||||
->chunk(100, function ($users) {
|
|
||||||
foreach($users as $user) {
|
|
||||||
if ($user->hasRole(UserRole::SUPPORTER)) {
|
|
||||||
Log::info("Removed Supporter Role from {$user->name}");
|
|
||||||
$user->removeRole(UserRole::SUPPORTER);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private function markActiveUsers(array $activeKeys)
|
|
||||||
{
|
|
||||||
User::query()
|
|
||||||
->whereNotNull('subscription_key')
|
|
||||||
->whereIn('subscription_key', $activeKeys)
|
|
||||||
->chunk(100, function ($users) {
|
|
||||||
foreach($users as $user) {
|
|
||||||
if (!$user->hasRole(UserRole::SUPPORTER)) {
|
|
||||||
Log::info("Added Supporter Role for {$user->name}");
|
|
||||||
$user->addRole(UserRole::SUPPORTER);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -95,16 +95,6 @@ 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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,75 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Livewire;
|
|
||||||
|
|
||||||
use App\Enums\UserRole;
|
|
||||||
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 = '';
|
|
||||||
|
|
||||||
public $isActive = false;
|
|
||||||
|
|
||||||
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 ?? '';
|
|
||||||
$this->isActive = $user->hasRole(UserRole::SUPPORTER) ?? false;
|
|
||||||
}
|
|
||||||
|
|
||||||
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();
|
|
||||||
$this->isActive = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function render()
|
|
||||||
{
|
|
||||||
return view('livewire.user-subscription');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -31,7 +31,6 @@ class User extends Authenticatable implements HasPasskeys
|
|||||||
// Discord
|
// Discord
|
||||||
'discord_id',
|
'discord_id',
|
||||||
'discord_avatar',
|
'discord_avatar',
|
||||||
'subscription_key',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -42,7 +41,6 @@ class User extends Authenticatable implements HasPasskeys
|
|||||||
protected $hidden = [
|
protected $hidden = [
|
||||||
'password',
|
'password',
|
||||||
'remember_token',
|
'remember_token',
|
||||||
'subscription_key',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
<?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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -54,9 +54,4 @@ 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'),
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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->dropColumn('subscription_key');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->string('subscription_key', 64)
|
||||||
|
->unique()
|
||||||
|
->nullable()
|
||||||
|
->after('roles');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
@@ -2,10 +2,6 @@
|
|||||||
<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>
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
<x-app-layout>
|
|
||||||
@include('partials.background')
|
|
||||||
<div class="relative max-w-[120rem] mx-auto sm:px-6 lg:px-8 space-y-6 pt-10 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>
|
|
||||||
</x-app-layout>
|
|
||||||
@@ -18,4 +18,3 @@ use Illuminate\Support\Facades\Schedule;
|
|||||||
Schedule::command('app:auto-stats')->hourly();
|
Schedule::command('app:auto-stats')->hourly();
|
||||||
Schedule::command('app:reset-user-downloads')->daily();
|
Schedule::command('app:reset-user-downloads')->daily();
|
||||||
Schedule::command('app:generate-sitemap')->daily();
|
Schedule::command('app:generate-sitemap')->daily();
|
||||||
Schedule::command('app:sync-subscription-keys')->daily();
|
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ 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');
|
||||||
|
|||||||
Reference in New Issue
Block a user