567 lines
20 KiB
PHP
567 lines
20 KiB
PHP
<?php
|
|
|
|
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
|
|
{
|
|
private const TIER_FREE = 'free';
|
|
private const TIER_PRO = 'pro';
|
|
|
|
/** @var array<string,mixed>|null */
|
|
private static ?array $dataset = null;
|
|
|
|
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);
|
|
try {
|
|
$data = $this->loadData();
|
|
} catch (RuntimeException $e) {
|
|
return $this->jsonWithTier($request, [
|
|
'error' => 'data_load_failed',
|
|
'message' => $e->getMessage(),
|
|
], $tier, 500);
|
|
}
|
|
|
|
$items = $data['emojis'] ?? [];
|
|
$map = [];
|
|
foreach ($items as $item) {
|
|
$category = trim((string) ($item['category'] ?? ''));
|
|
$subcategory = trim((string) ($item['subcategory'] ?? ''));
|
|
if ($category === '' || $subcategory === '') {
|
|
continue;
|
|
}
|
|
|
|
$map[$category] ??= [];
|
|
$map[$category][$subcategory] = true;
|
|
}
|
|
|
|
$out = [];
|
|
foreach ($map as $category => $subcategories) {
|
|
$out[$category] = array_keys($subcategories);
|
|
sort($out[$category], SORT_NATURAL | SORT_FLAG_CASE);
|
|
}
|
|
ksort($out, SORT_NATURAL | SORT_FLAG_CASE);
|
|
|
|
$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
|
|
{
|
|
$tier = $this->detectTier($request);
|
|
try {
|
|
$data = $this->loadData();
|
|
} catch (RuntimeException $e) {
|
|
return $this->jsonWithTier($request, [
|
|
'error' => 'data_load_failed',
|
|
'message' => $e->getMessage(),
|
|
], $tier, 500);
|
|
}
|
|
|
|
$items = $data['emojis'] ?? [];
|
|
$q = trim((string) ($request->query('q', $request->query('query', ''))));
|
|
$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 = $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, $subSlug): bool {
|
|
$itemCategory = trim((string) ($item['category'] ?? ''));
|
|
$itemSubcategory = trim((string) ($item['subcategory'] ?? ''));
|
|
|
|
if ($category !== '' && strcasecmp($itemCategory, $category) !== 0) {
|
|
return false;
|
|
}
|
|
if ($subSlug !== '' && $this->slugify($itemSubcategory) !== $subSlug) {
|
|
return false;
|
|
}
|
|
if ($q === '') {
|
|
return true;
|
|
}
|
|
|
|
$haystack = strtolower(implode(' ', [
|
|
(string) ($item['emoji'] ?? ''),
|
|
(string) ($item['name'] ?? ''),
|
|
(string) ($item['slug'] ?? ''),
|
|
$itemCategory,
|
|
$itemSubcategory,
|
|
implode(' ', $item['keywords_en'] ?? []),
|
|
implode(' ', $item['keywords_id'] ?? []),
|
|
implode(' ', $item['aliases'] ?? []),
|
|
implode(' ', $item['shortcodes'] ?? []),
|
|
implode(' ', $item['alt_shortcodes'] ?? []),
|
|
implode(' ', $item['intent_tags'] ?? []),
|
|
]));
|
|
|
|
$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);
|
|
|
|
$responsePayload = [
|
|
'items' => $outItems,
|
|
'total' => $total,
|
|
'page' => $page,
|
|
'limit' => $limit,
|
|
];
|
|
|
|
$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
|
|
{
|
|
$key = trim((string) $request->bearerToken());
|
|
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 ($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>
|
|
*/
|
|
private function loadData(): array
|
|
{
|
|
if (self::$dataset !== null) {
|
|
return self::$dataset;
|
|
}
|
|
|
|
$path = (string) config('dewemoji.data_path');
|
|
if (!is_file($path)) {
|
|
throw new RuntimeException('Emoji dataset file was not found at: '.$path);
|
|
}
|
|
|
|
$raw = file_get_contents($path);
|
|
if ($raw === false) {
|
|
throw new RuntimeException('Emoji dataset file could not be read.');
|
|
}
|
|
|
|
$decoded = json_decode($raw, true);
|
|
if (!is_array($decoded)) {
|
|
throw new RuntimeException('Emoji dataset JSON is invalid.');
|
|
}
|
|
|
|
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' => $emoji,
|
|
'name' => (string) ($item['name'] ?? ''),
|
|
'slug' => (string) ($item['slug'] ?? ''),
|
|
'category' => (string) ($item['category'] ?? ''),
|
|
'subcategory' => (string) ($item['subcategory'] ?? ''),
|
|
'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' => 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'] ?? ''),
|
|
];
|
|
}
|
|
|
|
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)) ?? '');
|
|
if (mb_strlen($text) <= $max) {
|
|
return $text;
|
|
}
|
|
|
|
return rtrim(mb_substr($text, 0, $max - 1), " ,.;:-").'…';
|
|
}
|
|
|
|
/**
|
|
* @param array<string,mixed> $payload
|
|
* @param array<string,string> $extraHeaders
|
|
*/
|
|
private function jsonWithTier(Request $request, array $payload, string $tier, int $status = 200, array $extraHeaders = []): JsonResponse
|
|
{
|
|
$headers = [
|
|
'X-Dewemoji-Tier' => $tier,
|
|
'X-Dewemoji-Plan' => $tier,
|
|
'Vary' => 'Origin',
|
|
'Access-Control-Allow-Methods' => (string) config('dewemoji.cors.allow_methods', 'GET, POST, OPTIONS'),
|
|
'Access-Control-Allow-Headers' => (string) config('dewemoji.cors.allow_headers', 'Content-Type, Authorization, X-License-Key, X-Account-Id, X-Dewemoji-Frontend'),
|
|
];
|
|
|
|
$origin = (string) $request->headers->get('Origin', '');
|
|
$allowedOrigins = config('dewemoji.cors.allowed_origins', []);
|
|
if (is_array($allowedOrigins) && in_array($origin, $allowedOrigins, true)) {
|
|
$headers['Access-Control-Allow-Origin'] = $origin;
|
|
}
|
|
|
|
return response()->json($payload, $status, $headers + $extraHeaders);
|
|
}
|
|
}
|