Files
dewemoji/app/app/Http/Controllers/Api/V1/EmojiApiController.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);
}
}