feat: harden billing verification and add browse route parity

This commit is contained in:
Dwindi Ramadhana
2026-02-04 08:52:22 +07:00
parent ccec406d6d
commit a4d2031117
20 changed files with 2080 additions and 144 deletions

View File

@@ -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<string,mixed>|null */
private static ?array $dataset = null;
public function categories(): JsonResponse
public function __construct(
private readonly LicenseVerificationService $verification
) {
}
/** @var array<string,string> */
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<string,mixed>
*/
@@ -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<string,mixed> $item
* @return array<string,mixed>
*/
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<string,mixed> $item
* @return array<string,mixed>
*/
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<string,mixed>}
*/
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<string,mixed>}
*/
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<string,mixed>}
*/
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<string,mixed> $payload
* @param array<string,string> $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);
}
}

View File

@@ -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<string,mixed> $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<string,array<string,array<string,string>>>}
*/
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<string,array<string,array<string,string>>>} $state
*/
private function storeLicenseState(string $key, array $state): void
{
$cacheKey = 'dw_license_state_'.sha1($key);
Cache::put($cacheKey, $state, now()->addDays(30));
}
/**
* @param array<string,mixed> $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);
}
}

View File

@@ -0,0 +1,135 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
class SystemController extends Controller
{
public function health(Request $request): JsonResponse
{
return $this->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<string,mixed> $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);
}
}

View File

@@ -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<string,string> */
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<string,string>
*/
private function categorySlugMap(): array
{
$out = [];
foreach (self::CATEGORY_TO_SLUG as $label => $slug) {
$out[$slug] = $label;
}
return $out;
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class CanonicalPathMiddleware
{
/**
* @param Closure(Request): Response $next
*/
public function handle(Request $request, Closure $next): Response
{
$rawPath = parse_url((string) ($_SERVER['REQUEST_URI'] ?? ''), PHP_URL_PATH);
$rawPath = is_string($rawPath) && $rawPath !== '' ? $rawPath : null;
$path = $rawPath ?? '/'.ltrim($request->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);
}
}

View File

@@ -0,0 +1,335 @@
<?php
namespace App\Services\Billing;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
class LicenseVerificationService
{
/**
* @return array{
* ok:bool,
* tier:string,
* source:string,
* error:?string,
* plan:string,
* product_id:?string,
* expires_at:?string,
* meta:array<string,mixed>,
* details:array<string,mixed>
* }
*/
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<string,mixed>,
* details:array<string,mixed>
* } $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<string,mixed>,
* details:array<string,mixed>
* }
*/
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<string,mixed>
* }
*/
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<string,mixed>
* }
*/
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<string,mixed> $meta
* @return array{
* ok:bool,
* tier:string,
* source:string,
* error:?string,
* plan:string,
* product_id:?string,
* expires_at:?string,
* meta:array<string,mixed>,
* details:array<string,mixed>
* }
*/
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<string,mixed> $details
* @return array{
* ok:bool,
* tier:string,
* source:string,
* error:?string,
* plan:string,
* product_id:?string,
* expires_at:?string,
* meta:array<string,mixed>,
* details:array<string,mixed>
* }
*/
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,
];
}
}