From a4d2031117a6f60fc70a949e2434b5f52bb4aef6 Mon Sep 17 00:00:00 2001 From: Dwindi Ramadhana Date: Wed, 4 Feb 2026 08:52:22 +0700 Subject: [PATCH] feat: harden billing verification and add browse route parity --- app/.env.example | 24 + .../Controllers/Api/V1/EmojiApiController.php | 426 +++++++++++++++-- .../Controllers/Api/V1/LicenseController.php | 435 ++++++++++++++++-- .../Controllers/Api/V1/SystemController.php | 135 ++++++ .../Http/Controllers/Web/SiteController.php | 93 +++- .../Middleware/CanonicalPathMiddleware.php | 33 ++ .../Billing/LicenseVerificationService.php | 335 ++++++++++++++ app/bootstrap/app.php | 5 +- app/config/dewemoji.php | 49 +- ...026_02_04_000100_create_licenses_table.php | 32 ++ ...00200_create_license_activations_table.php | 31 ++ ...6_02_04_000300_create_usage_logs_table.php | 29 ++ app/resources/views/site/home.blade.php | 69 ++- app/resources/views/site/layout.blade.php | 9 + app/routes/api.php | 26 +- app/routes/web.php | 9 + app/tests/Feature/ApiV1EndpointsTest.php | 172 ++++++- app/tests/Feature/SitePagesTest.php | 4 +- billing-sandbox-live.md | 58 +++ rebuild-progress.md | 250 ++++++++-- 20 files changed, 2080 insertions(+), 144 deletions(-) create mode 100644 app/app/Http/Controllers/Api/V1/SystemController.php create mode 100644 app/app/Http/Middleware/CanonicalPathMiddleware.php create mode 100644 app/app/Services/Billing/LicenseVerificationService.php create mode 100644 app/database/migrations/2026_02_04_000100_create_licenses_table.php create mode 100644 app/database/migrations/2026_02_04_000200_create_license_activations_table.php create mode 100644 app/database/migrations/2026_02_04_000300_create_usage_logs_table.php create mode 100644 billing-sandbox-live.md diff --git a/app/.env.example b/app/.env.example index 2f4d980..434f29a 100644 --- a/app/.env.example +++ b/app/.env.example @@ -67,5 +67,29 @@ VITE_APP_NAME="${APP_NAME}" DEWEMOJI_DATA_PATH= DEWEMOJI_DEFAULT_LIMIT=20 DEWEMOJI_MAX_LIMIT=50 +DEWEMOJI_FREE_MAX_LIMIT=20 +DEWEMOJI_PRO_MAX_LIMIT=50 +DEWEMOJI_FREE_DAILY_LIMIT=30 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_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 diff --git a/app/app/Http/Controllers/Api/V1/EmojiApiController.php b/app/app/Http/Controllers/Api/V1/EmojiApiController.php index 138f01a..7aecfdb 100644 --- a/app/app/Http/Controllers/Api/V1/EmojiApiController.php +++ b/app/app/Http/Controllers/Api/V1/EmojiApiController.php @@ -2,9 +2,14 @@ namespace App\Http\Controllers\Api\V1; +use App\Services\Billing\LicenseVerificationService; 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 @@ -15,23 +20,42 @@ class EmojiApiController extends Controller /** @var array|null */ private static ?array $dataset = null; - public function categories(): JsonResponse + public function __construct( + private readonly LicenseVerificationService $verification + ) { + } + + /** @var array */ + private const CATEGORY_MAP = [ + 'all' => 'all', + 'smileys' => 'Smileys & Emotion', + 'people' => 'People & Body', + 'animals' => 'Animals & Nature', + 'food' => 'Food & Drink', + 'travel' => 'Travel & Places', + 'activities' => 'Activities', + 'objects' => 'Objects', + 'symbols' => 'Symbols', + 'flags' => 'Flags', + ]; + + public function categories(Request $request): JsonResponse { - $tier = $this->detectTier(request()); + $tier = $this->detectTier($request); try { $data = $this->loadData(); } catch (RuntimeException $e) { - return $this->jsonWithTier([ + return $this->jsonWithTier($request, [ 'error' => 'data_load_failed', 'message' => $e->getMessage(), ], $tier, 500); } - $items = $data['emojis'] ?? []; + $items = $data['emojis'] ?? []; $map = []; foreach ($items as $item) { - $category = (string) ($item['category'] ?? ''); - $subcategory = (string) ($item['subcategory'] ?? ''); + $category = trim((string) ($item['category'] ?? '')); + $subcategory = trim((string) ($item['subcategory'] ?? '')); if ($category === '' || $subcategory === '') { continue; } @@ -47,7 +71,18 @@ class EmojiApiController extends Controller } ksort($out, SORT_NATURAL | SORT_FLAG_CASE); - return $this->jsonWithTier($out, $tier); + $etag = '"'.sha1(json_encode($out)).'"'; + if ($this->isNotModified($request, $etag)) { + return $this->jsonWithTier($request, [], $tier, 304, [ + 'ETag' => $etag, + 'Cache-Control' => 'public, max-age=3600', + ]); + } + + return $this->jsonWithTier($request, $out, $tier, 200, [ + 'ETag' => $etag, + 'Cache-Control' => 'public, max-age=3600', + ]); } public function emojis(Request $request): JsonResponse @@ -56,31 +91,34 @@ class EmojiApiController extends Controller try { $data = $this->loadData(); } catch (RuntimeException $e) { - return $this->jsonWithTier([ + return $this->jsonWithTier($request, [ 'error' => 'data_load_failed', 'message' => $e->getMessage(), ], $tier, 500); } - $items = $data['emojis'] ?? []; + $items = $data['emojis'] ?? []; $q = trim((string) ($request->query('q', $request->query('query', '')))); - $category = trim((string) $request->query('category', '')); - $subcategory = trim((string) $request->query('subcategory', '')); + $category = $this->normalizeCategoryFilter((string) $request->query('category', '')); + $subSlug = $this->slugify((string) $request->query('subcategory', '')); $page = max((int) $request->query('page', 1), 1); $defaultLimit = max((int) config('dewemoji.pagination.default_limit', 20), 1); - $maxLimit = max((int) config('dewemoji.pagination.max_limit', 50), 1); + $maxLimit = $tier === self::TIER_PRO + ? max((int) config('dewemoji.pagination.pro_max_limit', 50), 1) + : max((int) config('dewemoji.pagination.free_max_limit', 20), 1); $limit = min(max((int) $request->query('limit', $defaultLimit), 1), $maxLimit); - $filtered = array_values(array_filter($items, function (array $item) use ($q, $category, $subcategory): bool { - if ($category !== '' && strcasecmp((string) ($item['category'] ?? ''), $category) !== 0) { + $filtered = array_values(array_filter($items, function (array $item) use ($q, $category, $subSlug): bool { + $itemCategory = trim((string) ($item['category'] ?? '')); + $itemSubcategory = trim((string) ($item['subcategory'] ?? '')); + + if ($category !== '' && strcasecmp($itemCategory, $category) !== 0) { return false; } - - if ($subcategory !== '' && strcasecmp((string) ($item['subcategory'] ?? ''), $subcategory) !== 0) { + if ($subSlug !== '' && $this->slugify($itemSubcategory) !== $subSlug) { return false; } - if ($q === '') { return true; } @@ -89,8 +127,8 @@ class EmojiApiController extends Controller (string) ($item['emoji'] ?? ''), (string) ($item['name'] ?? ''), (string) ($item['slug'] ?? ''), - (string) ($item['category'] ?? ''), - (string) ($item['subcategory'] ?? ''), + $itemCategory, + $itemSubcategory, implode(' ', $item['keywords_en'] ?? []), implode(' ', $item['keywords_id'] ?? []), implode(' ', $item['aliases'] ?? []), @@ -99,21 +137,123 @@ class EmojiApiController extends Controller implode(' ', $item['intent_tags'] ?? []), ])); - return str_contains($haystack, strtolower($q)); + $tokens = preg_split('/\s+/', strtolower($q)) ?: []; + foreach ($tokens as $token) { + if ($token === '') { + continue; + } + if (!str_contains($haystack, $token)) { + return false; + } + } + return true; })); $total = count($filtered); $offset = ($page - 1) * $limit; $pageItems = array_slice($filtered, $offset, $limit); - $outItems = array_map(fn (array $item): array => $this->transformItem($item, $tier), $pageItems); - return $this->jsonWithTier([ + $responsePayload = [ 'items' => $outItems, 'total' => $total, 'page' => $page, 'limit' => $limit, - ], $tier); + ]; + + $etag = '"'.sha1(json_encode([$responsePayload, $tier, $q, $category, $subSlug])).'"'; + if ($this->isNotModified($request, $etag)) { + return $this->jsonWithTier($request, [], $tier, 304, [ + 'ETag' => $etag, + 'Cache-Control' => 'public, max-age=120', + ]); + } + + if ($tier === self::TIER_FREE && $page === 1) { + $usage = $this->trackDailyUsage($request, $q, $category, $subSlug); + if ($usage['blocked']) { + return $this->jsonWithTier($request, [ + 'ok' => false, + 'error' => 'daily_limit_reached', + 'message' => 'Daily free limit reached. Upgrade to Pro for unlimited usage.', + 'plan' => self::TIER_FREE, + 'usage' => $usage['meta'], + ], $tier, 429, [ + 'X-RateLimit-Limit' => (string) $usage['meta']['limit'], + 'X-RateLimit-Remaining' => '0', + 'X-RateLimit-Reset' => (string) strtotime('tomorrow 00:00:00 UTC'), + 'ETag' => $etag, + 'Cache-Control' => 'public, max-age=120', + ]); + } + + $responsePayload['plan'] = self::TIER_FREE; + $responsePayload['usage'] = $usage['meta']; + + return $this->jsonWithTier($request, $responsePayload, $tier, 200, [ + 'X-RateLimit-Limit' => (string) $usage['meta']['limit'], + 'X-RateLimit-Remaining' => (string) $usage['meta']['remaining'], + 'X-RateLimit-Reset' => (string) strtotime('tomorrow 00:00:00 UTC'), + 'ETag' => $etag, + 'Cache-Control' => 'public, max-age=120', + ]); + } + + return $this->jsonWithTier($request, $responsePayload, $tier, 200, [ + 'ETag' => $etag, + 'Cache-Control' => 'public, max-age=120', + ]); + } + + public function emoji(Request $request, ?string $slug = null): JsonResponse + { + $tier = $this->detectTier($request); + $slug = trim((string) ($slug ?? $request->query('slug', ''))); + if ($slug === '') { + return $this->jsonWithTier($request, [ + 'ok' => false, + 'error' => 'missing_slug', + ], $tier, 400); + } + + try { + $data = $this->loadData(); + } catch (RuntimeException $e) { + return $this->jsonWithTier($request, [ + 'error' => 'data_load_failed', + 'message' => $e->getMessage(), + ], $tier, 500); + } + + $items = $data['emojis'] ?? []; + $match = null; + foreach ($items as $item) { + if (strcasecmp((string) ($item['slug'] ?? ''), $slug) === 0) { + $match = $item; + break; + } + } + if ($match === null) { + return $this->jsonWithTier($request, [ + 'ok' => false, + 'error' => 'not_found', + 'slug' => $slug, + ], $tier, 404); + } + + $payload = $this->transformEmojiDetail($match, $tier); + $etag = '"'.sha1(json_encode([$payload, $tier])).'"'; + if ($this->isNotModified($request, $etag)) { + return $this->jsonWithTier($request, [], $tier, 304, [ + 'ETag' => $etag, + 'Cache-Control' => 'public, max-age=300', + ]); + } + + return $this->jsonWithTier($request, $payload, $tier, 200, [ + 'ETag' => $etag, + 'Cache-Control' => 'public, max-age=300', + ]); } private function detectTier(Request $request): string @@ -122,22 +262,26 @@ class EmojiApiController extends Controller if ($key === '') { $key = trim((string) $request->header('X-License-Key', '')); } + if ($key === '') { + $key = trim((string) $request->query('key', '')); + } + if ($key === '') { return self::TIER_FREE; } - - if ((bool) config('dewemoji.license.accept_all', false)) { - return self::TIER_PRO; - } - - $validKeys = config('dewemoji.license.pro_keys', []); - if (is_array($validKeys) && in_array($key, $validKeys, true)) { + if ($this->verification->isPro($key)) { return self::TIER_PRO; } return self::TIER_FREE; } + private function isNotModified(Request $request, string $etag): bool + { + $ifNoneMatch = trim((string) $request->header('If-None-Match', '')); + return $ifNoneMatch !== '' && $ifNoneMatch === $etag; + } + /** * @return array */ @@ -163,34 +307,65 @@ class EmojiApiController extends Controller } self::$dataset = $decoded; - return self::$dataset; } + private function normalizeCategoryFilter(string $category): string + { + $value = strtolower(trim($category)); + if ($value === '' || $value === 'all') { + return ''; + } + + return self::CATEGORY_MAP[$value] ?? $category; + } + + private function slugify(string $text): string + { + $value = strtolower(trim($text)); + $value = str_replace('&', 'and', $value); + $value = preg_replace('/[^a-z0-9]+/', '-', $value) ?? ''; + return trim($value, '-'); + } + /** * @param array $item * @return array */ private function transformItem(array $item, string $tier): array { + $supportsTone = (bool) ($item['supports_skin_tone'] ?? false); + $emoji = (string) ($item['emoji'] ?? ''); + $out = [ - 'emoji' => (string) ($item['emoji'] ?? ''), + 'emoji' => $emoji, 'name' => (string) ($item['name'] ?? ''), 'slug' => (string) ($item['slug'] ?? ''), 'category' => (string) ($item['category'] ?? ''), 'subcategory' => (string) ($item['subcategory'] ?? ''), - 'supports_skin_tone' => (bool) ($item['supports_skin_tone'] ?? false), + 'supports_skin_tone' => $supportsTone, 'summary' => $this->summary((string) ($item['description'] ?? ''), 150), ]; + if ($supportsTone && $emoji !== '') { + $base = preg_replace('/\x{1F3FB}|\x{1F3FC}|\x{1F3FD}|\x{1F3FE}|\x{1F3FF}/u', '', $emoji) ?? $emoji; + $out['emoji_base'] = $base; + if ($tier === self::TIER_PRO) { + $out['variants'] = array_map( + fn (string $tone): string => $base.$tone, + ["\u{1F3FB}", "\u{1F3FC}", "\u{1F3FD}", "\u{1F3FE}", "\u{1F3FF}"] + ); + } + } + if ($tier === self::TIER_PRO) { $out += [ 'unified' => (string) ($item['unified'] ?? ''), 'codepoints' => $item['codepoints'] ?? [], 'shortcodes' => $item['shortcodes'] ?? [], 'aliases' => $item['aliases'] ?? [], - 'keywords_en' => $item['keywords_en'] ?? [], - 'keywords_id' => $item['keywords_id'] ?? [], + 'keywords_en' => array_slice($item['keywords_en'] ?? [], 0, 30), + 'keywords_id' => array_slice($item['keywords_id'] ?? [], 0, 30), 'related' => $item['related'] ?? [], 'intent_tags' => $item['intent_tags'] ?? [], 'description' => (string) ($item['description'] ?? ''), @@ -200,6 +375,162 @@ class EmojiApiController extends Controller return $out; } + /** + * @param array $item + * @return array + */ + private function transformEmojiDetail(array $item, string $tier): array + { + $payload = $this->transformItem($item, $tier); + $payload['permalink'] = (string) ($item['permalink'] ?? ''); + $payload['title'] = (string) ($item['title'] ?? $item['name'] ?? ''); + $payload['meta_title'] = (string) ($item['meta_title'] ?? ''); + $payload['meta_description'] = (string) ($item['meta_description'] ?? ''); + $payload['usage_examples'] = $item['usage_examples'] ?? []; + $payload['alt_shortcodes'] = $item['alt_shortcodes'] ?? []; + + 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); + + $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)) ?? ''); @@ -212,15 +543,24 @@ class EmojiApiController extends Controller /** * @param array $payload + * @param array $extraHeaders */ - private function jsonWithTier(array $payload, string $tier, int $status = 200): JsonResponse + private function jsonWithTier(Request $request, array $payload, string $tier, int $status = 200, array $extraHeaders = []): JsonResponse { - return response() - ->json($payload, $status, [ - 'X-Dewemoji-Tier' => $tier, - 'Access-Control-Allow-Origin' => '*', - 'Access-Control-Allow-Methods' => 'GET, POST, OPTIONS', - 'Access-Control-Allow-Headers' => 'Content-Type, Authorization, X-License-Key, X-Account-Id, X-Dewemoji-Frontend', - ]); + $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 + $extraHeaders); } } diff --git a/app/app/Http/Controllers/Api/V1/LicenseController.php b/app/app/Http/Controllers/Api/V1/LicenseController.php index 997459a..7605aec 100644 --- a/app/app/Http/Controllers/Api/V1/LicenseController.php +++ b/app/app/Http/Controllers/Api/V1/LicenseController.php @@ -2,75 +2,412 @@ namespace App\Http\Controllers\Api\V1; +use App\Services\Billing\LicenseVerificationService; use App\Http\Controllers\Controller; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Schema; class LicenseController extends Controller { - public function verify(Request $request): JsonResponse - { - $key = trim((string) $request->input('key', '')); - $accountId = trim((string) $request->input('account_id', '')); - $version = trim((string) $request->input('version', '')); + private const TIER_FREE = 'free'; + private const TIER_PRO = 'pro'; - if ($key === '') { - return $this->response([ - 'ok' => false, - 'error' => 'missing_key', - ]); - } - - if ($accountId === '') { - return $this->response([ - 'ok' => false, - 'error' => 'missing_account_id', - ]); - } - - if ($version === '') { - return $this->response([ - 'ok' => false, - 'error' => 'missing_version', - ]); - } - - $valid = $this->isValidKey($key); - - return $this->response([ - 'ok' => $valid, - 'tier' => $valid ? 'pro' : 'free', - 'error' => $valid ? null : 'invalid_license', - ]); + public function __construct( + private readonly LicenseVerificationService $verification + ) { } - private function isValidKey(string $key): bool + public function verify(Request $request): JsonResponse { - if ((bool) config('dewemoji.license.accept_all', false)) { - return true; + $key = $this->extractKey($request); + if ($key === '') { + return $this->response($request, [ + 'ok' => false, + 'error' => 'missing_key', + ], 400); } - $validKeys = config('dewemoji.license.pro_keys', []); - if (!is_array($validKeys)) { - return false; + $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); } - return in_array($key, $validKeys, true); + $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(array $payload, int $status = 200): JsonResponse + private function response(Request $request, array $payload, int $status = 200, string $tier = self::TIER_FREE): JsonResponse { - $tier = ($payload['ok'] ?? false) ? 'pro' : 'free'; - - return response()->json($payload, $status, [ + $headers = [ 'X-Dewemoji-Tier' => $tier, - 'Access-Control-Allow-Origin' => '*', - 'Access-Control-Allow-Methods' => 'GET, POST, OPTIONS', - 'Access-Control-Allow-Headers' => 'Content-Type, Authorization, X-License-Key, X-Account-Id, X-Dewemoji-Frontend', - ]); + '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 new file mode 100644 index 0000000..fe0039a --- /dev/null +++ b/app/app/Http/Controllers/Api/V1/SystemController.php @@ -0,0 +1,135 @@ +response($request, [ + 'ok' => true, + 'time' => now()->toIso8601String(), + 'app' => config('app.name'), + ]); + } + + public function metricsLite(Request $request): JsonResponse + { + if (!$this->metricsEnabled()) { + return $this->response($request, [ + 'ok' => false, + 'error' => 'metrics_disabled', + ], 404); + } + + return $this->response($request, [ + 'ok' => true, + 'time' => now()->toIso8601String(), + 'php_version' => PHP_VERSION, + 'memory_used_bytes' => memory_get_usage(true), + 'cache_driver' => (string) config('cache.default'), + ]); + } + + public function metrics(Request $request): JsonResponse + { + if (!$this->metricsEnabled()) { + return $this->response($request, [ + 'ok' => false, + 'error' => 'metrics_disabled', + ], 404); + } + + if (!$this->canAccessMetrics($request)) { + return $this->response($request, [ + 'ok' => false, + 'error' => 'forbidden', + ], 403); + } + + $dbOk = true; + try { + DB::connection()->getPdo(); + } catch (\Throwable) { + $dbOk = false; + } + + return $this->response($request, [ + 'ok' => true, + 'time' => now()->toIso8601String(), + 'php_version' => PHP_VERSION, + 'memory_used_bytes' => memory_get_usage(true), + 'db' => $dbOk ? 'ok' : 'down', + 'cache_driver' => (string) config('cache.default'), + 'cache_health' => $this->cacheHealth(), + 'system' => [ + 'loadavg' => function_exists('sys_getloadavg') ? array_map( + fn (float $n): string => number_format($n, 2, '.', ''), + sys_getloadavg() ?: [] + ) : null, + ], + ]); + } + + private function cacheHealth(): string + { + try { + $key = 'dw_metrics_ping'; + Cache::put($key, 'ok', 60); + $val = Cache::get($key); + return $val === 'ok' ? 'ok' : 'degraded'; + } catch (\Throwable) { + return 'down'; + } + } + + private function metricsEnabled(): bool + { + return (bool) config('dewemoji.metrics.enabled', true); + } + + private function canAccessMetrics(Request $request): bool + { + $token = trim((string) config('dewemoji.metrics.token', '')); + $provided = trim((string) $request->header('X-Metrics-Token', $request->query('token', ''))); + if ($token !== '' && hash_equals($token, $provided)) { + return true; + } + + $allowIps = config('dewemoji.metrics.allow_ips', []); + if (is_array($allowIps) && in_array($request->ip(), $allowIps, true)) { + return true; + } + + return false; + } + + /** + * @param array $payload + */ + private function response(Request $request, array $payload, int $status = 200): JsonResponse + { + $headers = [ + 'X-Dewemoji-Tier' => 'free', + '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'), + ]; + + $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/Web/SiteController.php b/app/app/Http/Controllers/Web/SiteController.php index be75023..f4442c9 100644 --- a/app/app/Http/Controllers/Web/SiteController.php +++ b/app/app/Http/Controllers/Web/SiteController.php @@ -4,13 +4,87 @@ namespace App\Http\Controllers\Web; use App\Http\Controllers\Controller; use Illuminate\Contracts\View\View; +use Illuminate\Http\RedirectResponse; +use Illuminate\Http\Request; use Illuminate\Http\Response; class SiteController extends Controller { - public function home(): View + /** @var array */ + private const CATEGORY_TO_SLUG = [ + 'Smileys & Emotion' => 'smileys', + 'People & Body' => 'people', + 'Animals & Nature' => 'animals', + 'Food & Drink' => 'food', + 'Travel & Places' => 'travel', + 'Activities' => 'activities', + 'Objects' => 'objects', + 'Symbols' => 'symbols', + 'Flags' => 'flags', + ]; + + public function home(Request $request): View { - return view('site.home'); + return view('site.home', [ + 'initialQuery' => trim((string) $request->query('q', '')), + 'initialCategory' => trim((string) $request->query('category', '')), + 'initialSubcategory' => trim((string) $request->query('subcategory', '')), + 'canonicalPath' => '/', + ]); + } + + public function browse(Request $request): RedirectResponse|View + { + $cat = strtolower(trim((string) $request->query('cat', 'all'))); + if ($cat !== '' && $cat !== 'all' && array_key_exists($cat, $this->categorySlugMap())) { + return redirect('/'.$cat, 301); + } + + return view('site.home', [ + 'initialQuery' => trim((string) $request->query('q', '')), + 'initialCategory' => trim((string) $request->query('category', '')), + 'initialSubcategory' => trim((string) $request->query('subcategory', '')), + 'canonicalPath' => '/browse', + ]); + } + + public function category(string $categorySlug): View + { + if ($categorySlug === 'all') { + return view('site.home', [ + 'initialQuery' => '', + 'initialCategory' => '', + 'initialSubcategory' => '', + 'canonicalPath' => '/', + ]); + } + + $categoryLabel = $this->categorySlugMap()[$categorySlug] ?? ''; + abort_if($categoryLabel === '', 404); + + return view('site.home', [ + 'initialQuery' => '', + 'initialCategory' => $categoryLabel, + 'initialSubcategory' => '', + 'canonicalPath' => '/'.$categorySlug, + ]); + } + + public function categorySubcategory(string $categorySlug, string $subcategorySlug): View + { + if ($categorySlug === 'all') { + abort(404); + } + + $categoryLabel = $this->categorySlugMap()[$categorySlug] ?? ''; + abort_if($categoryLabel === '', 404); + + return view('site.home', [ + 'initialQuery' => '', + 'initialCategory' => $categoryLabel, + 'initialSubcategory' => $subcategorySlug, + 'canonicalPath' => '/'.$categorySlug.'/'.$subcategorySlug, + ]); } public function apiDocs(): View @@ -65,7 +139,20 @@ class SiteController extends Controller return view('site.emoji-detail', [ 'emoji' => $match, + 'canonicalPath' => '/emoji/'.$slug, ]); } -} + /** + * @return array + */ + private function categorySlugMap(): array + { + $out = []; + foreach (self::CATEGORY_TO_SLUG as $label => $slug) { + $out[$slug] = $label; + } + + return $out; + } +} diff --git a/app/app/Http/Middleware/CanonicalPathMiddleware.php b/app/app/Http/Middleware/CanonicalPathMiddleware.php new file mode 100644 index 0000000..4677998 --- /dev/null +++ b/app/app/Http/Middleware/CanonicalPathMiddleware.php @@ -0,0 +1,33 @@ +path(), '/'); + + if ($path !== '/' && str_ends_with($path, '/')) { + $canonicalPath = rtrim($path, '/'); + $query = $request->getQueryString(); + if ($query !== null && $query !== '') { + $canonicalPath .= '?'.$query; + } + + return new RedirectResponse($canonicalPath, 301); + } + + return $next($request); + } +} diff --git a/app/app/Services/Billing/LicenseVerificationService.php b/app/app/Services/Billing/LicenseVerificationService.php new file mode 100644 index 0000000..ced5ea1 --- /dev/null +++ b/app/app/Services/Billing/LicenseVerificationService.php @@ -0,0 +1,335 @@ +, + * 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'] : []; + $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', '')); + } + 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); + } + + $response = $request->post($url, ['license_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'] : []; + $valid = (($json['success'] ?? false) === true) || (($json['valid'] ?? false) === true) || (($data['valid'] ?? false) === true); + if (!$valid) { + return ['ok' => false, 'err' => 'mayar_invalid']; + } + + $planType = strtolower((string) ($data['type'] ?? 'lifetime')); + return [ + 'ok' => true, + 'plan' => 'pro', + 'product_id' => (string) ($data['product_id'] ?? '') ?: null, + 'expires_at' => isset($data['expires_at']) ? (string) $data['expires_at'] : null, + '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, + ]; + } +} diff --git a/app/bootstrap/app.php b/app/bootstrap/app.php index ecde68a..0550ce9 100644 --- a/app/bootstrap/app.php +++ b/app/bootstrap/app.php @@ -1,5 +1,6 @@ withMiddleware(function (Middleware $middleware): void { - // + $middleware->web(append: [ + CanonicalPathMiddleware::class, + ]); }) ->withExceptions(function (Exceptions $exceptions): void { // diff --git a/app/config/dewemoji.php b/app/config/dewemoji.php index 1ca15a2..237e410 100644 --- a/app/config/dewemoji.php +++ b/app/config/dewemoji.php @@ -6,11 +6,58 @@ return [ 'pagination' => [ 'default_limit' => (int) env('DEWEMOJI_DEFAULT_LIMIT', 20), 'max_limit' => (int) env('DEWEMOJI_MAX_LIMIT', 50), + 'free_max_limit' => (int) env('DEWEMOJI_FREE_MAX_LIMIT', 20), + 'pro_max_limit' => (int) env('DEWEMOJI_PRO_MAX_LIMIT', 50), ], + 'free_daily_limit' => (int) env('DEWEMOJI_FREE_DAILY_LIMIT', 30), + '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'), + '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', ''))))), + ], + ], + ], + + '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-Account-Id, X-Dewemoji-Frontend', + ], + + 'frontend' => [ + 'header_token' => env('DEWEMOJI_FRONTEND_HEADER', 'web-v1'), + ], + + 'metrics' => [ + 'enabled' => filter_var(env('DEWEMOJI_METRICS_ENABLED', true), FILTER_VALIDATE_BOOL), + 'token' => (string) env('DEWEMOJI_METRICS_TOKEN', ''), + 'allow_ips' => array_values(array_filter(array_map('trim', explode(',', (string) env('DEWEMOJI_METRICS_ALLOW_IPS', '127.0.0.1,::1'))))), ], ]; - diff --git a/app/database/migrations/2026_02_04_000100_create_licenses_table.php b/app/database/migrations/2026_02_04_000100_create_licenses_table.php new file mode 100644 index 0000000..f9fc23e --- /dev/null +++ b/app/database/migrations/2026_02_04_000100_create_licenses_table.php @@ -0,0 +1,32 @@ +string('license_key', 191)->primary(); + $table->string('source', 32)->nullable(); + $table->string('plan', 32)->default('pro'); + $table->string('status', 32)->default('active'); + $table->string('product_id', 191)->nullable(); + $table->string('owner_user_id', 64)->nullable()->index(); + $table->timestamp('expires_at')->nullable(); + $table->timestamp('last_verified_at')->nullable(); + $table->json('meta_json')->nullable(); + $table->timestamps(); + + $table->index(['status', 'expires_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('licenses'); + } +}; + diff --git a/app/database/migrations/2026_02_04_000200_create_license_activations_table.php b/app/database/migrations/2026_02_04_000200_create_license_activations_table.php new file mode 100644 index 0000000..05cd9cc --- /dev/null +++ b/app/database/migrations/2026_02_04_000200_create_license_activations_table.php @@ -0,0 +1,31 @@ +id(); + $table->string('license_key', 191); + $table->string('user_id', 64); + $table->string('product', 32)->default('site'); + $table->string('device_id', 191)->nullable(); + $table->string('status', 32)->default('active'); + $table->timestamps(); + + $table->unique(['license_key', 'product', 'device_id'], 'uniq_license_product_device'); + $table->index(['license_key', 'product', 'status'], 'idx_license_product_status'); + $table->index(['user_id', 'status'], 'idx_user_status'); + }); + } + + public function down(): void + { + Schema::dropIfExists('license_activations'); + } +}; + diff --git a/app/database/migrations/2026_02_04_000300_create_usage_logs_table.php b/app/database/migrations/2026_02_04_000300_create_usage_logs_table.php new file mode 100644 index 0000000..8d44ce3 --- /dev/null +++ b/app/database/migrations/2026_02_04_000300_create_usage_logs_table.php @@ -0,0 +1,29 @@ +id(); + $table->string('bucket_id', 191); + $table->date('date_key'); + $table->unsignedInteger('used')->default(0); + $table->unsignedInteger('limit_count')->nullable(); + $table->json('seen_signatures')->nullable(); + $table->timestamps(); + + $table->unique(['bucket_id', 'date_key'], 'uniq_bucket_day'); + }); + } + + public function down(): void + { + Schema::dropIfExists('usage_logs'); + } +}; + diff --git a/app/resources/views/site/home.blade.php b/app/resources/views/site/home.blade.php index 87561e4..a9e40dd 100644 --- a/app/resources/views/site/home.blade.php +++ b/app/resources/views/site/home.blade.php @@ -176,6 +176,10 @@ diff --git a/app/routes/api.php b/app/routes/api.php index 6b8e8de..af52449 100644 --- a/app/routes/api.php +++ b/app/routes/api.php @@ -2,19 +2,35 @@ use App\Http\Controllers\Api\V1\EmojiApiController; use App\Http\Controllers\Api\V1\LicenseController; +use App\Http\Controllers\Api\V1\SystemController; use Illuminate\Support\Facades\Route; Route::options('/v1/{any}', function () { - return response('', 204, [ - 'Access-Control-Allow-Origin' => '*', + $headers = [ 'Access-Control-Allow-Methods' => 'GET, POST, OPTIONS', 'Access-Control-Allow-Headers' => 'Content-Type, Authorization, X-License-Key, X-Account-Id, X-Dewemoji-Frontend', - ]); + 'Vary' => 'Origin', + ]; + $origin = 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('', 204, $headers); })->where('any', '.*'); Route::prefix('v1')->group(function () { Route::get('/categories', [EmojiApiController::class, 'categories']); Route::get('/emojis', [EmojiApiController::class, 'emojis']); - Route::post('/license/verify', [LicenseController::class, 'verify']); -}); + Route::get('/emoji', [EmojiApiController::class, 'emoji']); + Route::get('/emoji/{slug}', [EmojiApiController::class, 'emoji']); + Route::post('/license/verify', [LicenseController::class, 'verify']); + Route::post('/license/activate', [LicenseController::class, 'activate']); + Route::post('/license/deactivate', [LicenseController::class, 'deactivate']); + + Route::get('/health', [SystemController::class, 'health']); + Route::get('/metrics-lite', [SystemController::class, 'metricsLite']); + Route::get('/metrics', [SystemController::class, 'metrics']); +}); diff --git a/app/routes/web.php b/app/routes/web.php index b675c0f..5db33bd 100644 --- a/app/routes/web.php +++ b/app/routes/web.php @@ -4,9 +4,18 @@ use App\Http\Controllers\Web\SiteController; use Illuminate\Support\Facades\Route; Route::get('/', [SiteController::class, 'home'])->name('home'); +Route::get('/browse', [SiteController::class, 'browse'])->name('browse'); Route::get('/api-docs', [SiteController::class, 'apiDocs'])->name('api-docs'); Route::get('/emoji/{slug}', [SiteController::class, 'emojiDetail'])->name('emoji-detail'); Route::get('/pricing', [SiteController::class, 'pricing'])->name('pricing'); Route::get('/privacy', [SiteController::class, 'privacy'])->name('privacy'); Route::get('/terms', [SiteController::class, 'terms'])->name('terms'); + +Route::get('/{categorySlug}', [SiteController::class, 'category']) + ->where('categorySlug', 'all|smileys|people|animals|food|travel|activities|objects|symbols|flags') + ->name('category'); +Route::get('/{categorySlug}/{subcategorySlug}', [SiteController::class, 'categorySubcategory']) + ->where('categorySlug', 'all|smileys|people|animals|food|travel|activities|objects|symbols|flags') + ->where('subcategorySlug', '[a-z0-9\-]+') + ->name('category-subcategory'); diff --git a/app/tests/Feature/ApiV1EndpointsTest.php b/app/tests/Feature/ApiV1EndpointsTest.php index c531836..d4cee11 100644 --- a/app/tests/Feature/ApiV1EndpointsTest.php +++ b/app/tests/Feature/ApiV1EndpointsTest.php @@ -38,6 +38,7 @@ class ApiV1EndpointsTest extends TestCase public function test_license_verify_returns_ok_when_accept_all_is_enabled(): void { config()->set('dewemoji.license.accept_all', true); + config()->set('dewemoji.billing.mode', 'live'); $response = $this->postJson('/v1/license/verify', [ 'key' => 'dummy-key', @@ -53,5 +54,174 @@ class ApiV1EndpointsTest extends TestCase 'tier' => 'pro', ]); } -} + public function test_license_verify_returns_401_when_live_and_key_is_not_valid(): void + { + config()->set('dewemoji.billing.mode', 'live'); + config()->set('dewemoji.license.accept_all', false); + config()->set('dewemoji.license.pro_keys', []); + config()->set('dewemoji.billing.providers.gumroad.enabled', false); + config()->set('dewemoji.billing.providers.mayar.enabled', false); + + $response = $this->postJson('/v1/license/verify', [ + 'key' => 'not-valid', + ]); + + $response + ->assertStatus(401) + ->assertHeader('X-Dewemoji-Tier', 'free') + ->assertJsonPath('ok', false) + ->assertJsonPath('error', 'invalid_license') + ->assertJsonPath('details.gumroad', 'gumroad_disabled') + ->assertJsonPath('details.mayar', 'mayar_disabled'); + } + + public function test_license_verify_uses_gumroad_stub_key_when_enabled(): void + { + config()->set('dewemoji.billing.mode', 'live'); + config()->set('dewemoji.license.accept_all', false); + config()->set('dewemoji.license.pro_keys', []); + config()->set('dewemoji.billing.providers.gumroad.enabled', true); + config()->set('dewemoji.billing.providers.gumroad.test_keys', ['gumroad-dev-key']); + config()->set('dewemoji.billing.providers.mayar.enabled', false); + + $response = $this->postJson('/v1/license/verify', [ + 'key' => 'gumroad-dev-key', + ]); + + $response + ->assertOk() + ->assertJsonPath('ok', true) + ->assertJsonPath('source', 'gumroad') + ->assertJsonPath('plan', 'pro'); + } + + public function test_emoji_detail_by_slug_endpoint_returns_item(): void + { + config()->set('dewemoji.billing.mode', 'sandbox'); + + $response = $this->getJson('/v1/emoji/grinning-face'); + + $response + ->assertOk() + ->assertJsonPath('slug', 'grinning-face') + ->assertJsonPath('name', 'grinning face'); + } + + public function test_emoji_detail_by_query_slug_endpoint_returns_item(): void + { + $response = $this->getJson('/v1/emoji?slug=thumbs-up'); + + $response + ->assertOk() + ->assertJsonPath('slug', 'thumbs-up') + ->assertJsonPath('supports_skin_tone', true); + } + + public function test_license_activate_and_deactivate_in_sandbox_mode(): void + { + config()->set('dewemoji.billing.mode', 'sandbox'); + config()->set('dewemoji.license.max_devices', 3); + + $activate = $this->postJson('/v1/license/activate', [ + 'key' => 'any-test-key', + 'email' => 'dev@dewemoji.test', + 'product' => 'chrome', + 'device_id' => 'device-1', + ]); + + $activate + ->assertOk() + ->assertJsonPath('ok', true) + ->assertJsonPath('pro', true) + ->assertJsonPath('product', 'chrome') + ->assertJsonPath('device_id', 'device-1'); + + $deactivate = $this->postJson('/v1/license/deactivate', [ + 'key' => 'any-test-key', + 'product' => 'chrome', + 'device_id' => 'device-1', + ]); + + $deactivate + ->assertOk() + ->assertJsonPath('ok', true) + ->assertJsonPath('product', 'chrome') + ->assertJsonPath('device_id', 'device-1'); + } + + public function test_free_daily_limit_returns_429_after_cap(): void + { + config()->set('dewemoji.billing.mode', 'live'); + config()->set('dewemoji.license.accept_all', false); + config()->set('dewemoji.license.pro_keys', []); + config()->set('dewemoji.free_daily_limit', 1); + + $first = $this->getJson('/v1/emojis?q=happy&page=1&limit=10'); + $first->assertOk()->assertJsonPath('usage.used', 1); + + $second = $this->getJson('/v1/emojis?q=good&page=1&limit=10'); + $second + ->assertStatus(429) + ->assertJsonPath('error', 'daily_limit_reached'); + } + + public function test_emojis_endpoint_returns_pro_tier_when_live_key_is_whitelisted(): void + { + config()->set('dewemoji.billing.mode', 'live'); + config()->set('dewemoji.license.accept_all', false); + config()->set('dewemoji.license.pro_keys', ['pro-key-123']); + config()->set('dewemoji.billing.providers.gumroad.enabled', false); + config()->set('dewemoji.billing.providers.mayar.enabled', false); + + $response = $this->getJson('/v1/emojis?q=grinning&key=pro-key-123'); + + $response + ->assertOk() + ->assertHeader('X-Dewemoji-Tier', 'pro') + ->assertJsonPath('total', 1); + } + + public function test_emojis_endpoint_accepts_authorization_bearer_key(): void + { + config()->set('dewemoji.billing.mode', 'live'); + config()->set('dewemoji.license.accept_all', false); + config()->set('dewemoji.license.pro_keys', ['bearer-pro-key']); + + $response = $this + ->withHeaders(['Authorization' => 'Bearer bearer-pro-key']) + ->getJson('/v1/emojis?q=grinning'); + + $response + ->assertOk() + ->assertHeader('X-Dewemoji-Tier', 'pro'); + } + + public function test_emojis_endpoint_accepts_x_license_key_header(): void + { + config()->set('dewemoji.billing.mode', 'live'); + config()->set('dewemoji.license.accept_all', false); + config()->set('dewemoji.license.pro_keys', ['header-pro-key']); + + $response = $this + ->withHeaders(['X-License-Key' => 'header-pro-key']) + ->getJson('/v1/emojis?q=grinning'); + + $response + ->assertOk() + ->assertHeader('X-Dewemoji-Tier', 'pro'); + } + + public function test_metrics_endpoint_requires_token_or_allowed_ip(): void + { + config()->set('dewemoji.metrics.enabled', true); + config()->set('dewemoji.metrics.token', 'secret-token'); + config()->set('dewemoji.metrics.allow_ips', []); + + $forbidden = $this->getJson('/v1/metrics'); + $forbidden->assertStatus(403); + + $allowed = $this->getJson('/v1/metrics?token=secret-token'); + $allowed->assertOk()->assertJsonPath('ok', true); + } +} diff --git a/app/tests/Feature/SitePagesTest.php b/app/tests/Feature/SitePagesTest.php index 9106fc5..297c6ac 100644 --- a/app/tests/Feature/SitePagesTest.php +++ b/app/tests/Feature/SitePagesTest.php @@ -16,6 +16,9 @@ class SitePagesTest extends TestCase public function test_core_pages_are_available(): void { $this->get('/')->assertOk(); + $this->get('/browse')->assertOk(); + $this->get('/animals')->assertOk(); + $this->get('/animals/animal-mammal')->assertOk(); $this->get('/api-docs')->assertOk(); $this->get('/pricing')->assertOk(); $this->get('/privacy')->assertOk(); @@ -34,4 +37,3 @@ class SitePagesTest extends TestCase $this->get('/emoji/unknown-slug')->assertNotFound(); } } - diff --git a/billing-sandbox-live.md b/billing-sandbox-live.md new file mode 100644 index 0000000..f36631c --- /dev/null +++ b/billing-sandbox-live.md @@ -0,0 +1,58 @@ +# Billing Mode Switch (`sandbox` -> `live`) + +This project supports two license verification modes via env: + +- `DEWEMOJI_BILLING_MODE=sandbox` + Any non-empty license key is treated as valid Pro. +- `DEWEMOJI_BILLING_MODE=live` + Key must pass live validation rules (`DEWEMOJI_LICENSE_ACCEPT_ALL`, `DEWEMOJI_PRO_KEYS`, or provider validation). + +## Recommended local setup + +Use sandbox while building: + +```env +DEWEMOJI_BILLING_MODE=sandbox +DEWEMOJI_LICENSE_ACCEPT_ALL=false +DEWEMOJI_PRO_KEYS= +``` + +## Staging/live setup + +Use live mode: + +```env +DEWEMOJI_BILLING_MODE=live +DEWEMOJI_LICENSE_ACCEPT_ALL=false +DEWEMOJI_PRO_KEYS=key_1,key_2,key_3 +DEWEMOJI_VERIFY_CACHE_TTL=300 +DEWEMOJI_GUMROAD_ENABLED=true +DEWEMOJI_GUMROAD_PRODUCT_IDS=prod_abc123 +DEWEMOJI_MAYAR_ENABLED=false +DEWEMOJI_MAYAR_API_BASE=https://api.mayar.id +DEWEMOJI_MAYAR_ENDPOINT_VERIFY=/v1/license/verify +DEWEMOJI_MAYAR_SECRET_KEY= +``` + +## Provider notes + +- Gumroad validation uses configured `DEWEMOJI_GUMROAD_VERIFY_URL` + first `DEWEMOJI_GUMROAD_PRODUCT_IDS`. +- Mayar validation uses `DEWEMOJI_MAYAR_VERIFY_URL` + `DEWEMOJI_MAYAR_API_KEY`. + - Or use `DEWEMOJI_MAYAR_API_BASE` + `DEWEMOJI_MAYAR_ENDPOINT_VERIFY` + `DEWEMOJI_MAYAR_SECRET_KEY`. +- For local QA (no external billing call), you can define: + - `DEWEMOJI_GUMROAD_TEST_KEYS=dev_key_1,dev_key_2` + - `DEWEMOJI_MAYAR_TEST_KEYS=dev_key_3` + +## API endpoints affected + +- `POST /v1/license/verify` +- `POST /v1/license/activate` +- `POST /v1/license/deactivate` +- Tier-aware API access such as `GET /v1/emojis` (free/pro behavior) + +## Notes + +- Current provider integration is baseline and safe-fallback (`false` on network/API mismatch). +- Keep `DEWEMOJI_PRO_KEYS` for emergency fallback during migration cutover. +- `POST /v1/license/verify` includes provider fields on success: `source`, `plan`, `product_id`, `expires_at`. +- Invalid live checks include `details.gumroad` and `details.mayar` for diagnostics. diff --git a/rebuild-progress.md b/rebuild-progress.md index 2135dfa..4c3f420 100644 --- a/rebuild-progress.md +++ b/rebuild-progress.md @@ -1,66 +1,240 @@ -# NativePHP Rebuild Progress (Retraced) +# NativePHP Rebuild Progress (with `dewemoji-live` audit) ## Confirmed source folders - Backend: `../dewemoji-api` +- Live backend reference: `../dewemoji-live-backend` (source of truth for current API + community/pro flows) - Chrome extension: `../dewemoji-chrome-ext` -- Website: `../dewemoji-site` (currently scaffold/empty) +- Website (legacy scaffold): `../dewemoji-site` +- Live production reference: `../dewemoji-live` (source of truth for parity) ## Current baseline -- Active backend logic exists in `dewemoji-api`. -- Active extension logic exists in `dewemoji-chrome-ext`. -- `dewemoji-site` currently has structure but no implemented content (mostly 0-byte files). -- New rebuild app scaffold now exists at `app/` (Laravel + NativePHP Desktop installed). +- New rebuild app: `app/` (Laravel + NativePHP Desktop). +- API v1 routes exist in rebuild: `/v1/emojis`, `/v1/categories`, `/v1/license/verify`. +- Website routes currently in rebuild: `/`, `/emoji/{slug}`, `/api-docs`, `/pricing`, `/privacy`, `/terms`. +- Live site has additional behavior and SEO/route details that must be ported before full parity. + +## Agreed strategy (locked) + +- Build order: backend-first, then frontend integration. +- Community feature: included in migration scope, but implemented last after core/pro stabilization. +- Payment/provider mode: start in sandbox, document switch path to live (`SANDBOX -> LIVE`) in project docs. +- Database: fresh Laravel-first schema + import scripts from legacy data sources. +- Metrics endpoints: keep, but internal-only (admin token/IP allowlist), not public. +- Upgrade policy: migration is parity-first, but we will take safe opportunities to improve architecture, security, and observability. ## Phase checklist ### Phase 0 - Retrace and documentation - [x] Revalidated source folders with corrected names. -- [x] Rebuilt docs against corrected folders. -- [x] Marked `dewemoji-site` as scaffold status. +- [x] Rebuilt initial docs against corrected folders. +- [x] Added live audit from `dewemoji-live`. +- [x] Added backend audit from `dewemoji-live-backend`. ### Phase 1 - Foundation -- [x] Initialize NativePHP app in `dewemoji` (`app/` folder). -- [x] Install NativePHP Desktop scaffolding (`config/nativephp.php`, `NativeAppServiceProvider`). -- [x] Define initial env/config strategy (documented in `phase-1-foundation.md`). -- [x] Decide canonical data source: start from `dewemoji-api/data/emojis.json`. +- [x] Initialize NativePHP app in `dewemoji/app`. +- [x] Install NativePHP Desktop scaffolding. +- [x] Define env/config strategy. +- [x] Use canonical emoji dataset as baseline. ### Phase 2 - API parity (extension first) - [x] Implement `GET /v1/emojis`. - [x] Implement `GET /v1/categories`. -- [x] Implement `POST /v1/license/verify`. -- [x] Preserve response/header compatibility (`X-Dewemoji-Tier`). -- [x] Support both `q` and `query` inputs. +- [x] Implement `POST /v1/license/verify` (temporary env-based validation). +- [x] Support both `q` and `query`. +- [ ] Add/verify full response contract parity with live docs (`variants`, `related`, trimmed `keywords_en`, limit behavior by tier). +- [x] Match live cache/rate semantics baseline (`page=1` metering behavior, 401/429 payload shape, ETag/304 behavior). +- [x] Verify header compatibility baseline: `Authorization`, `X-License-Key`, `X-Account-Id`, `X-Dewemoji-Frontend`, `X-Dewemoji-Tier`. +- [x] Restrict CORS to configured origins (no default `*`). +- [x] Add missing live backend routes/contracts now present in production API: + - `/v1/license/activate` + - `/v1/license/deactivate` + - `/v1/health` + - `/v1/metrics` and `/v1/metrics-lite` (internal/admin decision needed: keep, secure, or remove) +- [x] Reconcile live route mismatch: added `/v1/emoji` and `/v1/emoji/{slug}` in rebuild API. +- [x] Add sandbox/live provider switch documentation and env examples (`BILLING_MODE=sandbox|live`, keys, callbacks, smoke test flow). -### Phase 3 - Website rebuild -- [x] Build website pages in new app (index, emoji detail, api docs, legal pages). -- [ ] Replace scaffold in `dewemoji-site` via new NativePHP output. +### Phase 3 - Website parity from `dewemoji-live` +- [x] Core pages exist in rebuild. +- [ ] Add missing pages/routes: `/support`, `/browse`, pretty category routes (`/{category}` and `/{category}/{subcategory}`). + - [x] `/browse`, `/{category}`, `/{category}/{subcategory}` implemented in rebuild. +- [ ] Keep URL behavior parity (canonical no-trailing-slash pages, redirect rules, pretty-to-query hydration). + - [x] no-trailing-slash redirect middleware and canonical link baseline implemented. + - [x] pretty route hydration wired into homepage initial filters + URL sync. +- [ ] Port homepage behavior parity: + - API-backed filters (`q`, category, subcategory), URL sync, load-more pagination. + - API fallback when scoped search returns 0 (retry on `all` + hint). +- [ ] Port single emoji page parity: + - 404 `noindex` for missing. + - 410 + `X-Robots-Tag: noindex, noarchive` for policy-hidden emoji. + - skin-tone variant logic and optional tone path (`/emoji/{slug}/{tone}`). + - related fallback (same subcategory), prev/next navigation. + - details blocks: aliases, shortcodes, EN/ID keywords, copy interactions. + - curated blurbs support from `data/emoji_descriptions.json`. +- [ ] Port legal/support content parity and FAQ schema blocks. -### Phase 4 - Extension integration -- [ ] Point `dewemoji-chrome-ext` API base to new app endpoint. -- [ ] Validate free/pro flow, insert mode, and tone behavior. +### Phase 4 - Pricing and payments +- [ ] Keep pricing structure parity: + - Free, Pro subscription, Lifetime. + - Pro: `$3/mo` and yearly display `$27/yr` in UI. + - Lifetime: `$69`. +- [ ] Preserve live Gumroad links: + - `https://dwindown.gumroad.com/l/dewemoji-pro-subscription` + - `https://dwindown.gumroad.com/l/dewemoji-pro-lifetime` +- [ ] Keep IDR/Mayar messaging parity (manual-renew note). +- [ ] Implement real license lifecycle (activate/deactivate/verify + max 3 Chrome profiles) in new backend. +- [x] Implement real license lifecycle baseline in rebuild (`verify/activate/deactivate`, immutable owner binding behavior, max device cap). +- [ ] Implement provider verification parity: + - [x] Baseline service layer + env/config wiring + safe HTTP fallback + - [x] `/v1/license/verify` contract hardening: provider details + diagnostics (`details.gumroad`, `details.mayar`) + - [ ] Gumroad verify API flow (final payload/contract parity with live provider account) + - [ ] Mayar verify API flow (final payload/contract parity with live provider account) + - gateway mode switch (`sandbox` vs `live`) +- [ ] Implement immutable license binding to user + multi-device activation policy parity. -### Phase 5 - Quality and release -- [ ] Add endpoint tests + regression checks for extension-critical fields. -- [ ] Add migration and rollback checklist. +### Phase 5 - SEO parity (must not disrupt GSC) +- [ ] Preserve canonical strategy for all pages (including emoji detail + pretty category pages). +- [ ] Add/verify meta + social tags parity: title/description/OG/Twitter + theme color. +- [ ] Port JSON-LD strategy: + - Global `WebSite` + `SearchAction` + Organization. + - `TechArticle` on `/api-docs`. + - `Product` + `FAQPage` on `/pricing`. + - `FAQPage` on `/support`. + - `CreativeWork` + `BreadcrumbList` on emoji pages. +- [ ] Implement `robots.txt` parity and dynamic `sitemap.xml` parity. +- [ ] Ensure sitemap excludes policy-hidden emoji URLs (same filter policy as live). +- [ ] Keep core indexed URLs stable: `/`, `/pricing`, `/api-docs`, `/support`, `/privacy`, `/terms`, `/emoji/{slug}`. -## Critical risks to address early +### Phase 6 - Analytics, consent, and compliance +- [ ] Re-implement cookie consent flow before analytics activation. +- [ ] Re-implement GA4 only on allowed production hosts (live uses `G-R7FYYRBVJK`). +- [ ] Keep privacy/terms statements aligned with live content. -- Backend mismatch: `q` vs `query`. -- Backend tier validation stubs in legacy PHP. -- Contract differences between current `/api/*` and extension `/v1/*`. -- Website source currently split (`dewemoji-api` active pages vs `dewemoji-site` empty scaffold). +### Phase 7 - Data/ops pipelines +- [ ] Port blurb pipeline: + - `jobs/seed_blurbs_from_dataset.php` + - `jobs/sync_blurbs.php` (NocoDB approved blurbs sync). +- [ ] Define NativePHP/Laravel replacement for live file microcache (`cache/emoji/*.html`) if still needed for SEO performance. +- [ ] Add rebuild-side commands/jobs for sitemap regeneration and cache warmup. +- [ ] Port backend dataset pipelines from `dewemoji-live-backend/jobs`: + - JSON -> SQL import (`import_emojis_json_to_sql.php`) + - SQL -> JSON build (`build_emojis_json_from_sql.php`) + - Keywords index build (`build_keywords_json_from_sql.php`) + - Unicode parity validation (`validate_emojis_against_unicode.php`) + - License expiry revocation cron (`check_license_expiry.php`) -## Implementation notes (Phase 2) +### Phase 8 - Community feature migration +- [ ] Port contributor auth flow: + - magic link token issue/verify (`/v1/contrib/auth/request`, `/v1/contrib/auth/verify`) + - stateless HMAC token strategy or Laravel equivalent. +- [ ] Port contribution flows: + - suggest keywords (`/v1/contrib/suggest`) + - private -> public promotion (`/v1/contrib/make-public`) + - list and search (`/v1/contrib/list`, `/v1/contrib/search`) + - voting + pending queue (`/v1/contrib/vote`, `/v1/keywords/pending`) +- [ ] Port moderation protections: + - Turnstile verification + - AI guard moderation pipeline (OpenRouter + usage caps/cache) + - Redis/APCu rate limiting (vote/suggest/publish paths) +- [ ] Port auto-moderation behavior: + - score thresholds (`vote_auto_approve_score`, `vote_auto_reject_score`) + - status/visibility transitions (`private`, `public_pending`, `public`, `approved`, `rejected`) +- [ ] Fix live bug during migration: `/v1/contrib/search` is nested under `/v1/keywords/pending` block in current controller, so route behavior should be revalidated in rebuild. -- Routes are now available at `/v1/*` (no `/api` prefix) for extension compatibility. -- License verification is currently environment-driven (`DEWEMOJI_LICENSE_ACCEPT_ALL` / `DEWEMOJI_PRO_KEYS`) as a safe stub before real provider integration. -- Test coverage added for `v1` endpoints in `app/tests/Feature/ApiV1EndpointsTest.php`. +### Phase 9 - Extension integration and release +- [ ] Point `dewemoji-chrome-ext` to new API host. +- [ ] Validate free/pro flow end-to-end with real license checks. +- [ ] Run parity QA on tone handling, insert/copy behavior, and API limits. +- [ ] Prepare migration + rollback checklist (DNS/host switch, redirects, monitoring). -## Implementation notes (Phase 3) +## Recent implementation update -- Added website routes/pages in Laravel app: - - `/`, `/emoji/{slug}`, `/api-docs`, `/pricing`, `/privacy`, `/terms` -- Home page now consumes `/v1/categories` and `/v1/emojis` directly. -- Added page tests in `app/tests/Feature/SitePagesTest.php`. +- Added new API endpoints in rebuild: + - `GET /v1/emoji` + - `GET /v1/emoji/{slug}` + - `POST /v1/license/activate` + - `POST /v1/license/deactivate` + - `GET /v1/health` + - `GET /v1/metrics` + - `GET /v1/metrics-lite` +- Added internal-protected metrics controller (`token` or IP allowlist). +- Added sandbox/live billing mode documentation: `billing-sandbox-live.md`. +- Added fresh Laravel migrations for core backend state: + - `licenses` + - `license_activations` + - `usage_logs` +- Added `LicenseVerificationService` and wired controllers to use one verification path: + - sandbox mode + - live key-list mode + - baseline Gumroad/Mayar provider calls (+ local stub test keys) +- Added SEO-safe route/canonical baseline: + - `/browse` route + - pretty category routes (`/{category}`, `/{category}/{subcategory}`) + - trailing slash -> canonical path redirect (301) + - canonical `` output from layout + +## Live audit highlights (reference) + +- Live web routes in `dewemoji-live/public_html`: `/`, `/emoji/{slug}`, `/browse`, `/pricing`, `/api-docs`, `/support`, `/privacy`, `/terms`, `/sitemap.xml`. +- Rewrite rules and canonicalization live in `dewemoji-live/public_html/.htaccess`. +- SEO assets: + - `dewemoji-live/public_html/includes/head.php` + - `dewemoji-live/public_html/sitemap.xml.php` + - `dewemoji-live/public_html/robots.txt` +- Emoji page implementation reference: + - `dewemoji-live/public_html/emoji.php` + - includes microcache + structured data + policy filtering. +- Pricing + payment references: + - `dewemoji-live/public_html/pricing.php` + - `dewemoji-live/public_html/support.php` + - `dewemoji-live/public_html/privacy.php` + - `dewemoji-live/public_html/terms.php` +- Blurb data + jobs: + - `dewemoji-live/public_html/data/emoji_descriptions.json` + - `dewemoji-live/jobs/sync_blurbs.php` + - `dewemoji-live/jobs/seed_blurbs_from_dataset.php` + +## Live backend audit highlights (`dewemoji-live-backend`) + +- Backend architecture is a custom PHP router (`public_html/public/index.php`) with controller-per-endpoint files and shared helpers. +- Main live API surface discovered: + - `/v1/emojis`, `/v1/categories` + - `/v1/license/verify`, `/v1/license/activate`, `/v1/license/deactivate` + - `/v1/contrib/*` community endpoints (suggest/list/vote/auth/make-public/search) + - `/v1/keywords/pending` + - `/v1/health`, `/v1/metrics`, `/v1/metrics-lite` +- Data mode is hybrid: + - API reads from JSON dataset (`public_html/app/data/emojis.json`) for emoji search. + - Licensing, usage logs, and community data read/write in MySQL. + - Redis/APCu/in-memory used for runtime rate-limiting fallback chain. +- Pro logic currently exists in live backend: + - plan resolution from license key + first-party whitelist headers/origin. + - pro/free limits for API and contribution quotas. + - device activation model with max active devices per license/product. +- Community feature maturity: + - keyword contribution flow exists with private/public states. + - voting and auto-approval/rejection thresholds implemented. + - Turnstile + AI moderation + rate limiting integrated. + - still has fragile areas that should be normalized in Laravel service layer. +- Database model inferred from code (must be migrated with proper Laravel migrations): + - `emojis`, `emoji_aliases`, `emoji_shortcodes`, `emoji_usage_examples`, `emoji_related`, `emoji_intent_tags`, `emoji_search_tokens` + - `emoji_keywords`, `keyword_votes`, `moderation_events` + - `users` + - `licenses`, `license_activations`, `usage_logs` + - `ai_guard_logs`, `ai_provider_usage`, `ai_lang_cache` + +## Security and configuration migration requirements + +- Current live backend keeps many secrets directly in `public_html/config/env.php` (DB, Redis, Turnstile, payment providers, OpenRouter). +- Rebuild must move all secrets to `.env`, rotate exposed credentials, and remove committed secret values from repo history. +- Metrics endpoints currently appear open by default; rebuild should protect admin/internal endpoints with auth or network policy. +- Add internal observability baseline in rebuild: + - structured request logging + - protected metrics endpoint(s) + - deploy healthcheck endpoint + +## Important note on Gumroad API tracing + +- In `dewemoji-live/public_html/helpers/auth.php`, Gumroad/Mayar validation is currently a stub (`return false`), so live verification logic is not fully present in this folder. +- There is legacy local activation SQL flow in `dewemoji-live/public_html/db.php` (activate/deactivate/verify + device cap), which should be used only as behavioral reference for rebuild design.