From 205a8b08e1f253d89ce47a739796df2dcbc5f9fe Mon Sep 17 00:00:00 2001 From: Dwindi Ramadhana Date: Sat, 14 Feb 2026 18:08:19 +0700 Subject: [PATCH] refactor: finalize account-based billing and env cleanup --- app/.env.example | 28 +- .../Controllers/Api/V1/EmojiApiController.php | 306 +------------ .../Controllers/Api/V1/LicenseController.php | 413 ------------------ .../Controllers/Api/V1/SystemController.php | 3 +- .../Billing/LicenseVerificationService.php | 408 ----------------- app/config/dewemoji.php | 33 +- .../license-key-issued.html | 103 ----- app/resources/views/site/api-docs.blade.php | 11 +- app/resources/views/site/pricing.blade.php | 8 +- app/resources/views/site/privacy.blade.php | 4 +- app/resources/views/site/support.blade.php | 19 +- app/resources/views/site/terms.blade.php | 6 +- app/routes/api.php | 7 +- app/tests/Feature/ApiV1EndpointsTest.php | 214 +-------- production-env.md | 79 ++++ 15 files changed, 132 insertions(+), 1510 deletions(-) delete mode 100644 app/app/Http/Controllers/Api/V1/LicenseController.php delete mode 100644 app/app/Services/Billing/LicenseVerificationService.php delete mode 100644 app/references/email-branded-designs/license-key-issued.html create mode 100644 production-env.md diff --git a/app/.env.example b/app/.env.example index 2e2d64d..8ae7018 100644 --- a/app/.env.example +++ b/app/.env.example @@ -72,35 +72,19 @@ DEWEMOJI_DEFAULT_LIMIT=20 DEWEMOJI_MAX_LIMIT=50 DEWEMOJI_FREE_MAX_LIMIT=20 DEWEMOJI_PRO_MAX_LIMIT=50 -DEWEMOJI_FREE_DAILY_LIMIT=30 -DEWEMOJI_RATE_LIMIT_ENABLED=true -DEWEMOJI_LICENSE_ACCEPT_ALL=true -DEWEMOJI_PRO_KEYS= -DEWEMOJI_LICENSE_MAX_DEVICES=3 DEWEMOJI_BILLING_MODE=sandbox -DEWEMOJI_VERIFY_CACHE_TTL=300 -DEWEMOJI_GUMROAD_ENABLED=false -DEWEMOJI_GUMROAD_VERIFY_URL=https://api.gumroad.com/v2/licenses/verify -DEWEMOJI_GUMROAD_PRODUCT_IDS= -DEWEMOJI_GUMROAD_TIMEOUT=8 -DEWEMOJI_GUMROAD_TEST_KEYS= -DEWEMOJI_MAYAR_ENABLED=false -DEWEMOJI_MAYAR_VERIFY_URL= -DEWEMOJI_MAYAR_API_BASE= -DEWEMOJI_MAYAR_ENDPOINT_VERIFY=/v1/license/verify -DEWEMOJI_MAYAR_PRODUCT_IDS= -DEWEMOJI_MAYAR_API_KEY= -DEWEMOJI_MAYAR_SECRET_KEY= -DEWEMOJI_MAYAR_TIMEOUT=8 -DEWEMOJI_MAYAR_TEST_KEYS= DEWEMOJI_ALLOWED_ORIGINS=http://127.0.0.1:8000,http://localhost:8000,https://dewemoji.com,https://www.dewemoji.com DEWEMOJI_FRONTEND_HEADER=web-v1 DEWEMOJI_METRICS_ENABLED=true DEWEMOJI_METRICS_TOKEN= DEWEMOJI_METRICS_ALLOW_IPS=127.0.0.1,::1 +DEWEMOJI_PUBLIC_ENFORCE=true +DEWEMOJI_PUBLIC_ORIGINS=http://127.0.0.1:8000,http://localhost:8000,https://dewemoji.com,https://www.dewemoji.com +DEWEMOJI_PUBLIC_HOURLY_LIMIT=5000 DEWEMOJI_USD_RATE=15000 DEWEMOJI_QRIS_URL= DEWEMOJI_PAYPAL_URL= +DEWEMOJI_ADMIN_TOKEN= DEWEMOJI_PAYPAL_ENABLED=false DEWEMOJI_PAYPAL_TIMEOUT=10 DEWEMOJI_PAYPAL_SANDBOX_CLIENT_ID= @@ -122,3 +106,7 @@ DEWEMOJI_PAKASIR_API_BASE=https://app.pakasir.com DEWEMOJI_PAKASIR_API_KEY= DEWEMOJI_PAKASIR_PROJECT= DEWEMOJI_PAKASIR_TIMEOUT=10 +DEWEMOJI_EXTENSION_VERIFY_ENABLED=true +DEWEMOJI_GOOGLE_PROJECT_ID= +DEWEMOJI_GOOGLE_SERVER_KEY= +DEWEMOJI_EXTENSION_IDS= diff --git a/app/app/Http/Controllers/Api/V1/EmojiApiController.php b/app/app/Http/Controllers/Api/V1/EmojiApiController.php index 0866e2a..b6f1ef6 100644 --- a/app/app/Http/Controllers/Api/V1/EmojiApiController.php +++ b/app/app/Http/Controllers/Api/V1/EmojiApiController.php @@ -2,16 +2,12 @@ namespace App\Http\Controllers\Api\V1; -use App\Services\Billing\LicenseVerificationService; use App\Services\Auth\ApiKeyService; use App\Services\System\SettingsService; use App\Http\Controllers\Controller; -use Carbon\Carbon; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; -use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; -use Illuminate\Support\Facades\Schema; use RuntimeException; class EmojiApiController extends Controller @@ -23,7 +19,6 @@ class EmojiApiController extends Controller private static ?array $dataset = null; public function __construct( - private readonly LicenseVerificationService $verification, private readonly ApiKeyService $apiKeys ) { } @@ -324,18 +319,13 @@ class EmojiApiController extends Controller private function detectTier(Request $request): string { - $key = trim((string) $request->bearerToken()); - if ($key === '') { - $key = trim((string) $request->header('X-License-Key', '')); - } - if ($key === '') { - $key = trim((string) $request->query('key', '')); + $apiUser = $this->apiKeys->resolveUser($request); + if ($apiUser && (string) $apiUser->tier === 'personal') { + return self::TIER_PRO; } - if ($key === '') { - return self::TIER_FREE; - } - if ($this->verification->isPro($key)) { + $webUser = $request->user(); + if ($webUser && (string) $webUser->tier === 'personal') { return self::TIER_PRO; } @@ -344,11 +334,6 @@ class EmojiApiController extends Controller private function enforcePublicAccess(Request $request): ?JsonResponse { - if ($this->hasApiKeyAuth($request)) { - return null; - } - - $config = config('dewemoji.public_access', []); $settings = app(SettingsService::class); $maintenanceEnabled = (bool) $settings->get('maintenance_enabled', false); if ($maintenanceEnabled) { @@ -358,132 +343,9 @@ class EmojiApiController extends Controller ], self::TIER_FREE, 503); } - $enforceWhitelist = (bool) $settings->get('public_enforce', $config['enforce_whitelist'] ?? false); - $allowedOrigins = $settings->get('public_origins', $config['allowed_origins'] ?? []); - $extensionIds = $settings->get('public_extension_ids', $config['extension_ids'] ?? []); - $limitOverride = $settings->get('public_hourly_limit', $config['hourly_limit'] ?? 0); - - $allowed = $this->isPublicAllowed($request, [ - 'allowed_origins' => $allowedOrigins, - 'extension_ids' => $extensionIds, - ]); - - if ($enforceWhitelist && !$allowed) { - return $this->jsonWithTier($request, [ - 'ok' => false, - 'error' => 'public_access_denied', - ], self::TIER_FREE, 403); - } - - if (!$allowed) { - $limit = max((int) $limitOverride, 0); - if ($limit > 0) { - $usage = $this->trackPublicHourlyUsage($request, $limit); - if ($usage['blocked']) { - return $this->jsonWithTier($request, [ - 'ok' => false, - 'error' => 'public_rate_limited', - 'usage' => $usage['meta'], - ], self::TIER_FREE, 429, [ - 'X-RateLimit-Limit' => (string) $usage['meta']['limit'], - 'X-RateLimit-Remaining' => '0', - 'X-RateLimit-Reset' => (string) $usage['meta']['window_ends_at_unix'], - ]); - } - } - } - return null; } - /** - * @param array $config - */ - private function isPublicAllowed(Request $request, array $config): bool - { - if (app()->environment(['local', 'testing'])) { - return true; - } - - $host = trim((string) $request->getHost()); - if (in_array($host, ['127.0.0.1', 'localhost'], true)) { - return true; - } - - $origin = trim((string) $request->headers->get('Origin', '')); - $allowedOrigins = $config['allowed_origins'] ?? []; - if ($origin !== '' && is_array($allowedOrigins) && in_array($origin, $allowedOrigins, true)) { - return true; - } - - $frontendHeader = trim((string) $request->header('X-Dewemoji-Frontend', '')); - $extensionIds = $config['extension_ids'] ?? []; - if ($frontendHeader !== '' && is_array($extensionIds)) { - foreach ($extensionIds as $id) { - if ($id !== '' && str_contains($frontendHeader, $id)) { - return true; - } - } - } - - $extensionHeader = trim((string) $request->header('X-Extension-Id', '')); - if ($extensionHeader !== '' && is_array($extensionIds)) { - if (in_array($extensionHeader, $extensionIds, true)) { - return true; - } - } - - $userAgent = (string) $request->userAgent(); - if ($userAgent !== '' && is_array($extensionIds)) { - foreach ($extensionIds as $id) { - if ($id !== '' && str_contains($userAgent, $id)) { - return true; - } - } - } - - return false; - } - - private function hasApiKeyAuth(Request $request): bool - { - return $this->apiKeys->resolveUser($request) !== null; - } - - /** - * @return array{blocked:bool,meta:array} - */ - private function trackPublicHourlyUsage(Request $request, int $limit): array - { - $bucket = sha1((string) $request->ip().'|'.(string) $request->userAgent()); - $hourKey = Carbon::now('UTC')->format('YmdH'); - $cacheKey = 'dw_public_hourly_'.$bucket.'_'.$hourKey; - $seconds = max(60, Carbon::now('UTC')->diffInSeconds(Carbon::now('UTC')->addHour()->startOfHour())); - - $current = Cache::get($cacheKey, 0); - if (!is_int($current)) { - $current = (int) $current; - } - - $current++; - Cache::put($cacheKey, $current, $seconds); - - $blocked = $current > $limit; - $windowEnds = Carbon::now('UTC')->addHour()->startOfHour(); - - return [ - 'blocked' => $blocked, - 'meta' => [ - 'used' => min($current, $limit), - 'limit' => $limit, - 'remaining' => max(0, $limit - $current), - 'window' => 'hourly', - 'window_ends_at' => $windowEnds->toIso8601String(), - 'window_ends_at_unix' => $windowEnds->timestamp, - ], - ]; - } - private function isNotModified(Request $request, string $etag): bool { $ifNoneMatch = trim((string) $request->header('If-None-Match', '')); @@ -647,162 +509,6 @@ class EmojiApiController extends Controller return $payload; } - /** - * @return array{blocked:bool,meta:array} - */ - private function trackDailyUsage(Request $request, string $q, string $category, string $subcategory): array - { - $dailyLimit = max((int) config('dewemoji.free_daily_limit', 30), 1); - $rateLimitEnabled = (bool) config('dewemoji.rate_limit_enabled', true); - - // Local development should not silently look broken because of daily metering. - if (!$rateLimitEnabled || app()->environment('local')) { - return [ - 'blocked' => false, - 'meta' => [ - 'used' => 0, - 'limit' => $dailyLimit, - 'remaining' => $dailyLimit, - 'window' => 'daily', - 'window_ends_at' => Carbon::tomorrow('UTC')->toIso8601String(), - 'count_basis' => 'distinct_query', - 'metering' => 'disabled', - ], - ]; - } - - $key = trim((string) $request->query('key', '')); - if ($key === '') { - $key = trim((string) $request->header('X-License-Key', '')); - } - if ($key === '') { - $key = trim((string) $request->bearerToken()); - } - - $bucketRaw = $key !== '' - ? 'lic|'.$key - : 'ip|'.$request->ip().'|'.(string) $request->userAgent(); - $bucketId = sha1($bucketRaw); - $signature = sha1(strtolower($q).'|'.strtolower($category).'|'.strtolower($subcategory)); - - if (Schema::hasTable('usage_logs')) { - return $this->trackUsageWithDatabase($bucketId, $signature, $dailyLimit); - } - - return $this->trackUsageWithCache($bucketId, $signature, $dailyLimit); - } - - /** - * @return array{blocked:bool,meta:array} - */ - private function trackUsageWithDatabase(string $bucketId, string $signature, int $dailyLimit): array - { - $today = Carbon::today('UTC')->toDateString(); - $blocked = false; - $used = 0; - - DB::transaction(function () use ($bucketId, $signature, $dailyLimit, $today, &$blocked, &$used): void { - $row = DB::table('usage_logs') - ->where('bucket_id', $bucketId) - ->where('date_key', $today) - ->lockForUpdate() - ->first(); - - if (!$row) { - DB::table('usage_logs')->insert([ - 'bucket_id' => $bucketId, - 'date_key' => $today, - 'used' => 0, - 'limit_count' => $dailyLimit, - 'seen_signatures' => json_encode([]), - 'created_at' => now(), - 'updated_at' => now(), - ]); - - $row = (object) [ - 'used' => 0, - 'limit_count' => $dailyLimit, - 'seen_signatures' => json_encode([]), - ]; - } - - $seen = json_decode((string) ($row->seen_signatures ?? '[]'), true); - if (!is_array($seen)) { - $seen = []; - } - - $usedNow = (int) ($row->used ?? 0); - if (!array_key_exists($signature, $seen)) { - if ($usedNow >= $dailyLimit) { - $blocked = true; - } else { - $seen[$signature] = 1; - $usedNow++; - DB::table('usage_logs') - ->where('bucket_id', $bucketId) - ->where('date_key', $today) - ->update([ - 'used' => $usedNow, - 'limit_count' => $dailyLimit, - 'seen_signatures' => json_encode($seen), - 'updated_at' => now(), - ]); - } - } - - $used = min($usedNow, $dailyLimit); - }); - - return [ - 'blocked' => $blocked, - 'meta' => [ - 'used' => $used, - 'limit' => $dailyLimit, - 'remaining' => max(0, $dailyLimit - $used), - 'window' => 'daily', - 'window_ends_at' => Carbon::tomorrow('UTC')->toIso8601String(), - 'count_basis' => 'distinct_query', - ], - ]; - } - - /** - * @return array{blocked:bool,meta:array} - */ - private function trackUsageWithCache(string $bucketId, string $signature, int $dailyLimit): array - { - $cacheKey = 'dw_usage_'.$bucketId.'_'.Carbon::now('UTC')->format('Ymd'); - $state = Cache::get($cacheKey, ['used' => 0, 'seen' => []]); - if (!is_array($state)) { - $state = ['used' => 0, 'seen' => []]; - } - - $blocked = false; - if (!isset($state['seen'][$signature])) { - if ((int) $state['used'] >= $dailyLimit) { - $blocked = true; - } else { - $state['used'] = (int) $state['used'] + 1; - $state['seen'][$signature] = true; - $seconds = max(60, Carbon::now('UTC')->diffInSeconds(Carbon::tomorrow('UTC'))); - Cache::put($cacheKey, $state, $seconds); - } - } - - $used = min((int) $state['used'], $dailyLimit); - return [ - 'blocked' => $blocked, - 'meta' => [ - 'used' => $used, - 'limit' => $dailyLimit, - 'remaining' => max(0, $dailyLimit - $used), - 'window' => 'daily', - 'window_ends_at' => Carbon::tomorrow('UTC')->toIso8601String(), - 'count_basis' => 'distinct_query', - ], - ]; - } - private function summary(string $text, int $max): string { $text = trim(preg_replace('/\s+/', ' ', strip_tags($text)) ?? ''); @@ -824,7 +530,7 @@ class EmojiApiController extends Controller 'X-Dewemoji-Plan' => $tier, 'Vary' => 'Origin', 'Access-Control-Allow-Methods' => (string) config('dewemoji.cors.allow_methods', 'GET, POST, OPTIONS'), - 'Access-Control-Allow-Headers' => (string) config('dewemoji.cors.allow_headers', 'Content-Type, Authorization, X-License-Key, X-Account-Id, X-Dewemoji-Frontend'), + 'Access-Control-Allow-Headers' => (string) config('dewemoji.cors.allow_headers', 'Content-Type, Authorization, X-Api-Key, X-Admin-Token, X-Account-Id, X-Dewemoji-Frontend, X-Extension-Token'), ]; $origin = (string) $request->headers->get('Origin', ''); diff --git a/app/app/Http/Controllers/Api/V1/LicenseController.php b/app/app/Http/Controllers/Api/V1/LicenseController.php deleted file mode 100644 index 7605aec..0000000 --- a/app/app/Http/Controllers/Api/V1/LicenseController.php +++ /dev/null @@ -1,413 +0,0 @@ -extractKey($request); - if ($key === '') { - return $this->response($request, [ - 'ok' => false, - 'error' => 'missing_key', - ], 400); - } - - $check = $this->verification->verify($key, true); - if (!($check['ok'] ?? false)) { - return $this->response($request, [ - 'ok' => false, - 'tier' => self::TIER_FREE, - 'error' => 'invalid_license', - 'details' => $check['details'] ?? [], - ], 401, self::TIER_FREE); - } - - $this->upsertLicense($key, $check); - - return $this->response($request, [ - 'ok' => true, - 'tier' => self::TIER_PRO, - 'source' => $check['source'] ?? 'unknown', - 'plan' => $check['plan'] ?? 'pro', - 'product_id' => $check['product_id'] ?? null, - 'expires_at' => $check['expires_at'] ?? null, - 'error' => null, - ], 200, self::TIER_PRO); - } - - public function activate(Request $request): JsonResponse - { - $key = $this->extractKey($request); - $email = strtolower(trim((string) $request->input('email', ''))); - $product = strtolower(trim((string) $request->input('product', 'site'))); - $deviceId = trim((string) $request->input('device_id', '')); - - if ($key === '') { - return $this->response($request, ['ok' => false, 'error' => 'missing_key'], 400); - } - if ($email === '' || !str_contains($email, '@')) { - return $this->response($request, ['ok' => false, 'error' => 'email_required'], 401); - } - if ($product !== 'site' && $deviceId === '') { - return $this->response($request, ['ok' => false, 'error' => 'device_id_required'], 400); - } - $check = $this->verification->verify($key, true); - if (!($check['ok'] ?? false)) { - return $this->response($request, [ - 'ok' => false, - 'error' => 'invalid_license', - 'details' => $check['details'] ?? [], - ], 401); - } - - $this->upsertLicense($key, $check); - - $userId = hash('sha256', $email); - $device = $product === 'site' ? '__site__' : $deviceId; - $maxDevices = max((int) config('dewemoji.license.max_devices', 3), 1); - - if ($this->hasDatabaseLicenseTables()) { - return $this->activateUsingDatabase($request, $key, $userId, $product, $device, $maxDevices); - } - - return $this->activateUsingCache($request, $key, $userId, $product, $device, $maxDevices); - } - - public function deactivate(Request $request): JsonResponse - { - $key = $this->extractKey($request); - $product = strtolower(trim((string) $request->input('product', 'site'))); - $deviceId = trim((string) $request->input('device_id', '')); - $device = $product === 'site' ? '__site__' : $deviceId; - - if ($key === '') { - return $this->response($request, ['ok' => false, 'error' => 'missing_key'], 400); - } - if ($product !== 'site' && $deviceId === '') { - return $this->response($request, ['ok' => false, 'error' => 'device_id_required'], 400); - } - - if ($this->hasDatabaseLicenseTables()) { - $updated = DB::table('license_activations') - ->where('license_key', $key) - ->where('product', $product) - ->where('device_id', $device) - ->update([ - 'status' => 'revoked', - 'updated_at' => now(), - ]); - - if ($updated < 1) { - return $this->response($request, [ - 'ok' => false, - 'error' => 'activation_not_found', - ], 404); - } - } else { - $state = $this->loadLicenseState($key); - if (!isset($state['activations'][$product][$device])) { - return $this->response($request, [ - 'ok' => false, - 'error' => 'activation_not_found', - ], 404); - } - $state['activations'][$product][$device]['status'] = 'revoked'; - $state['activations'][$product][$device]['updated_at'] = now()->toIso8601String(); - $this->storeLicenseState($key, $state); - } - - return $this->response($request, [ - 'ok' => true, - 'product' => $product, - 'device_id' => $product === 'site' ? null : $device, - ], 200, self::TIER_PRO); - } - - private function activateUsingDatabase( - Request $request, - string $key, - string $userId, - string $product, - string $device, - int $maxDevices - ): JsonResponse { - try { - return DB::transaction(function () use ($request, $key, $userId, $product, $device, $maxDevices) { - $license = DB::table('licenses') - ->where('license_key', $key) - ->lockForUpdate() - ->first(); - - if (!$license) { - return $this->response($request, ['ok' => false, 'error' => 'invalid_license'], 401); - } - - if ((string) ($license->status ?? 'active') !== 'active') { - return $this->response($request, ['ok' => false, 'error' => 'inactive_or_expired'], 403); - } - - if (!empty($license->expires_at) && strtotime((string) $license->expires_at) <= time()) { - return $this->response($request, ['ok' => false, 'error' => 'inactive_or_expired'], 403); - } - - $owner = (string) ($license->owner_user_id ?? ''); - if ($owner !== '' && $owner !== $userId) { - return $this->response($request, [ - 'ok' => false, - 'error' => 'key_already_attached', - ], 403); - } - - if ($owner === '') { - DB::table('licenses') - ->where('license_key', $key) - ->update([ - 'owner_user_id' => $userId, - 'updated_at' => now(), - ]); - } - - $existing = DB::table('license_activations') - ->where('license_key', $key) - ->where('product', $product) - ->where('device_id', $device) - ->first(); - - if ($existing && (string) ($existing->status ?? '') === 'active') { - return $this->response($request, [ - 'ok' => true, - 'pro' => true, - 'product' => $product, - 'device_id' => $product === 'site' ? null : $device, - 'user_id' => $userId, - 'until' => $license->expires_at ?? null, - ], 200, self::TIER_PRO); - } - - $activeCount = (int) DB::table('license_activations') - ->where('license_key', $key) - ->where('product', $product) - ->where('status', 'active') - ->count(); - - if ($activeCount >= $maxDevices) { - return $this->response($request, [ - 'ok' => false, - 'error' => 'device_limit', - 'message' => 'Device limit reached for this license.', - ], 403); - } - - DB::table('license_activations')->updateOrInsert( - [ - 'license_key' => $key, - 'product' => $product, - 'device_id' => $device, - ], - [ - 'user_id' => $userId, - 'status' => 'active', - 'updated_at' => now(), - 'created_at' => $existing ? $existing->created_at : now(), - ] - ); - - return $this->response($request, [ - 'ok' => true, - 'pro' => true, - 'product' => $product, - 'device_id' => $product === 'site' ? null : $device, - 'user_id' => $userId, - 'until' => $license->expires_at ?? null, - ], 200, self::TIER_PRO); - }); - } catch (\Throwable) { - return $this->response($request, [ - 'ok' => false, - 'error' => 'activate_failed', - ], 500); - } - } - - private function activateUsingCache( - Request $request, - string $key, - string $userId, - string $product, - string $device, - int $maxDevices - ): JsonResponse { - $state = $this->loadLicenseState($key); - - if (($state['owner_user_id'] ?? '') !== '' && $state['owner_user_id'] !== $userId) { - return $this->response($request, [ - 'ok' => false, - 'error' => 'key_already_attached', - ], 403); - } - - $state['owner_user_id'] = $userId; - $state['activations'][$product] ??= []; - - if (isset($state['activations'][$product][$device]) && $state['activations'][$product][$device]['status'] === 'active') { - return $this->response($request, [ - 'ok' => true, - 'pro' => true, - 'product' => $product, - 'device_id' => $product === 'site' ? null : $device, - 'user_id' => $userId, - 'until' => null, - ], 200, self::TIER_PRO); - } - - $activeCount = 0; - foreach ($state['activations'][$product] as $row) { - if (($row['status'] ?? '') === 'active') { - $activeCount++; - } - } - - if ($activeCount >= $maxDevices) { - return $this->response($request, [ - 'ok' => false, - 'error' => 'device_limit', - 'message' => 'Device limit reached for this license.', - ], 403); - } - - $state['activations'][$product][$device] = [ - 'status' => 'active', - 'updated_at' => now()->toIso8601String(), - ]; - $this->storeLicenseState($key, $state); - - return $this->response($request, [ - 'ok' => true, - 'pro' => true, - 'product' => $product, - 'device_id' => $product === 'site' ? null : $device, - 'user_id' => $userId, - 'until' => null, - ], 200, self::TIER_PRO); - } - - /** - * @param array $check - */ - private function upsertLicense(string $key, array $check): void - { - if (!$this->hasDatabaseLicenseTables()) { - return; - } - - $expiresAt = null; - if (!empty($check['expires_at'])) { - $ts = strtotime((string) $check['expires_at']); - if ($ts !== false) { - $expiresAt = date('Y-m-d H:i:s', $ts); - } - } - - DB::table('licenses')->updateOrInsert( - ['license_key' => $key], - [ - 'source' => (string) ($check['source'] ?? 'unknown'), - 'plan' => (string) ($check['plan'] ?? 'pro'), - 'status' => 'active', - 'product_id' => isset($check['product_id']) ? (string) $check['product_id'] : null, - 'expires_at' => $expiresAt, - 'meta_json' => json_encode($check['meta'] ?? []), - 'last_verified_at' => now(), - 'created_at' => now(), - 'updated_at' => now(), - ] - ); - } - - private function hasDatabaseLicenseTables(): bool - { - return Schema::hasTable('licenses') && Schema::hasTable('license_activations'); - } - - private function extractKey(Request $request): string - { - $key = trim((string) $request->bearerToken()); - if ($key === '') { - $key = trim((string) $request->header('X-License-Key', '')); - } - if ($key === '') { - $key = trim((string) $request->input('key', $request->query('key', ''))); - } - - return $key; - } - - /** - * @return array{owner_user_id:string,activations:array>>} - */ - private function loadLicenseState(string $key): array - { - $cacheKey = 'dw_license_state_'.sha1($key); - $state = Cache::get($cacheKey); - if (!is_array($state)) { - return [ - 'owner_user_id' => '', - 'activations' => [], - ]; - } - - return $state + [ - 'owner_user_id' => '', - 'activations' => [], - ]; - } - - /** - * @param array{owner_user_id:string,activations:array>>} $state - */ - private function storeLicenseState(string $key, array $state): void - { - $cacheKey = 'dw_license_state_'.sha1($key); - Cache::put($cacheKey, $state, now()->addDays(30)); - } - - /** - * @param array $payload - */ - private function response(Request $request, array $payload, int $status = 200, string $tier = self::TIER_FREE): JsonResponse - { - $headers = [ - 'X-Dewemoji-Tier' => $tier, - 'X-Dewemoji-Plan' => $tier, - 'Vary' => 'Origin', - 'Access-Control-Allow-Methods' => (string) config('dewemoji.cors.allow_methods', 'GET, POST, OPTIONS'), - 'Access-Control-Allow-Headers' => (string) config('dewemoji.cors.allow_headers', 'Content-Type, Authorization, X-License-Key, X-Account-Id, X-Dewemoji-Frontend'), - ]; - - $origin = (string) $request->headers->get('Origin', ''); - $allowedOrigins = config('dewemoji.cors.allowed_origins', []); - if (is_array($allowedOrigins) && in_array($origin, $allowedOrigins, true)) { - $headers['Access-Control-Allow-Origin'] = $origin; - } - - return response()->json($payload, $status, $headers); - } -} diff --git a/app/app/Http/Controllers/Api/V1/SystemController.php b/app/app/Http/Controllers/Api/V1/SystemController.php index fe0039a..d13f442 100644 --- a/app/app/Http/Controllers/Api/V1/SystemController.php +++ b/app/app/Http/Controllers/Api/V1/SystemController.php @@ -120,7 +120,7 @@ class SystemController extends Controller 'X-Dewemoji-Plan' => 'free', 'Vary' => 'Origin', 'Access-Control-Allow-Methods' => (string) config('dewemoji.cors.allow_methods', 'GET, POST, OPTIONS'), - 'Access-Control-Allow-Headers' => (string) config('dewemoji.cors.allow_headers', 'Content-Type, Authorization, X-License-Key, X-Account-Id, X-Dewemoji-Frontend'), + 'Access-Control-Allow-Headers' => (string) config('dewemoji.cors.allow_headers', 'Content-Type, Authorization, X-Api-Key, X-Admin-Token, X-Account-Id, X-Dewemoji-Frontend, X-Extension-Token'), ]; $origin = (string) $request->headers->get('Origin', ''); @@ -132,4 +132,3 @@ class SystemController extends Controller return response()->json($payload, $status, $headers); } } - diff --git a/app/app/Services/Billing/LicenseVerificationService.php b/app/app/Services/Billing/LicenseVerificationService.php deleted file mode 100644 index 87a2813..0000000 --- a/app/app/Services/Billing/LicenseVerificationService.php +++ /dev/null @@ -1,408 +0,0 @@ -, - * details:array - * } - */ - public function verify(string $key, bool $fresh = false): array - { - $key = trim($key); - if ($key === '') { - return $this->invalid('missing_key'); - } - - $ttl = max((int) config('dewemoji.billing.verify_cache_ttl', 300), 0); - if ($fresh || $ttl === 0) { - return $this->verifyNow($key); - } - - $cacheKey = 'dw_license_verify_'.sha1($key); - - /** @var array{ - * ok:bool, - * tier:string, - * source:string, - * error:?string, - * plan:string, - * product_id:?string, - * expires_at:?string, - * meta:array, - * details:array - * } $result */ - $result = Cache::remember($cacheKey, now()->addSeconds($ttl), fn (): array => $this->verifyNow($key)); - return $result; - } - - public function isPro(string $key): bool - { - $result = $this->verify($key); - return (bool) ($result['ok'] ?? false); - } - - /** - * @return array{ - * ok:bool, - * tier:string, - * source:string, - * error:?string, - * plan:string, - * product_id:?string, - * expires_at:?string, - * meta:array, - * details:array - * } - */ - private function verifyNow(string $key): array - { - $mode = strtolower((string) config('dewemoji.billing.mode', 'sandbox')); - - if ($mode === 'sandbox') { - return $this->ok('sandbox', 'pro', null, null, ['mode' => 'sandbox']); - } - - if ((bool) config('dewemoji.license.accept_all', false)) { - return $this->ok('accept_all', 'pro', null, null, ['mode' => 'accept_all']); - } - - $validKeys = config('dewemoji.license.pro_keys', []); - if (is_array($validKeys) && in_array($key, $validKeys, true)) { - return $this->ok('key_list', 'pro', null, null, ['mode' => 'key_list']); - } - - $gum = $this->verifyWithGumroad($key); - if ($gum['ok']) { - return $this->ok( - 'gumroad', - (string) ($gum['plan'] ?? 'pro'), - $gum['product_id'] ?? null, - $gum['expires_at'] ?? null, - is_array($gum['meta'] ?? null) ? $gum['meta'] : [] - ); - } - - $may = $this->verifyWithMayar($key); - if ($may['ok']) { - return $this->ok( - 'mayar', - (string) ($may['plan'] ?? 'pro'), - $may['product_id'] ?? null, - $may['expires_at'] ?? null, - is_array($may['meta'] ?? null) ? $may['meta'] : [] - ); - } - - return $this->invalid('invalid_license', [ - 'gumroad' => $gum['err'] ?? 'not_checked', - 'mayar' => $may['err'] ?? 'not_checked', - ]); - } - - /** - * @return array{ - * ok:bool, - * err?:string, - * plan?:string, - * product_id?:?string, - * expires_at?:?string, - * meta?:array - * } - */ - private function verifyWithGumroad(string $key): array - { - if (!(bool) config('dewemoji.billing.providers.gumroad.enabled', false)) { - return ['ok' => false, 'err' => 'gumroad_disabled']; - } - - $stubKeys = config('dewemoji.billing.providers.gumroad.test_keys', []); - if (is_array($stubKeys) && in_array($key, $stubKeys, true)) { - return [ - 'ok' => true, - 'plan' => 'pro', - 'product_id' => null, - 'expires_at' => null, - 'meta' => ['stub' => true], - ]; - } - - $url = trim((string) config('dewemoji.billing.providers.gumroad.verify_url', '')); - $productIds = config('dewemoji.billing.providers.gumroad.product_ids', []); - if ($url === '') { - return ['ok' => false, 'err' => 'gumroad_missing_url']; - } - if (!is_array($productIds)) { - $productIds = []; - } - - try { - $timeout = max((int) config('dewemoji.billing.providers.gumroad.timeout', 8), 1); - $idsToTry = count($productIds) > 0 ? $productIds : [null]; - foreach ($idsToTry as $pid) { - $payload = [ - 'license_key' => $key, - 'increment_uses_count' => false, - ]; - if (is_string($pid) && trim($pid) !== '') { - $payload['product_id'] = trim($pid); - } - - $response = Http::asForm() - ->timeout($timeout) - ->post($url, $payload); - - if (!$response->successful()) { - continue; - } - - $json = $response->json(); - if (!is_array($json) || (($json['success'] ?? false) !== true)) { - continue; - } - - $purchase = is_array($json['purchase'] ?? null) ? $json['purchase'] : []; - if (!$this->isTruthy($purchase['is_valid'] ?? true)) { - continue; - } - if ($this->isTruthy($purchase['refunded'] ?? false)) { - continue; - } - if ($this->isTruthy($purchase['chargebacked'] ?? false)) { - continue; - } - $isRecurring = !empty($purchase['recurrence']); - - return [ - 'ok' => true, - 'plan' => 'pro', - 'product_id' => (string) ($purchase['product_id'] ?? ($payload['product_id'] ?? '')) ?: null, - 'expires_at' => null, - 'meta' => [ - 'plan_type' => $isRecurring ? 'subscription' : 'lifetime', - ], - ]; - } - - return ['ok' => false, 'err' => 'gumroad_no_match']; - } catch (\Throwable) { - return ['ok' => false, 'err' => 'gumroad_verify_failed']; - } - } - - /** - * @return array{ - * ok:bool, - * err?:string, - * plan?:string, - * product_id?:?string, - * expires_at?:?string, - * meta?:array - * } - */ - private function verifyWithMayar(string $key): array - { - if (!(bool) config('dewemoji.billing.providers.mayar.enabled', false)) { - return ['ok' => false, 'err' => 'mayar_disabled']; - } - - $stubKeys = config('dewemoji.billing.providers.mayar.test_keys', []); - if (is_array($stubKeys) && in_array($key, $stubKeys, true)) { - return [ - 'ok' => true, - 'plan' => 'pro', - 'product_id' => null, - 'expires_at' => null, - 'meta' => ['stub' => true], - ]; - } - - $url = trim((string) config('dewemoji.billing.providers.mayar.verify_url', '')); - $apiBase = rtrim((string) config('dewemoji.billing.providers.mayar.api_base', ''), '/'); - $verifyEndpoint = '/'.ltrim((string) config('dewemoji.billing.providers.mayar.endpoint_verify', '/v1/license/verify'), '/'); - if ($url === '' && $apiBase !== '') { - $url = $apiBase.$verifyEndpoint; - } - $apiKey = trim((string) config('dewemoji.billing.providers.mayar.api_key', '')); - if ($apiKey === '') { - $apiKey = trim((string) config('dewemoji.billing.providers.mayar.secret_key', '')); - } - $productIds = config('dewemoji.billing.providers.mayar.product_ids', []); - if (!is_array($productIds)) { - $productIds = []; - } - if ($url === '') { - return ['ok' => false, 'err' => 'mayar_missing_url']; - } - - try { - $timeout = max((int) config('dewemoji.billing.providers.mayar.timeout', 8), 1); - $request = Http::timeout($timeout) - ->withHeaders(['Accept' => 'application/json']); - if ($apiKey !== '') { - $request = $request->withToken($apiKey) - ->withHeaders(['X-API-Key' => $apiKey]); - } - - $response = $request->post($url, [ - 'license_key' => $key, - 'license' => $key, - 'key' => $key, - ]); - - if (!$response->successful()) { - return ['ok' => false, 'err' => 'mayar_http_'.$response->status()]; - } - - $json = $response->json(); - if (!is_array($json)) { - return ['ok' => false, 'err' => 'mayar_invalid_json']; - } - - $data = is_array($json['data'] ?? null) ? $json['data'] : []; - $status = strtolower((string) ($data['status'] ?? $json['status'] ?? '')); - $valid = (($json['success'] ?? false) === true) - || (($json['valid'] ?? false) === true) - || (($data['valid'] ?? false) === true) - || in_array($status, ['active', 'paid', 'completed', 'valid'], true); - if (!$valid) { - return ['ok' => false, 'err' => 'mayar_invalid']; - } - - $productId = $this->firstString([ - $data['product_id'] ?? null, - $data['productId'] ?? null, - $data['product_code'] ?? null, - $json['product_id'] ?? null, - ]); - if (!empty($productIds) && ($productId === null || !in_array($productId, $productIds, true))) { - return ['ok' => false, 'err' => 'mayar_no_match']; - } - - $planType = strtolower((string) ($data['type'] ?? 'lifetime')); - $expiresAt = $this->firstString([ - $data['expires_at'] ?? null, - $data['expired_at'] ?? null, - $data['expiry_date'] ?? null, - $data['valid_until'] ?? null, - $json['expires_at'] ?? null, - ]); - return [ - 'ok' => true, - 'plan' => 'pro', - 'product_id' => $productId, - 'expires_at' => $expiresAt, - 'meta' => [ - 'plan_type' => $planType, - ], - ]; - } catch (\Throwable) { - return ['ok' => false, 'err' => 'mayar_verify_failed']; - } - } - - /** - * @param array $meta - * @return array{ - * ok:bool, - * tier:string, - * source:string, - * error:?string, - * plan:string, - * product_id:?string, - * expires_at:?string, - * meta:array, - * details:array - * } - */ - private function ok(string $source, string $plan, ?string $productId, ?string $expiresAt, array $meta): array - { - return [ - 'ok' => true, - 'tier' => 'pro', - 'source' => $source, - 'error' => null, - 'plan' => $plan, - 'product_id' => $productId, - 'expires_at' => $expiresAt, - 'meta' => $meta, - 'details' => [], - ]; - } - - /** - * @param array $details - * @return array{ - * ok:bool, - * tier:string, - * source:string, - * error:?string, - * plan:string, - * product_id:?string, - * expires_at:?string, - * meta:array, - * details:array - * } - */ - private function invalid(string $error, array $details = []): array - { - return [ - 'ok' => false, - 'tier' => 'free', - 'source' => 'none', - 'error' => $error, - 'plan' => 'free', - 'product_id' => null, - 'expires_at' => null, - 'meta' => [], - 'details' => $details, - ]; - } - - private function isTruthy(mixed $value): bool - { - if (is_bool($value)) { - return $value; - } - if (is_numeric($value)) { - return (int) $value === 1; - } - if (is_string($value)) { - $normalized = strtolower(trim($value)); - return in_array($normalized, ['1', 'true', 'yes', 'y', 'on'], true); - } - - return false; - } - - /** - * @param array $values - */ - private function firstString(array $values): ?string - { - foreach ($values as $value) { - if (!is_string($value)) { - continue; - } - $value = trim($value); - if ($value !== '') { - return $value; - } - } - - return null; - } -} diff --git a/app/config/dewemoji.php b/app/config/dewemoji.php index d4b65f2..2bb46af 100644 --- a/app/config/dewemoji.php +++ b/app/config/dewemoji.php @@ -10,40 +10,9 @@ return [ 'pro_max_limit' => (int) env('DEWEMOJI_PRO_MAX_LIMIT', 50), ], - 'free_daily_limit' => (int) env('DEWEMOJI_FREE_DAILY_LIMIT', 30), - 'rate_limit_enabled' => filter_var(env('DEWEMOJI_RATE_LIMIT_ENABLED', true), FILTER_VALIDATE_BOOL), - - 'license' => [ - 'accept_all' => filter_var(env('DEWEMOJI_LICENSE_ACCEPT_ALL', false), FILTER_VALIDATE_BOOL), - 'pro_keys' => array_values(array_filter(array_map('trim', explode(',', (string) env('DEWEMOJI_PRO_KEYS', ''))))), - 'max_devices' => (int) env('DEWEMOJI_LICENSE_MAX_DEVICES', 3), - ], - 'billing' => [ - // sandbox: accepts any non-empty key, live: requires configured pro keys/provider validation. 'mode' => env('DEWEMOJI_BILLING_MODE', 'sandbox'), - 'verify_cache_ttl' => (int) env('DEWEMOJI_VERIFY_CACHE_TTL', 300), 'providers' => [ - 'gumroad' => [ - 'enabled' => filter_var(env('DEWEMOJI_GUMROAD_ENABLED', false), FILTER_VALIDATE_BOOL), - 'verify_url' => env('DEWEMOJI_GUMROAD_VERIFY_URL', 'https://api.gumroad.com/v2/licenses/verify'), - 'product_ids' => array_values(array_filter(array_map('trim', explode(',', (string) env('DEWEMOJI_GUMROAD_PRODUCT_IDS', ''))))), - 'timeout' => (int) env('DEWEMOJI_GUMROAD_TIMEOUT', 8), - // Optional stub keys for local testing without external HTTP calls. - 'test_keys' => array_values(array_filter(array_map('trim', explode(',', (string) env('DEWEMOJI_GUMROAD_TEST_KEYS', ''))))), - ], - 'mayar' => [ - 'enabled' => filter_var(env('DEWEMOJI_MAYAR_ENABLED', false), FILTER_VALIDATE_BOOL), - 'verify_url' => env('DEWEMOJI_MAYAR_VERIFY_URL', ''), - 'api_base' => env('DEWEMOJI_MAYAR_API_BASE', ''), - 'endpoint_verify' => env('DEWEMOJI_MAYAR_ENDPOINT_VERIFY', '/v1/license/verify'), - 'product_ids' => array_values(array_filter(array_map('trim', explode(',', (string) env('DEWEMOJI_MAYAR_PRODUCT_IDS', ''))))), - 'api_key' => env('DEWEMOJI_MAYAR_API_KEY', ''), - 'secret_key' => env('DEWEMOJI_MAYAR_SECRET_KEY', ''), - 'timeout' => (int) env('DEWEMOJI_MAYAR_TIMEOUT', 8), - // Optional stub keys for local testing without external HTTP calls. - 'test_keys' => array_values(array_filter(array_map('trim', explode(',', (string) env('DEWEMOJI_MAYAR_TEST_KEYS', ''))))), - ], 'paypal' => [ 'enabled' => filter_var(env('DEWEMOJI_PAYPAL_ENABLED', false), FILTER_VALIDATE_BOOL), 'timeout' => (int) env('DEWEMOJI_PAYPAL_TIMEOUT', 10), @@ -87,7 +56,7 @@ return [ 'cors' => [ 'allowed_origins' => array_values(array_filter(array_map('trim', explode(',', (string) env('DEWEMOJI_ALLOWED_ORIGINS', 'http://127.0.0.1:8000,http://localhost:8000,https://dewemoji.com,https://www.dewemoji.com'))))), 'allow_methods' => 'GET, POST, OPTIONS', - 'allow_headers' => 'Content-Type, Authorization, X-License-Key, X-Api-Key, X-Admin-Token, X-Account-Id, X-Dewemoji-Frontend, X-Extension-Token', + 'allow_headers' => 'Content-Type, Authorization, X-Api-Key, X-Admin-Token, X-Account-Id, X-Dewemoji-Frontend, X-Extension-Token', ], 'admin' => [ diff --git a/app/references/email-branded-designs/license-key-issued.html b/app/references/email-branded-designs/license-key-issued.html deleted file mode 100644 index d50409b..0000000 --- a/app/references/email-branded-designs/license-key-issued.html +++ /dev/null @@ -1,103 +0,0 @@ - - - - - - License key issued - - - - - - -
- - - - - - - - - - - - -
- - - - - -
- - - - - -
- Dewemoji - - Dewemoji -
-
- License -
-
- - - - - - - - - - - - - - - - -
- - - - - -
- 🔑 - - License key issued -
-
- Your license key is ready. Use it to unlock Dewemoji in the extension and API. -
- License: {{ license_key }}
- Tier: {{ tier }}
- Devices: {{ max_devices }} -
- - Manage License - -
- Keep your license secure. You can rotate it anytime from your dashboard. -
-
- - - - - - - -
- Need help getting started? Visit your API docs from the dashboard. -
- Dewemoji • Emoji discovery and keywords for creators -
-
-
- - diff --git a/app/resources/views/site/api-docs.blade.php b/app/resources/views/site/api-docs.blade.php index ed5736d..1f599c0 100644 --- a/app/resources/views/site/api-docs.blade.php +++ b/app/resources/views/site/api-docs.blade.php @@ -1,7 +1,7 @@ @extends('site.layout') @section('title', 'API Docs - Dewemoji') -@section('meta_description', 'Dewemoji API docs for emoji search, categories, license verification, activation, and system endpoints.') +@section('meta_description', 'Dewemoji API docs for emoji search, categories, account API keys, and system endpoints.') @push('head')