feat: harden billing verification and add browse route parity
This commit is contained in:
@@ -67,5 +67,29 @@ VITE_APP_NAME="${APP_NAME}"
|
|||||||
DEWEMOJI_DATA_PATH=
|
DEWEMOJI_DATA_PATH=
|
||||||
DEWEMOJI_DEFAULT_LIMIT=20
|
DEWEMOJI_DEFAULT_LIMIT=20
|
||||||
DEWEMOJI_MAX_LIMIT=50
|
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_LICENSE_ACCEPT_ALL=true
|
||||||
DEWEMOJI_PRO_KEYS=
|
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
|
||||||
|
|||||||
@@ -2,9 +2,14 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Api\V1;
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Services\Billing\LicenseVerificationService;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use Carbon\Carbon;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
|
|
||||||
class EmojiApiController extends Controller
|
class EmojiApiController extends Controller
|
||||||
@@ -15,23 +20,42 @@ class EmojiApiController extends Controller
|
|||||||
/** @var array<string,mixed>|null */
|
/** @var array<string,mixed>|null */
|
||||||
private static ?array $dataset = 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 {
|
try {
|
||||||
$data = $this->loadData();
|
$data = $this->loadData();
|
||||||
} catch (RuntimeException $e) {
|
} catch (RuntimeException $e) {
|
||||||
return $this->jsonWithTier([
|
return $this->jsonWithTier($request, [
|
||||||
'error' => 'data_load_failed',
|
'error' => 'data_load_failed',
|
||||||
'message' => $e->getMessage(),
|
'message' => $e->getMessage(),
|
||||||
], $tier, 500);
|
], $tier, 500);
|
||||||
}
|
}
|
||||||
$items = $data['emojis'] ?? [];
|
|
||||||
|
|
||||||
|
$items = $data['emojis'] ?? [];
|
||||||
$map = [];
|
$map = [];
|
||||||
foreach ($items as $item) {
|
foreach ($items as $item) {
|
||||||
$category = (string) ($item['category'] ?? '');
|
$category = trim((string) ($item['category'] ?? ''));
|
||||||
$subcategory = (string) ($item['subcategory'] ?? '');
|
$subcategory = trim((string) ($item['subcategory'] ?? ''));
|
||||||
if ($category === '' || $subcategory === '') {
|
if ($category === '' || $subcategory === '') {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -47,7 +71,18 @@ class EmojiApiController extends Controller
|
|||||||
}
|
}
|
||||||
ksort($out, SORT_NATURAL | SORT_FLAG_CASE);
|
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
|
public function emojis(Request $request): JsonResponse
|
||||||
@@ -56,31 +91,34 @@ class EmojiApiController extends Controller
|
|||||||
try {
|
try {
|
||||||
$data = $this->loadData();
|
$data = $this->loadData();
|
||||||
} catch (RuntimeException $e) {
|
} catch (RuntimeException $e) {
|
||||||
return $this->jsonWithTier([
|
return $this->jsonWithTier($request, [
|
||||||
'error' => 'data_load_failed',
|
'error' => 'data_load_failed',
|
||||||
'message' => $e->getMessage(),
|
'message' => $e->getMessage(),
|
||||||
], $tier, 500);
|
], $tier, 500);
|
||||||
}
|
}
|
||||||
$items = $data['emojis'] ?? [];
|
|
||||||
|
|
||||||
|
$items = $data['emojis'] ?? [];
|
||||||
$q = trim((string) ($request->query('q', $request->query('query', ''))));
|
$q = trim((string) ($request->query('q', $request->query('query', ''))));
|
||||||
$category = trim((string) $request->query('category', ''));
|
$category = $this->normalizeCategoryFilter((string) $request->query('category', ''));
|
||||||
$subcategory = trim((string) $request->query('subcategory', ''));
|
$subSlug = $this->slugify((string) $request->query('subcategory', ''));
|
||||||
$page = max((int) $request->query('page', 1), 1);
|
$page = max((int) $request->query('page', 1), 1);
|
||||||
|
|
||||||
$defaultLimit = max((int) config('dewemoji.pagination.default_limit', 20), 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);
|
$limit = min(max((int) $request->query('limit', $defaultLimit), 1), $maxLimit);
|
||||||
|
|
||||||
$filtered = array_values(array_filter($items, function (array $item) use ($q, $category, $subcategory): bool {
|
$filtered = array_values(array_filter($items, function (array $item) use ($q, $category, $subSlug): bool {
|
||||||
if ($category !== '' && strcasecmp((string) ($item['category'] ?? ''), $category) !== 0) {
|
$itemCategory = trim((string) ($item['category'] ?? ''));
|
||||||
|
$itemSubcategory = trim((string) ($item['subcategory'] ?? ''));
|
||||||
|
|
||||||
|
if ($category !== '' && strcasecmp($itemCategory, $category) !== 0) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if ($subSlug !== '' && $this->slugify($itemSubcategory) !== $subSlug) {
|
||||||
if ($subcategory !== '' && strcasecmp((string) ($item['subcategory'] ?? ''), $subcategory) !== 0) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($q === '') {
|
if ($q === '') {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -89,8 +127,8 @@ class EmojiApiController extends Controller
|
|||||||
(string) ($item['emoji'] ?? ''),
|
(string) ($item['emoji'] ?? ''),
|
||||||
(string) ($item['name'] ?? ''),
|
(string) ($item['name'] ?? ''),
|
||||||
(string) ($item['slug'] ?? ''),
|
(string) ($item['slug'] ?? ''),
|
||||||
(string) ($item['category'] ?? ''),
|
$itemCategory,
|
||||||
(string) ($item['subcategory'] ?? ''),
|
$itemSubcategory,
|
||||||
implode(' ', $item['keywords_en'] ?? []),
|
implode(' ', $item['keywords_en'] ?? []),
|
||||||
implode(' ', $item['keywords_id'] ?? []),
|
implode(' ', $item['keywords_id'] ?? []),
|
||||||
implode(' ', $item['aliases'] ?? []),
|
implode(' ', $item['aliases'] ?? []),
|
||||||
@@ -99,21 +137,123 @@ class EmojiApiController extends Controller
|
|||||||
implode(' ', $item['intent_tags'] ?? []),
|
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);
|
$total = count($filtered);
|
||||||
$offset = ($page - 1) * $limit;
|
$offset = ($page - 1) * $limit;
|
||||||
$pageItems = array_slice($filtered, $offset, $limit);
|
$pageItems = array_slice($filtered, $offset, $limit);
|
||||||
|
|
||||||
$outItems = array_map(fn (array $item): array => $this->transformItem($item, $tier), $pageItems);
|
$outItems = array_map(fn (array $item): array => $this->transformItem($item, $tier), $pageItems);
|
||||||
|
|
||||||
return $this->jsonWithTier([
|
$responsePayload = [
|
||||||
'items' => $outItems,
|
'items' => $outItems,
|
||||||
'total' => $total,
|
'total' => $total,
|
||||||
'page' => $page,
|
'page' => $page,
|
||||||
'limit' => $limit,
|
'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
|
private function detectTier(Request $request): string
|
||||||
@@ -122,22 +262,26 @@ class EmojiApiController extends Controller
|
|||||||
if ($key === '') {
|
if ($key === '') {
|
||||||
$key = trim((string) $request->header('X-License-Key', ''));
|
$key = trim((string) $request->header('X-License-Key', ''));
|
||||||
}
|
}
|
||||||
|
if ($key === '') {
|
||||||
|
$key = trim((string) $request->query('key', ''));
|
||||||
|
}
|
||||||
|
|
||||||
if ($key === '') {
|
if ($key === '') {
|
||||||
return self::TIER_FREE;
|
return self::TIER_FREE;
|
||||||
}
|
}
|
||||||
|
if ($this->verification->isPro($key)) {
|
||||||
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)) {
|
|
||||||
return self::TIER_PRO;
|
return self::TIER_PRO;
|
||||||
}
|
}
|
||||||
|
|
||||||
return self::TIER_FREE;
|
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>
|
* @return array<string,mixed>
|
||||||
*/
|
*/
|
||||||
@@ -163,34 +307,65 @@ class EmojiApiController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
self::$dataset = $decoded;
|
self::$dataset = $decoded;
|
||||||
|
|
||||||
return self::$dataset;
|
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
|
* @param array<string,mixed> $item
|
||||||
* @return array<string,mixed>
|
* @return array<string,mixed>
|
||||||
*/
|
*/
|
||||||
private function transformItem(array $item, string $tier): array
|
private function transformItem(array $item, string $tier): array
|
||||||
{
|
{
|
||||||
|
$supportsTone = (bool) ($item['supports_skin_tone'] ?? false);
|
||||||
|
$emoji = (string) ($item['emoji'] ?? '');
|
||||||
|
|
||||||
$out = [
|
$out = [
|
||||||
'emoji' => (string) ($item['emoji'] ?? ''),
|
'emoji' => $emoji,
|
||||||
'name' => (string) ($item['name'] ?? ''),
|
'name' => (string) ($item['name'] ?? ''),
|
||||||
'slug' => (string) ($item['slug'] ?? ''),
|
'slug' => (string) ($item['slug'] ?? ''),
|
||||||
'category' => (string) ($item['category'] ?? ''),
|
'category' => (string) ($item['category'] ?? ''),
|
||||||
'subcategory' => (string) ($item['subcategory'] ?? ''),
|
'subcategory' => (string) ($item['subcategory'] ?? ''),
|
||||||
'supports_skin_tone' => (bool) ($item['supports_skin_tone'] ?? false),
|
'supports_skin_tone' => $supportsTone,
|
||||||
'summary' => $this->summary((string) ($item['description'] ?? ''), 150),
|
'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) {
|
if ($tier === self::TIER_PRO) {
|
||||||
$out += [
|
$out += [
|
||||||
'unified' => (string) ($item['unified'] ?? ''),
|
'unified' => (string) ($item['unified'] ?? ''),
|
||||||
'codepoints' => $item['codepoints'] ?? [],
|
'codepoints' => $item['codepoints'] ?? [],
|
||||||
'shortcodes' => $item['shortcodes'] ?? [],
|
'shortcodes' => $item['shortcodes'] ?? [],
|
||||||
'aliases' => $item['aliases'] ?? [],
|
'aliases' => $item['aliases'] ?? [],
|
||||||
'keywords_en' => $item['keywords_en'] ?? [],
|
'keywords_en' => array_slice($item['keywords_en'] ?? [], 0, 30),
|
||||||
'keywords_id' => $item['keywords_id'] ?? [],
|
'keywords_id' => array_slice($item['keywords_id'] ?? [], 0, 30),
|
||||||
'related' => $item['related'] ?? [],
|
'related' => $item['related'] ?? [],
|
||||||
'intent_tags' => $item['intent_tags'] ?? [],
|
'intent_tags' => $item['intent_tags'] ?? [],
|
||||||
'description' => (string) ($item['description'] ?? ''),
|
'description' => (string) ($item['description'] ?? ''),
|
||||||
@@ -200,6 +375,162 @@ class EmojiApiController extends Controller
|
|||||||
return $out;
|
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
|
private function summary(string $text, int $max): string
|
||||||
{
|
{
|
||||||
$text = trim(preg_replace('/\s+/', ' ', strip_tags($text)) ?? '');
|
$text = trim(preg_replace('/\s+/', ' ', strip_tags($text)) ?? '');
|
||||||
@@ -212,15 +543,24 @@ class EmojiApiController extends Controller
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string,mixed> $payload
|
* @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()
|
$headers = [
|
||||||
->json($payload, $status, [
|
|
||||||
'X-Dewemoji-Tier' => $tier,
|
'X-Dewemoji-Tier' => $tier,
|
||||||
'Access-Control-Allow-Origin' => '*',
|
'X-Dewemoji-Plan' => $tier,
|
||||||
'Access-Control-Allow-Methods' => 'GET, POST, OPTIONS',
|
'Vary' => 'Origin',
|
||||||
'Access-Control-Allow-Headers' => 'Content-Type, Authorization, X-License-Key, X-Account-Id, X-Dewemoji-Frontend',
|
'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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,75 +2,412 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Api\V1;
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Services\Billing\LicenseVerificationService;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
class LicenseController extends Controller
|
class LicenseController extends Controller
|
||||||
{
|
{
|
||||||
|
private const TIER_FREE = 'free';
|
||||||
|
private const TIER_PRO = 'pro';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly LicenseVerificationService $verification
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
public function verify(Request $request): JsonResponse
|
public function verify(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$key = trim((string) $request->input('key', ''));
|
$key = $this->extractKey($request);
|
||||||
$accountId = trim((string) $request->input('account_id', ''));
|
|
||||||
$version = trim((string) $request->input('version', ''));
|
|
||||||
|
|
||||||
if ($key === '') {
|
if ($key === '') {
|
||||||
return $this->response([
|
return $this->response($request, [
|
||||||
'ok' => false,
|
'ok' => false,
|
||||||
'error' => 'missing_key',
|
'error' => 'missing_key',
|
||||||
]);
|
], 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($accountId === '') {
|
$check = $this->verification->verify($key, true);
|
||||||
return $this->response([
|
if (!($check['ok'] ?? false)) {
|
||||||
|
return $this->response($request, [
|
||||||
'ok' => false,
|
'ok' => false,
|
||||||
'error' => 'missing_account_id',
|
'tier' => self::TIER_FREE,
|
||||||
]);
|
'error' => 'invalid_license',
|
||||||
|
'details' => $check['details'] ?? [],
|
||||||
|
], 401, self::TIER_FREE);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($version === '') {
|
$this->upsertLicense($key, $check);
|
||||||
return $this->response([
|
|
||||||
'ok' => false,
|
return $this->response($request, [
|
||||||
'error' => 'missing_version',
|
'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);
|
||||||
}
|
}
|
||||||
|
|
||||||
$valid = $this->isValidKey($key);
|
public function activate(Request $request): JsonResponse
|
||||||
|
|
||||||
return $this->response([
|
|
||||||
'ok' => $valid,
|
|
||||||
'tier' => $valid ? 'pro' : 'free',
|
|
||||||
'error' => $valid ? null : 'invalid_license',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function isValidKey(string $key): bool
|
|
||||||
{
|
{
|
||||||
if ((bool) config('dewemoji.license.accept_all', false)) {
|
$key = $this->extractKey($request);
|
||||||
return true;
|
$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);
|
||||||
}
|
}
|
||||||
|
|
||||||
$validKeys = config('dewemoji.license.pro_keys', []);
|
$this->upsertLicense($key, $check);
|
||||||
if (!is_array($validKeys)) {
|
|
||||||
return false;
|
$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 in_array($key, $validKeys, true);
|
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
|
* @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';
|
$headers = [
|
||||||
|
|
||||||
return response()->json($payload, $status, [
|
|
||||||
'X-Dewemoji-Tier' => $tier,
|
'X-Dewemoji-Tier' => $tier,
|
||||||
'Access-Control-Allow-Origin' => '*',
|
'X-Dewemoji-Plan' => $tier,
|
||||||
'Access-Control-Allow-Methods' => 'GET, POST, OPTIONS',
|
'Vary' => 'Origin',
|
||||||
'Access-Control-Allow-Headers' => 'Content-Type, Authorization, X-License-Key, X-Account-Id, X-Dewemoji-Frontend',
|
'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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
135
app/app/Http/Controllers/Api/V1/SystemController.php
Normal file
135
app/app/Http/Controllers/Api/V1/SystemController.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -4,13 +4,87 @@ namespace App\Http\Controllers\Web;
|
|||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use Illuminate\Contracts\View\View;
|
use Illuminate\Contracts\View\View;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Response;
|
use Illuminate\Http\Response;
|
||||||
|
|
||||||
class SiteController extends Controller
|
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
|
public function apiDocs(): View
|
||||||
@@ -65,7 +139,20 @@ class SiteController extends Controller
|
|||||||
|
|
||||||
return view('site.emoji-detail', [
|
return view('site.emoji-detail', [
|
||||||
'emoji' => $match,
|
'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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
33
app/app/Http/Middleware/CanonicalPathMiddleware.php
Normal file
33
app/app/Http/Middleware/CanonicalPathMiddleware.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
335
app/app/Services/Billing/LicenseVerificationService.php
Normal file
335
app/app/Services/Billing/LicenseVerificationService.php
Normal 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Middleware\CanonicalPathMiddleware;
|
||||||
use Illuminate\Foundation\Application;
|
use Illuminate\Foundation\Application;
|
||||||
use Illuminate\Foundation\Configuration\Exceptions;
|
use Illuminate\Foundation\Configuration\Exceptions;
|
||||||
use Illuminate\Foundation\Configuration\Middleware;
|
use Illuminate\Foundation\Configuration\Middleware;
|
||||||
@@ -13,7 +14,9 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
health: '/up',
|
health: '/up',
|
||||||
)
|
)
|
||||||
->withMiddleware(function (Middleware $middleware): void {
|
->withMiddleware(function (Middleware $middleware): void {
|
||||||
//
|
$middleware->web(append: [
|
||||||
|
CanonicalPathMiddleware::class,
|
||||||
|
]);
|
||||||
})
|
})
|
||||||
->withExceptions(function (Exceptions $exceptions): void {
|
->withExceptions(function (Exceptions $exceptions): void {
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -6,11 +6,58 @@ return [
|
|||||||
'pagination' => [
|
'pagination' => [
|
||||||
'default_limit' => (int) env('DEWEMOJI_DEFAULT_LIMIT', 20),
|
'default_limit' => (int) env('DEWEMOJI_DEFAULT_LIMIT', 20),
|
||||||
'max_limit' => (int) env('DEWEMOJI_MAX_LIMIT', 50),
|
'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' => [
|
'license' => [
|
||||||
'accept_all' => filter_var(env('DEWEMOJI_LICENSE_ACCEPT_ALL', false), FILTER_VALIDATE_BOOL),
|
'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', ''))))),
|
'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'))))),
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('licenses', function (Blueprint $table): void {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('license_activations', function (Blueprint $table): void {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('usage_logs', function (Blueprint $table): void {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
@@ -176,6 +176,10 @@
|
|||||||
<script>
|
<script>
|
||||||
(() => {
|
(() => {
|
||||||
const state = { page: 1, limit: 32, total: 0, items: [], categories: {} };
|
const state = { page: 1, limit: 32, total: 0, items: [], categories: {} };
|
||||||
|
const initialQuery = @json($initialQuery ?? '');
|
||||||
|
const initialCategory = @json($initialCategory ?? '');
|
||||||
|
const initialSubcategory = @json($initialSubcategory ?? '');
|
||||||
|
const initialPath = @json($canonicalPath ?? '/');
|
||||||
const qEl = document.getElementById('q');
|
const qEl = document.getElementById('q');
|
||||||
const catEl = document.getElementById('category');
|
const catEl = document.getElementById('category');
|
||||||
const subEl = document.getElementById('subcategory');
|
const subEl = document.getElementById('subcategory');
|
||||||
@@ -191,8 +195,60 @@
|
|||||||
const heroOptional1 = document.getElementById('hero-optional-1');
|
const heroOptional1 = document.getElementById('hero-optional-1');
|
||||||
const heroOptional2 = document.getElementById('hero-optional-2');
|
const heroOptional2 = document.getElementById('hero-optional-2');
|
||||||
|
|
||||||
const initParams = new URLSearchParams(window.location.search);
|
if (initialQuery) qEl.value = initialQuery;
|
||||||
if (initParams.get('q')) qEl.value = initParams.get('q');
|
|
||||||
|
function slugify(text) {
|
||||||
|
return String(text || '')
|
||||||
|
.toLowerCase()
|
||||||
|
.replaceAll('&', 'and')
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function categoryLabelToSlug(label) {
|
||||||
|
const map = {
|
||||||
|
'Smileys & Emotion': 'smileys',
|
||||||
|
'People & Body': 'people',
|
||||||
|
'Animals & Nature': 'animals',
|
||||||
|
'Food & Drink': 'food',
|
||||||
|
'Travel & Places': 'travel',
|
||||||
|
'Activities': 'activities',
|
||||||
|
'Objects': 'objects',
|
||||||
|
'Symbols': 'symbols',
|
||||||
|
'Flags': 'flags',
|
||||||
|
};
|
||||||
|
return map[String(label || '')] || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncUrl() {
|
||||||
|
const q = qEl.value.trim();
|
||||||
|
const cat = catEl.value;
|
||||||
|
const sub = subEl.value;
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
let path = '/';
|
||||||
|
|
||||||
|
if (q !== '') {
|
||||||
|
params.set('q', q);
|
||||||
|
if (cat) params.set('category', cat);
|
||||||
|
if (sub) params.set('subcategory', sub);
|
||||||
|
path = '/';
|
||||||
|
} else if (cat) {
|
||||||
|
const catSlug = categoryLabelToSlug(cat);
|
||||||
|
if (catSlug) {
|
||||||
|
path = '/' + catSlug;
|
||||||
|
if (sub) path += '/' + slugify(sub);
|
||||||
|
} else {
|
||||||
|
params.set('category', cat);
|
||||||
|
if (sub) params.set('subcategory', sub);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
path = initialPath === '/browse' ? '/browse' : '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = params.toString();
|
||||||
|
const target = query ? `${path}?${query}` : path;
|
||||||
|
window.history.replaceState({}, '', target);
|
||||||
|
}
|
||||||
|
|
||||||
function esc(s) {
|
function esc(s) {
|
||||||
return String(s || '').replace(/[&<>"']/g, (c) => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c]));
|
return String(s || '').replace(/[&<>"']/g, (c) => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c]));
|
||||||
@@ -338,6 +394,7 @@
|
|||||||
incoming.forEach((item) => state.items.push(item));
|
incoming.forEach((item) => state.items.push(item));
|
||||||
renderGrid(incoming, reset);
|
renderGrid(incoming, reset);
|
||||||
updateStats();
|
updateStats();
|
||||||
|
syncUrl();
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderGrid(items, reset) {
|
function renderGrid(items, reset) {
|
||||||
@@ -399,6 +456,14 @@
|
|||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
await loadCategories();
|
await loadCategories();
|
||||||
|
if (initialCategory && state.categories[initialCategory]) {
|
||||||
|
catEl.value = initialCategory;
|
||||||
|
renderSubcategories();
|
||||||
|
if (initialSubcategory) {
|
||||||
|
const hasInitialSub = Array.from(subEl.options).some((opt) => opt.value === initialSubcategory);
|
||||||
|
if (hasInitialSub) subEl.value = initialSubcategory;
|
||||||
|
}
|
||||||
|
}
|
||||||
await fetchEmojis(true);
|
await fetchEmojis(true);
|
||||||
renderTrendingFromItems(state.items);
|
renderTrendingFromItems(state.items);
|
||||||
renderRecent();
|
renderRecent();
|
||||||
|
|||||||
@@ -3,7 +3,16 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
@php
|
||||||
|
$canonicalPath = $canonicalPath ?? request()->getPathInfo();
|
||||||
|
$canonicalPath = $canonicalPath === '' ? '/' : $canonicalPath;
|
||||||
|
if ($canonicalPath !== '/') {
|
||||||
|
$canonicalPath = '/'.trim($canonicalPath, '/');
|
||||||
|
}
|
||||||
|
$canonicalUrl = rtrim(config('app.url', request()->getSchemeAndHttpHost()), '/').$canonicalPath;
|
||||||
|
@endphp
|
||||||
<title>@yield('title', 'Dewemoji')</title>
|
<title>@yield('title', 'Dewemoji')</title>
|
||||||
|
<link rel="canonical" href="{{ $canonicalUrl }}">
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
|||||||
@@ -2,19 +2,35 @@
|
|||||||
|
|
||||||
use App\Http\Controllers\Api\V1\EmojiApiController;
|
use App\Http\Controllers\Api\V1\EmojiApiController;
|
||||||
use App\Http\Controllers\Api\V1\LicenseController;
|
use App\Http\Controllers\Api\V1\LicenseController;
|
||||||
|
use App\Http\Controllers\Api\V1\SystemController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::options('/v1/{any}', function () {
|
Route::options('/v1/{any}', function () {
|
||||||
return response('', 204, [
|
$headers = [
|
||||||
'Access-Control-Allow-Origin' => '*',
|
|
||||||
'Access-Control-Allow-Methods' => 'GET, POST, OPTIONS',
|
'Access-Control-Allow-Methods' => 'GET, POST, OPTIONS',
|
||||||
'Access-Control-Allow-Headers' => 'Content-Type, Authorization, X-License-Key, X-Account-Id, X-Dewemoji-Frontend',
|
'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', '.*');
|
})->where('any', '.*');
|
||||||
|
|
||||||
Route::prefix('v1')->group(function () {
|
Route::prefix('v1')->group(function () {
|
||||||
Route::get('/categories', [EmojiApiController::class, 'categories']);
|
Route::get('/categories', [EmojiApiController::class, 'categories']);
|
||||||
Route::get('/emojis', [EmojiApiController::class, 'emojis']);
|
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']);
|
||||||
|
});
|
||||||
|
|||||||
@@ -4,9 +4,18 @@ use App\Http\Controllers\Web\SiteController;
|
|||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::get('/', [SiteController::class, 'home'])->name('home');
|
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('/api-docs', [SiteController::class, 'apiDocs'])->name('api-docs');
|
||||||
Route::get('/emoji/{slug}', [SiteController::class, 'emojiDetail'])->name('emoji-detail');
|
Route::get('/emoji/{slug}', [SiteController::class, 'emojiDetail'])->name('emoji-detail');
|
||||||
|
|
||||||
Route::get('/pricing', [SiteController::class, 'pricing'])->name('pricing');
|
Route::get('/pricing', [SiteController::class, 'pricing'])->name('pricing');
|
||||||
Route::get('/privacy', [SiteController::class, 'privacy'])->name('privacy');
|
Route::get('/privacy', [SiteController::class, 'privacy'])->name('privacy');
|
||||||
Route::get('/terms', [SiteController::class, 'terms'])->name('terms');
|
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');
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ class ApiV1EndpointsTest extends TestCase
|
|||||||
public function test_license_verify_returns_ok_when_accept_all_is_enabled(): void
|
public function test_license_verify_returns_ok_when_accept_all_is_enabled(): void
|
||||||
{
|
{
|
||||||
config()->set('dewemoji.license.accept_all', true);
|
config()->set('dewemoji.license.accept_all', true);
|
||||||
|
config()->set('dewemoji.billing.mode', 'live');
|
||||||
|
|
||||||
$response = $this->postJson('/v1/license/verify', [
|
$response = $this->postJson('/v1/license/verify', [
|
||||||
'key' => 'dummy-key',
|
'key' => 'dummy-key',
|
||||||
@@ -53,5 +54,174 @@ class ApiV1EndpointsTest extends TestCase
|
|||||||
'tier' => 'pro',
|
'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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ class SitePagesTest extends TestCase
|
|||||||
public function test_core_pages_are_available(): void
|
public function test_core_pages_are_available(): void
|
||||||
{
|
{
|
||||||
$this->get('/')->assertOk();
|
$this->get('/')->assertOk();
|
||||||
|
$this->get('/browse')->assertOk();
|
||||||
|
$this->get('/animals')->assertOk();
|
||||||
|
$this->get('/animals/animal-mammal')->assertOk();
|
||||||
$this->get('/api-docs')->assertOk();
|
$this->get('/api-docs')->assertOk();
|
||||||
$this->get('/pricing')->assertOk();
|
$this->get('/pricing')->assertOk();
|
||||||
$this->get('/privacy')->assertOk();
|
$this->get('/privacy')->assertOk();
|
||||||
@@ -34,4 +37,3 @@ class SitePagesTest extends TestCase
|
|||||||
$this->get('/emoji/unknown-slug')->assertNotFound();
|
$this->get('/emoji/unknown-slug')->assertNotFound();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
58
billing-sandbox-live.md
Normal file
58
billing-sandbox-live.md
Normal file
@@ -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.
|
||||||
@@ -1,66 +1,240 @@
|
|||||||
# NativePHP Rebuild Progress (Retraced)
|
# NativePHP Rebuild Progress (with `dewemoji-live` audit)
|
||||||
|
|
||||||
## Confirmed source folders
|
## Confirmed source folders
|
||||||
|
|
||||||
- Backend: `../dewemoji-api`
|
- Backend: `../dewemoji-api`
|
||||||
|
- Live backend reference: `../dewemoji-live-backend` (source of truth for current API + community/pro flows)
|
||||||
- Chrome extension: `../dewemoji-chrome-ext`
|
- 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
|
## Current baseline
|
||||||
|
|
||||||
- Active backend logic exists in `dewemoji-api`.
|
- New rebuild app: `app/` (Laravel + NativePHP Desktop).
|
||||||
- Active extension logic exists in `dewemoji-chrome-ext`.
|
- API v1 routes exist in rebuild: `/v1/emojis`, `/v1/categories`, `/v1/license/verify`.
|
||||||
- `dewemoji-site` currently has structure but no implemented content (mostly 0-byte files).
|
- Website routes currently in rebuild: `/`, `/emoji/{slug}`, `/api-docs`, `/pricing`, `/privacy`, `/terms`.
|
||||||
- New rebuild app scaffold now exists at `app/` (Laravel + NativePHP Desktop installed).
|
- 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 checklist
|
||||||
|
|
||||||
### Phase 0 - Retrace and documentation
|
### Phase 0 - Retrace and documentation
|
||||||
- [x] Revalidated source folders with corrected names.
|
- [x] Revalidated source folders with corrected names.
|
||||||
- [x] Rebuilt docs against corrected folders.
|
- [x] Rebuilt initial docs against corrected folders.
|
||||||
- [x] Marked `dewemoji-site` as scaffold status.
|
- [x] Added live audit from `dewemoji-live`.
|
||||||
|
- [x] Added backend audit from `dewemoji-live-backend`.
|
||||||
|
|
||||||
### Phase 1 - Foundation
|
### Phase 1 - Foundation
|
||||||
- [x] Initialize NativePHP app in `dewemoji` (`app/` folder).
|
- [x] Initialize NativePHP app in `dewemoji/app`.
|
||||||
- [x] Install NativePHP Desktop scaffolding (`config/nativephp.php`, `NativeAppServiceProvider`).
|
- [x] Install NativePHP Desktop scaffolding.
|
||||||
- [x] Define initial env/config strategy (documented in `phase-1-foundation.md`).
|
- [x] Define env/config strategy.
|
||||||
- [x] Decide canonical data source: start from `dewemoji-api/data/emojis.json`.
|
- [x] Use canonical emoji dataset as baseline.
|
||||||
|
|
||||||
### Phase 2 - API parity (extension first)
|
### Phase 2 - API parity (extension first)
|
||||||
- [x] Implement `GET /v1/emojis`.
|
- [x] Implement `GET /v1/emojis`.
|
||||||
- [x] Implement `GET /v1/categories`.
|
- [x] Implement `GET /v1/categories`.
|
||||||
- [x] Implement `POST /v1/license/verify`.
|
- [x] Implement `POST /v1/license/verify` (temporary env-based validation).
|
||||||
- [x] Preserve response/header compatibility (`X-Dewemoji-Tier`).
|
- [x] Support both `q` and `query`.
|
||||||
- [x] Support both `q` and `query` inputs.
|
- [ ] 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
|
### Phase 3 - Website parity from `dewemoji-live`
|
||||||
- [x] Build website pages in new app (index, emoji detail, api docs, legal pages).
|
- [x] Core pages exist in rebuild.
|
||||||
- [ ] Replace scaffold in `dewemoji-site` via new NativePHP output.
|
- [ ] 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
|
### Phase 4 - Pricing and payments
|
||||||
- [ ] Point `dewemoji-chrome-ext` API base to new app endpoint.
|
- [ ] Keep pricing structure parity:
|
||||||
- [ ] Validate free/pro flow, insert mode, and tone behavior.
|
- 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
|
### Phase 5 - SEO parity (must not disrupt GSC)
|
||||||
- [ ] Add endpoint tests + regression checks for extension-critical fields.
|
- [ ] Preserve canonical strategy for all pages (including emoji detail + pretty category pages).
|
||||||
- [ ] Add migration and rollback checklist.
|
- [ ] 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`.
|
### Phase 7 - Data/ops pipelines
|
||||||
- Backend tier validation stubs in legacy PHP.
|
- [ ] Port blurb pipeline:
|
||||||
- Contract differences between current `/api/*` and extension `/v1/*`.
|
- `jobs/seed_blurbs_from_dataset.php`
|
||||||
- Website source currently split (`dewemoji-api` active pages vs `dewemoji-site` empty scaffold).
|
- `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.
|
### Phase 9 - Extension integration and release
|
||||||
- License verification is currently environment-driven (`DEWEMOJI_LICENSE_ACCEPT_ALL` / `DEWEMOJI_PRO_KEYS`) as a safe stub before real provider integration.
|
- [ ] Point `dewemoji-chrome-ext` to new API host.
|
||||||
- Test coverage added for `v1` endpoints in `app/tests/Feature/ApiV1EndpointsTest.php`.
|
- [ ] 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:
|
- Added new API endpoints in rebuild:
|
||||||
- `/`, `/emoji/{slug}`, `/api-docs`, `/pricing`, `/privacy`, `/terms`
|
- `GET /v1/emoji`
|
||||||
- Home page now consumes `/v1/categories` and `/v1/emojis` directly.
|
- `GET /v1/emoji/{slug}`
|
||||||
- Added page tests in `app/tests/Feature/SitePagesTest.php`.
|
- `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 `<link>` 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.
|
||||||
|
|||||||
Reference in New Issue
Block a user