refactor: finalize account-based billing and env cleanup
This commit is contained in:
@@ -72,35 +72,19 @@ DEWEMOJI_DEFAULT_LIMIT=20
|
||||
DEWEMOJI_MAX_LIMIT=50
|
||||
DEWEMOJI_FREE_MAX_LIMIT=20
|
||||
DEWEMOJI_PRO_MAX_LIMIT=50
|
||||
DEWEMOJI_FREE_DAILY_LIMIT=30
|
||||
DEWEMOJI_RATE_LIMIT_ENABLED=true
|
||||
DEWEMOJI_LICENSE_ACCEPT_ALL=true
|
||||
DEWEMOJI_PRO_KEYS=
|
||||
DEWEMOJI_LICENSE_MAX_DEVICES=3
|
||||
DEWEMOJI_BILLING_MODE=sandbox
|
||||
DEWEMOJI_VERIFY_CACHE_TTL=300
|
||||
DEWEMOJI_GUMROAD_ENABLED=false
|
||||
DEWEMOJI_GUMROAD_VERIFY_URL=https://api.gumroad.com/v2/licenses/verify
|
||||
DEWEMOJI_GUMROAD_PRODUCT_IDS=
|
||||
DEWEMOJI_GUMROAD_TIMEOUT=8
|
||||
DEWEMOJI_GUMROAD_TEST_KEYS=
|
||||
DEWEMOJI_MAYAR_ENABLED=false
|
||||
DEWEMOJI_MAYAR_VERIFY_URL=
|
||||
DEWEMOJI_MAYAR_API_BASE=
|
||||
DEWEMOJI_MAYAR_ENDPOINT_VERIFY=/v1/license/verify
|
||||
DEWEMOJI_MAYAR_PRODUCT_IDS=
|
||||
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
|
||||
DEWEMOJI_PUBLIC_ENFORCE=true
|
||||
DEWEMOJI_PUBLIC_ORIGINS=http://127.0.0.1:8000,http://localhost:8000,https://dewemoji.com,https://www.dewemoji.com
|
||||
DEWEMOJI_PUBLIC_HOURLY_LIMIT=5000
|
||||
DEWEMOJI_USD_RATE=15000
|
||||
DEWEMOJI_QRIS_URL=
|
||||
DEWEMOJI_PAYPAL_URL=
|
||||
DEWEMOJI_ADMIN_TOKEN=
|
||||
DEWEMOJI_PAYPAL_ENABLED=false
|
||||
DEWEMOJI_PAYPAL_TIMEOUT=10
|
||||
DEWEMOJI_PAYPAL_SANDBOX_CLIENT_ID=
|
||||
@@ -122,3 +106,7 @@ DEWEMOJI_PAKASIR_API_BASE=https://app.pakasir.com
|
||||
DEWEMOJI_PAKASIR_API_KEY=
|
||||
DEWEMOJI_PAKASIR_PROJECT=
|
||||
DEWEMOJI_PAKASIR_TIMEOUT=10
|
||||
DEWEMOJI_EXTENSION_VERIFY_ENABLED=true
|
||||
DEWEMOJI_GOOGLE_PROJECT_ID=
|
||||
DEWEMOJI_GOOGLE_SERVER_KEY=
|
||||
DEWEMOJI_EXTENSION_IDS=
|
||||
|
||||
@@ -2,16 +2,12 @@
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Services\Billing\LicenseVerificationService;
|
||||
use App\Services\Auth\ApiKeyService;
|
||||
use App\Services\System\SettingsService;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use RuntimeException;
|
||||
|
||||
class EmojiApiController extends Controller
|
||||
@@ -23,7 +19,6 @@ class EmojiApiController extends Controller
|
||||
private static ?array $dataset = null;
|
||||
|
||||
public function __construct(
|
||||
private readonly LicenseVerificationService $verification,
|
||||
private readonly ApiKeyService $apiKeys
|
||||
) {
|
||||
}
|
||||
@@ -324,18 +319,13 @@ class EmojiApiController extends Controller
|
||||
|
||||
private function detectTier(Request $request): string
|
||||
{
|
||||
$key = trim((string) $request->bearerToken());
|
||||
if ($key === '') {
|
||||
$key = trim((string) $request->header('X-License-Key', ''));
|
||||
}
|
||||
if ($key === '') {
|
||||
$key = trim((string) $request->query('key', ''));
|
||||
$apiUser = $this->apiKeys->resolveUser($request);
|
||||
if ($apiUser && (string) $apiUser->tier === 'personal') {
|
||||
return self::TIER_PRO;
|
||||
}
|
||||
|
||||
if ($key === '') {
|
||||
return self::TIER_FREE;
|
||||
}
|
||||
if ($this->verification->isPro($key)) {
|
||||
$webUser = $request->user();
|
||||
if ($webUser && (string) $webUser->tier === 'personal') {
|
||||
return self::TIER_PRO;
|
||||
}
|
||||
|
||||
@@ -344,11 +334,6 @@ class EmojiApiController extends Controller
|
||||
|
||||
private function enforcePublicAccess(Request $request): ?JsonResponse
|
||||
{
|
||||
if ($this->hasApiKeyAuth($request)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$config = config('dewemoji.public_access', []);
|
||||
$settings = app(SettingsService::class);
|
||||
$maintenanceEnabled = (bool) $settings->get('maintenance_enabled', false);
|
||||
if ($maintenanceEnabled) {
|
||||
@@ -358,132 +343,9 @@ class EmojiApiController extends Controller
|
||||
], self::TIER_FREE, 503);
|
||||
}
|
||||
|
||||
$enforceWhitelist = (bool) $settings->get('public_enforce', $config['enforce_whitelist'] ?? false);
|
||||
$allowedOrigins = $settings->get('public_origins', $config['allowed_origins'] ?? []);
|
||||
$extensionIds = $settings->get('public_extension_ids', $config['extension_ids'] ?? []);
|
||||
$limitOverride = $settings->get('public_hourly_limit', $config['hourly_limit'] ?? 0);
|
||||
|
||||
$allowed = $this->isPublicAllowed($request, [
|
||||
'allowed_origins' => $allowedOrigins,
|
||||
'extension_ids' => $extensionIds,
|
||||
]);
|
||||
|
||||
if ($enforceWhitelist && !$allowed) {
|
||||
return $this->jsonWithTier($request, [
|
||||
'ok' => false,
|
||||
'error' => 'public_access_denied',
|
||||
], self::TIER_FREE, 403);
|
||||
}
|
||||
|
||||
if (!$allowed) {
|
||||
$limit = max((int) $limitOverride, 0);
|
||||
if ($limit > 0) {
|
||||
$usage = $this->trackPublicHourlyUsage($request, $limit);
|
||||
if ($usage['blocked']) {
|
||||
return $this->jsonWithTier($request, [
|
||||
'ok' => false,
|
||||
'error' => 'public_rate_limited',
|
||||
'usage' => $usage['meta'],
|
||||
], self::TIER_FREE, 429, [
|
||||
'X-RateLimit-Limit' => (string) $usage['meta']['limit'],
|
||||
'X-RateLimit-Remaining' => '0',
|
||||
'X-RateLimit-Reset' => (string) $usage['meta']['window_ends_at_unix'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $config
|
||||
*/
|
||||
private function isPublicAllowed(Request $request, array $config): bool
|
||||
{
|
||||
if (app()->environment(['local', 'testing'])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$host = trim((string) $request->getHost());
|
||||
if (in_array($host, ['127.0.0.1', 'localhost'], true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$origin = trim((string) $request->headers->get('Origin', ''));
|
||||
$allowedOrigins = $config['allowed_origins'] ?? [];
|
||||
if ($origin !== '' && is_array($allowedOrigins) && in_array($origin, $allowedOrigins, true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$frontendHeader = trim((string) $request->header('X-Dewemoji-Frontend', ''));
|
||||
$extensionIds = $config['extension_ids'] ?? [];
|
||||
if ($frontendHeader !== '' && is_array($extensionIds)) {
|
||||
foreach ($extensionIds as $id) {
|
||||
if ($id !== '' && str_contains($frontendHeader, $id)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$extensionHeader = trim((string) $request->header('X-Extension-Id', ''));
|
||||
if ($extensionHeader !== '' && is_array($extensionIds)) {
|
||||
if (in_array($extensionHeader, $extensionIds, true)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
$userAgent = (string) $request->userAgent();
|
||||
if ($userAgent !== '' && is_array($extensionIds)) {
|
||||
foreach ($extensionIds as $id) {
|
||||
if ($id !== '' && str_contains($userAgent, $id)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function hasApiKeyAuth(Request $request): bool
|
||||
{
|
||||
return $this->apiKeys->resolveUser($request) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{blocked:bool,meta:array<string,mixed>}
|
||||
*/
|
||||
private function trackPublicHourlyUsage(Request $request, int $limit): array
|
||||
{
|
||||
$bucket = sha1((string) $request->ip().'|'.(string) $request->userAgent());
|
||||
$hourKey = Carbon::now('UTC')->format('YmdH');
|
||||
$cacheKey = 'dw_public_hourly_'.$bucket.'_'.$hourKey;
|
||||
$seconds = max(60, Carbon::now('UTC')->diffInSeconds(Carbon::now('UTC')->addHour()->startOfHour()));
|
||||
|
||||
$current = Cache::get($cacheKey, 0);
|
||||
if (!is_int($current)) {
|
||||
$current = (int) $current;
|
||||
}
|
||||
|
||||
$current++;
|
||||
Cache::put($cacheKey, $current, $seconds);
|
||||
|
||||
$blocked = $current > $limit;
|
||||
$windowEnds = Carbon::now('UTC')->addHour()->startOfHour();
|
||||
|
||||
return [
|
||||
'blocked' => $blocked,
|
||||
'meta' => [
|
||||
'used' => min($current, $limit),
|
||||
'limit' => $limit,
|
||||
'remaining' => max(0, $limit - $current),
|
||||
'window' => 'hourly',
|
||||
'window_ends_at' => $windowEnds->toIso8601String(),
|
||||
'window_ends_at_unix' => $windowEnds->timestamp,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function isNotModified(Request $request, string $etag): bool
|
||||
{
|
||||
$ifNoneMatch = trim((string) $request->header('If-None-Match', ''));
|
||||
@@ -647,162 +509,6 @@ class EmojiApiController extends Controller
|
||||
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);
|
||||
$rateLimitEnabled = (bool) config('dewemoji.rate_limit_enabled', true);
|
||||
|
||||
// Local development should not silently look broken because of daily metering.
|
||||
if (!$rateLimitEnabled || app()->environment('local')) {
|
||||
return [
|
||||
'blocked' => false,
|
||||
'meta' => [
|
||||
'used' => 0,
|
||||
'limit' => $dailyLimit,
|
||||
'remaining' => $dailyLimit,
|
||||
'window' => 'daily',
|
||||
'window_ends_at' => Carbon::tomorrow('UTC')->toIso8601String(),
|
||||
'count_basis' => 'distinct_query',
|
||||
'metering' => 'disabled',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
$key = trim((string) $request->query('key', ''));
|
||||
if ($key === '') {
|
||||
$key = trim((string) $request->header('X-License-Key', ''));
|
||||
}
|
||||
if ($key === '') {
|
||||
$key = trim((string) $request->bearerToken());
|
||||
}
|
||||
|
||||
$bucketRaw = $key !== ''
|
||||
? 'lic|'.$key
|
||||
: 'ip|'.$request->ip().'|'.(string) $request->userAgent();
|
||||
$bucketId = sha1($bucketRaw);
|
||||
$signature = sha1(strtolower($q).'|'.strtolower($category).'|'.strtolower($subcategory));
|
||||
|
||||
if (Schema::hasTable('usage_logs')) {
|
||||
return $this->trackUsageWithDatabase($bucketId, $signature, $dailyLimit);
|
||||
}
|
||||
|
||||
return $this->trackUsageWithCache($bucketId, $signature, $dailyLimit);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{blocked:bool,meta:array<string,mixed>}
|
||||
*/
|
||||
private function trackUsageWithDatabase(string $bucketId, string $signature, int $dailyLimit): array
|
||||
{
|
||||
$today = Carbon::today('UTC')->toDateString();
|
||||
$blocked = false;
|
||||
$used = 0;
|
||||
|
||||
DB::transaction(function () use ($bucketId, $signature, $dailyLimit, $today, &$blocked, &$used): void {
|
||||
$row = DB::table('usage_logs')
|
||||
->where('bucket_id', $bucketId)
|
||||
->where('date_key', $today)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if (!$row) {
|
||||
DB::table('usage_logs')->insert([
|
||||
'bucket_id' => $bucketId,
|
||||
'date_key' => $today,
|
||||
'used' => 0,
|
||||
'limit_count' => $dailyLimit,
|
||||
'seen_signatures' => json_encode([]),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$row = (object) [
|
||||
'used' => 0,
|
||||
'limit_count' => $dailyLimit,
|
||||
'seen_signatures' => json_encode([]),
|
||||
];
|
||||
}
|
||||
|
||||
$seen = json_decode((string) ($row->seen_signatures ?? '[]'), true);
|
||||
if (!is_array($seen)) {
|
||||
$seen = [];
|
||||
}
|
||||
|
||||
$usedNow = (int) ($row->used ?? 0);
|
||||
if (!array_key_exists($signature, $seen)) {
|
||||
if ($usedNow >= $dailyLimit) {
|
||||
$blocked = true;
|
||||
} else {
|
||||
$seen[$signature] = 1;
|
||||
$usedNow++;
|
||||
DB::table('usage_logs')
|
||||
->where('bucket_id', $bucketId)
|
||||
->where('date_key', $today)
|
||||
->update([
|
||||
'used' => $usedNow,
|
||||
'limit_count' => $dailyLimit,
|
||||
'seen_signatures' => json_encode($seen),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$used = min($usedNow, $dailyLimit);
|
||||
});
|
||||
|
||||
return [
|
||||
'blocked' => $blocked,
|
||||
'meta' => [
|
||||
'used' => $used,
|
||||
'limit' => $dailyLimit,
|
||||
'remaining' => max(0, $dailyLimit - $used),
|
||||
'window' => 'daily',
|
||||
'window_ends_at' => Carbon::tomorrow('UTC')->toIso8601String(),
|
||||
'count_basis' => 'distinct_query',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{blocked:bool,meta:array<string,mixed>}
|
||||
*/
|
||||
private function trackUsageWithCache(string $bucketId, string $signature, int $dailyLimit): array
|
||||
{
|
||||
$cacheKey = 'dw_usage_'.$bucketId.'_'.Carbon::now('UTC')->format('Ymd');
|
||||
$state = Cache::get($cacheKey, ['used' => 0, 'seen' => []]);
|
||||
if (!is_array($state)) {
|
||||
$state = ['used' => 0, 'seen' => []];
|
||||
}
|
||||
|
||||
$blocked = false;
|
||||
if (!isset($state['seen'][$signature])) {
|
||||
if ((int) $state['used'] >= $dailyLimit) {
|
||||
$blocked = true;
|
||||
} else {
|
||||
$state['used'] = (int) $state['used'] + 1;
|
||||
$state['seen'][$signature] = true;
|
||||
$seconds = max(60, Carbon::now('UTC')->diffInSeconds(Carbon::tomorrow('UTC')));
|
||||
Cache::put($cacheKey, $state, $seconds);
|
||||
}
|
||||
}
|
||||
|
||||
$used = min((int) $state['used'], $dailyLimit);
|
||||
return [
|
||||
'blocked' => $blocked,
|
||||
'meta' => [
|
||||
'used' => $used,
|
||||
'limit' => $dailyLimit,
|
||||
'remaining' => max(0, $dailyLimit - $used),
|
||||
'window' => 'daily',
|
||||
'window_ends_at' => Carbon::tomorrow('UTC')->toIso8601String(),
|
||||
'count_basis' => 'distinct_query',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function summary(string $text, int $max): string
|
||||
{
|
||||
$text = trim(preg_replace('/\s+/', ' ', strip_tags($text)) ?? '');
|
||||
@@ -824,7 +530,7 @@ class EmojiApiController extends Controller
|
||||
'X-Dewemoji-Plan' => $tier,
|
||||
'Vary' => 'Origin',
|
||||
'Access-Control-Allow-Methods' => (string) config('dewemoji.cors.allow_methods', 'GET, POST, OPTIONS'),
|
||||
'Access-Control-Allow-Headers' => (string) config('dewemoji.cors.allow_headers', 'Content-Type, Authorization, X-License-Key, X-Account-Id, X-Dewemoji-Frontend'),
|
||||
'Access-Control-Allow-Headers' => (string) config('dewemoji.cors.allow_headers', 'Content-Type, Authorization, X-Api-Key, X-Admin-Token, X-Account-Id, X-Dewemoji-Frontend, X-Extension-Token'),
|
||||
];
|
||||
|
||||
$origin = (string) $request->headers->get('Origin', '');
|
||||
|
||||
@@ -1,413 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Services\Billing\LicenseVerificationService;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class LicenseController extends Controller
|
||||
{
|
||||
private const TIER_FREE = 'free';
|
||||
private const TIER_PRO = 'pro';
|
||||
|
||||
public function __construct(
|
||||
private readonly LicenseVerificationService $verification
|
||||
) {
|
||||
}
|
||||
|
||||
public function verify(Request $request): JsonResponse
|
||||
{
|
||||
$key = $this->extractKey($request);
|
||||
if ($key === '') {
|
||||
return $this->response($request, [
|
||||
'ok' => false,
|
||||
'error' => 'missing_key',
|
||||
], 400);
|
||||
}
|
||||
|
||||
$check = $this->verification->verify($key, true);
|
||||
if (!($check['ok'] ?? false)) {
|
||||
return $this->response($request, [
|
||||
'ok' => false,
|
||||
'tier' => self::TIER_FREE,
|
||||
'error' => 'invalid_license',
|
||||
'details' => $check['details'] ?? [],
|
||||
], 401, self::TIER_FREE);
|
||||
}
|
||||
|
||||
$this->upsertLicense($key, $check);
|
||||
|
||||
return $this->response($request, [
|
||||
'ok' => true,
|
||||
'tier' => self::TIER_PRO,
|
||||
'source' => $check['source'] ?? 'unknown',
|
||||
'plan' => $check['plan'] ?? 'pro',
|
||||
'product_id' => $check['product_id'] ?? null,
|
||||
'expires_at' => $check['expires_at'] ?? null,
|
||||
'error' => null,
|
||||
], 200, self::TIER_PRO);
|
||||
}
|
||||
|
||||
public function activate(Request $request): JsonResponse
|
||||
{
|
||||
$key = $this->extractKey($request);
|
||||
$email = strtolower(trim((string) $request->input('email', '')));
|
||||
$product = strtolower(trim((string) $request->input('product', 'site')));
|
||||
$deviceId = trim((string) $request->input('device_id', ''));
|
||||
|
||||
if ($key === '') {
|
||||
return $this->response($request, ['ok' => false, 'error' => 'missing_key'], 400);
|
||||
}
|
||||
if ($email === '' || !str_contains($email, '@')) {
|
||||
return $this->response($request, ['ok' => false, 'error' => 'email_required'], 401);
|
||||
}
|
||||
if ($product !== 'site' && $deviceId === '') {
|
||||
return $this->response($request, ['ok' => false, 'error' => 'device_id_required'], 400);
|
||||
}
|
||||
$check = $this->verification->verify($key, true);
|
||||
if (!($check['ok'] ?? false)) {
|
||||
return $this->response($request, [
|
||||
'ok' => false,
|
||||
'error' => 'invalid_license',
|
||||
'details' => $check['details'] ?? [],
|
||||
], 401);
|
||||
}
|
||||
|
||||
$this->upsertLicense($key, $check);
|
||||
|
||||
$userId = hash('sha256', $email);
|
||||
$device = $product === 'site' ? '__site__' : $deviceId;
|
||||
$maxDevices = max((int) config('dewemoji.license.max_devices', 3), 1);
|
||||
|
||||
if ($this->hasDatabaseLicenseTables()) {
|
||||
return $this->activateUsingDatabase($request, $key, $userId, $product, $device, $maxDevices);
|
||||
}
|
||||
|
||||
return $this->activateUsingCache($request, $key, $userId, $product, $device, $maxDevices);
|
||||
}
|
||||
|
||||
public function deactivate(Request $request): JsonResponse
|
||||
{
|
||||
$key = $this->extractKey($request);
|
||||
$product = strtolower(trim((string) $request->input('product', 'site')));
|
||||
$deviceId = trim((string) $request->input('device_id', ''));
|
||||
$device = $product === 'site' ? '__site__' : $deviceId;
|
||||
|
||||
if ($key === '') {
|
||||
return $this->response($request, ['ok' => false, 'error' => 'missing_key'], 400);
|
||||
}
|
||||
if ($product !== 'site' && $deviceId === '') {
|
||||
return $this->response($request, ['ok' => false, 'error' => 'device_id_required'], 400);
|
||||
}
|
||||
|
||||
if ($this->hasDatabaseLicenseTables()) {
|
||||
$updated = DB::table('license_activations')
|
||||
->where('license_key', $key)
|
||||
->where('product', $product)
|
||||
->where('device_id', $device)
|
||||
->update([
|
||||
'status' => 'revoked',
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
if ($updated < 1) {
|
||||
return $this->response($request, [
|
||||
'ok' => false,
|
||||
'error' => 'activation_not_found',
|
||||
], 404);
|
||||
}
|
||||
} else {
|
||||
$state = $this->loadLicenseState($key);
|
||||
if (!isset($state['activations'][$product][$device])) {
|
||||
return $this->response($request, [
|
||||
'ok' => false,
|
||||
'error' => 'activation_not_found',
|
||||
], 404);
|
||||
}
|
||||
$state['activations'][$product][$device]['status'] = 'revoked';
|
||||
$state['activations'][$product][$device]['updated_at'] = now()->toIso8601String();
|
||||
$this->storeLicenseState($key, $state);
|
||||
}
|
||||
|
||||
return $this->response($request, [
|
||||
'ok' => true,
|
||||
'product' => $product,
|
||||
'device_id' => $product === 'site' ? null : $device,
|
||||
], 200, self::TIER_PRO);
|
||||
}
|
||||
|
||||
private function activateUsingDatabase(
|
||||
Request $request,
|
||||
string $key,
|
||||
string $userId,
|
||||
string $product,
|
||||
string $device,
|
||||
int $maxDevices
|
||||
): JsonResponse {
|
||||
try {
|
||||
return DB::transaction(function () use ($request, $key, $userId, $product, $device, $maxDevices) {
|
||||
$license = DB::table('licenses')
|
||||
->where('license_key', $key)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if (!$license) {
|
||||
return $this->response($request, ['ok' => false, 'error' => 'invalid_license'], 401);
|
||||
}
|
||||
|
||||
if ((string) ($license->status ?? 'active') !== 'active') {
|
||||
return $this->response($request, ['ok' => false, 'error' => 'inactive_or_expired'], 403);
|
||||
}
|
||||
|
||||
if (!empty($license->expires_at) && strtotime((string) $license->expires_at) <= time()) {
|
||||
return $this->response($request, ['ok' => false, 'error' => 'inactive_or_expired'], 403);
|
||||
}
|
||||
|
||||
$owner = (string) ($license->owner_user_id ?? '');
|
||||
if ($owner !== '' && $owner !== $userId) {
|
||||
return $this->response($request, [
|
||||
'ok' => false,
|
||||
'error' => 'key_already_attached',
|
||||
], 403);
|
||||
}
|
||||
|
||||
if ($owner === '') {
|
||||
DB::table('licenses')
|
||||
->where('license_key', $key)
|
||||
->update([
|
||||
'owner_user_id' => $userId,
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
$existing = DB::table('license_activations')
|
||||
->where('license_key', $key)
|
||||
->where('product', $product)
|
||||
->where('device_id', $device)
|
||||
->first();
|
||||
|
||||
if ($existing && (string) ($existing->status ?? '') === 'active') {
|
||||
return $this->response($request, [
|
||||
'ok' => true,
|
||||
'pro' => true,
|
||||
'product' => $product,
|
||||
'device_id' => $product === 'site' ? null : $device,
|
||||
'user_id' => $userId,
|
||||
'until' => $license->expires_at ?? null,
|
||||
], 200, self::TIER_PRO);
|
||||
}
|
||||
|
||||
$activeCount = (int) DB::table('license_activations')
|
||||
->where('license_key', $key)
|
||||
->where('product', $product)
|
||||
->where('status', 'active')
|
||||
->count();
|
||||
|
||||
if ($activeCount >= $maxDevices) {
|
||||
return $this->response($request, [
|
||||
'ok' => false,
|
||||
'error' => 'device_limit',
|
||||
'message' => 'Device limit reached for this license.',
|
||||
], 403);
|
||||
}
|
||||
|
||||
DB::table('license_activations')->updateOrInsert(
|
||||
[
|
||||
'license_key' => $key,
|
||||
'product' => $product,
|
||||
'device_id' => $device,
|
||||
],
|
||||
[
|
||||
'user_id' => $userId,
|
||||
'status' => 'active',
|
||||
'updated_at' => now(),
|
||||
'created_at' => $existing ? $existing->created_at : now(),
|
||||
]
|
||||
);
|
||||
|
||||
return $this->response($request, [
|
||||
'ok' => true,
|
||||
'pro' => true,
|
||||
'product' => $product,
|
||||
'device_id' => $product === 'site' ? null : $device,
|
||||
'user_id' => $userId,
|
||||
'until' => $license->expires_at ?? null,
|
||||
], 200, self::TIER_PRO);
|
||||
});
|
||||
} catch (\Throwable) {
|
||||
return $this->response($request, [
|
||||
'ok' => false,
|
||||
'error' => 'activate_failed',
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
private function activateUsingCache(
|
||||
Request $request,
|
||||
string $key,
|
||||
string $userId,
|
||||
string $product,
|
||||
string $device,
|
||||
int $maxDevices
|
||||
): JsonResponse {
|
||||
$state = $this->loadLicenseState($key);
|
||||
|
||||
if (($state['owner_user_id'] ?? '') !== '' && $state['owner_user_id'] !== $userId) {
|
||||
return $this->response($request, [
|
||||
'ok' => false,
|
||||
'error' => 'key_already_attached',
|
||||
], 403);
|
||||
}
|
||||
|
||||
$state['owner_user_id'] = $userId;
|
||||
$state['activations'][$product] ??= [];
|
||||
|
||||
if (isset($state['activations'][$product][$device]) && $state['activations'][$product][$device]['status'] === 'active') {
|
||||
return $this->response($request, [
|
||||
'ok' => true,
|
||||
'pro' => true,
|
||||
'product' => $product,
|
||||
'device_id' => $product === 'site' ? null : $device,
|
||||
'user_id' => $userId,
|
||||
'until' => null,
|
||||
], 200, self::TIER_PRO);
|
||||
}
|
||||
|
||||
$activeCount = 0;
|
||||
foreach ($state['activations'][$product] as $row) {
|
||||
if (($row['status'] ?? '') === 'active') {
|
||||
$activeCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if ($activeCount >= $maxDevices) {
|
||||
return $this->response($request, [
|
||||
'ok' => false,
|
||||
'error' => 'device_limit',
|
||||
'message' => 'Device limit reached for this license.',
|
||||
], 403);
|
||||
}
|
||||
|
||||
$state['activations'][$product][$device] = [
|
||||
'status' => 'active',
|
||||
'updated_at' => now()->toIso8601String(),
|
||||
];
|
||||
$this->storeLicenseState($key, $state);
|
||||
|
||||
return $this->response($request, [
|
||||
'ok' => true,
|
||||
'pro' => true,
|
||||
'product' => $product,
|
||||
'device_id' => $product === 'site' ? null : $device,
|
||||
'user_id' => $userId,
|
||||
'until' => null,
|
||||
], 200, self::TIER_PRO);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $check
|
||||
*/
|
||||
private function upsertLicense(string $key, array $check): void
|
||||
{
|
||||
if (!$this->hasDatabaseLicenseTables()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$expiresAt = null;
|
||||
if (!empty($check['expires_at'])) {
|
||||
$ts = strtotime((string) $check['expires_at']);
|
||||
if ($ts !== false) {
|
||||
$expiresAt = date('Y-m-d H:i:s', $ts);
|
||||
}
|
||||
}
|
||||
|
||||
DB::table('licenses')->updateOrInsert(
|
||||
['license_key' => $key],
|
||||
[
|
||||
'source' => (string) ($check['source'] ?? 'unknown'),
|
||||
'plan' => (string) ($check['plan'] ?? 'pro'),
|
||||
'status' => 'active',
|
||||
'product_id' => isset($check['product_id']) ? (string) $check['product_id'] : null,
|
||||
'expires_at' => $expiresAt,
|
||||
'meta_json' => json_encode($check['meta'] ?? []),
|
||||
'last_verified_at' => now(),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
private function hasDatabaseLicenseTables(): bool
|
||||
{
|
||||
return Schema::hasTable('licenses') && Schema::hasTable('license_activations');
|
||||
}
|
||||
|
||||
private function extractKey(Request $request): string
|
||||
{
|
||||
$key = trim((string) $request->bearerToken());
|
||||
if ($key === '') {
|
||||
$key = trim((string) $request->header('X-License-Key', ''));
|
||||
}
|
||||
if ($key === '') {
|
||||
$key = trim((string) $request->input('key', $request->query('key', '')));
|
||||
}
|
||||
|
||||
return $key;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{owner_user_id:string,activations:array<string,array<string,array<string,string>>>}
|
||||
*/
|
||||
private function loadLicenseState(string $key): array
|
||||
{
|
||||
$cacheKey = 'dw_license_state_'.sha1($key);
|
||||
$state = Cache::get($cacheKey);
|
||||
if (!is_array($state)) {
|
||||
return [
|
||||
'owner_user_id' => '',
|
||||
'activations' => [],
|
||||
];
|
||||
}
|
||||
|
||||
return $state + [
|
||||
'owner_user_id' => '',
|
||||
'activations' => [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{owner_user_id:string,activations:array<string,array<string,array<string,string>>>} $state
|
||||
*/
|
||||
private function storeLicenseState(string $key, array $state): void
|
||||
{
|
||||
$cacheKey = 'dw_license_state_'.sha1($key);
|
||||
Cache::put($cacheKey, $state, now()->addDays(30));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $payload
|
||||
*/
|
||||
private function response(Request $request, array $payload, int $status = 200, string $tier = self::TIER_FREE): JsonResponse
|
||||
{
|
||||
$headers = [
|
||||
'X-Dewemoji-Tier' => $tier,
|
||||
'X-Dewemoji-Plan' => $tier,
|
||||
'Vary' => 'Origin',
|
||||
'Access-Control-Allow-Methods' => (string) config('dewemoji.cors.allow_methods', 'GET, POST, OPTIONS'),
|
||||
'Access-Control-Allow-Headers' => (string) config('dewemoji.cors.allow_headers', 'Content-Type, Authorization, X-License-Key, X-Account-Id, X-Dewemoji-Frontend'),
|
||||
];
|
||||
|
||||
$origin = (string) $request->headers->get('Origin', '');
|
||||
$allowedOrigins = config('dewemoji.cors.allowed_origins', []);
|
||||
if (is_array($allowedOrigins) && in_array($origin, $allowedOrigins, true)) {
|
||||
$headers['Access-Control-Allow-Origin'] = $origin;
|
||||
}
|
||||
|
||||
return response()->json($payload, $status, $headers);
|
||||
}
|
||||
}
|
||||
@@ -120,7 +120,7 @@ class SystemController extends Controller
|
||||
'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'),
|
||||
'Access-Control-Allow-Headers' => (string) config('dewemoji.cors.allow_headers', 'Content-Type, Authorization, X-Api-Key, X-Admin-Token, X-Account-Id, X-Dewemoji-Frontend, X-Extension-Token'),
|
||||
];
|
||||
|
||||
$origin = (string) $request->headers->get('Origin', '');
|
||||
@@ -132,4 +132,3 @@ class SystemController extends Controller
|
||||
return response()->json($payload, $status, $headers);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,408 +0,0 @@
|
||||
<?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'] : [];
|
||||
if (!$this->isTruthy($purchase['is_valid'] ?? true)) {
|
||||
continue;
|
||||
}
|
||||
if ($this->isTruthy($purchase['refunded'] ?? false)) {
|
||||
continue;
|
||||
}
|
||||
if ($this->isTruthy($purchase['chargebacked'] ?? false)) {
|
||||
continue;
|
||||
}
|
||||
$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', ''));
|
||||
}
|
||||
$productIds = config('dewemoji.billing.providers.mayar.product_ids', []);
|
||||
if (!is_array($productIds)) {
|
||||
$productIds = [];
|
||||
}
|
||||
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)
|
||||
->withHeaders(['X-API-Key' => $apiKey]);
|
||||
}
|
||||
|
||||
$response = $request->post($url, [
|
||||
'license_key' => $key,
|
||||
'license' => $key,
|
||||
'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'] : [];
|
||||
$status = strtolower((string) ($data['status'] ?? $json['status'] ?? ''));
|
||||
$valid = (($json['success'] ?? false) === true)
|
||||
|| (($json['valid'] ?? false) === true)
|
||||
|| (($data['valid'] ?? false) === true)
|
||||
|| in_array($status, ['active', 'paid', 'completed', 'valid'], true);
|
||||
if (!$valid) {
|
||||
return ['ok' => false, 'err' => 'mayar_invalid'];
|
||||
}
|
||||
|
||||
$productId = $this->firstString([
|
||||
$data['product_id'] ?? null,
|
||||
$data['productId'] ?? null,
|
||||
$data['product_code'] ?? null,
|
||||
$json['product_id'] ?? null,
|
||||
]);
|
||||
if (!empty($productIds) && ($productId === null || !in_array($productId, $productIds, true))) {
|
||||
return ['ok' => false, 'err' => 'mayar_no_match'];
|
||||
}
|
||||
|
||||
$planType = strtolower((string) ($data['type'] ?? 'lifetime'));
|
||||
$expiresAt = $this->firstString([
|
||||
$data['expires_at'] ?? null,
|
||||
$data['expired_at'] ?? null,
|
||||
$data['expiry_date'] ?? null,
|
||||
$data['valid_until'] ?? null,
|
||||
$json['expires_at'] ?? null,
|
||||
]);
|
||||
return [
|
||||
'ok' => true,
|
||||
'plan' => 'pro',
|
||||
'product_id' => $productId,
|
||||
'expires_at' => $expiresAt,
|
||||
'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,
|
||||
];
|
||||
}
|
||||
|
||||
private function isTruthy(mixed $value): bool
|
||||
{
|
||||
if (is_bool($value)) {
|
||||
return $value;
|
||||
}
|
||||
if (is_numeric($value)) {
|
||||
return (int) $value === 1;
|
||||
}
|
||||
if (is_string($value)) {
|
||||
$normalized = strtolower(trim($value));
|
||||
return in_array($normalized, ['1', 'true', 'yes', 'y', 'on'], true);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,mixed> $values
|
||||
*/
|
||||
private function firstString(array $values): ?string
|
||||
{
|
||||
foreach ($values as $value) {
|
||||
if (!is_string($value)) {
|
||||
continue;
|
||||
}
|
||||
$value = trim($value);
|
||||
if ($value !== '') {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -10,40 +10,9 @@ return [
|
||||
'pro_max_limit' => (int) env('DEWEMOJI_PRO_MAX_LIMIT', 50),
|
||||
],
|
||||
|
||||
'free_daily_limit' => (int) env('DEWEMOJI_FREE_DAILY_LIMIT', 30),
|
||||
'rate_limit_enabled' => filter_var(env('DEWEMOJI_RATE_LIMIT_ENABLED', true), FILTER_VALIDATE_BOOL),
|
||||
|
||||
'license' => [
|
||||
'accept_all' => filter_var(env('DEWEMOJI_LICENSE_ACCEPT_ALL', false), FILTER_VALIDATE_BOOL),
|
||||
'pro_keys' => array_values(array_filter(array_map('trim', explode(',', (string) env('DEWEMOJI_PRO_KEYS', ''))))),
|
||||
'max_devices' => (int) env('DEWEMOJI_LICENSE_MAX_DEVICES', 3),
|
||||
],
|
||||
|
||||
'billing' => [
|
||||
// sandbox: accepts any non-empty key, live: requires configured pro keys/provider validation.
|
||||
'mode' => env('DEWEMOJI_BILLING_MODE', 'sandbox'),
|
||||
'verify_cache_ttl' => (int) env('DEWEMOJI_VERIFY_CACHE_TTL', 300),
|
||||
'providers' => [
|
||||
'gumroad' => [
|
||||
'enabled' => filter_var(env('DEWEMOJI_GUMROAD_ENABLED', false), FILTER_VALIDATE_BOOL),
|
||||
'verify_url' => env('DEWEMOJI_GUMROAD_VERIFY_URL', 'https://api.gumroad.com/v2/licenses/verify'),
|
||||
'product_ids' => array_values(array_filter(array_map('trim', explode(',', (string) env('DEWEMOJI_GUMROAD_PRODUCT_IDS', ''))))),
|
||||
'timeout' => (int) env('DEWEMOJI_GUMROAD_TIMEOUT', 8),
|
||||
// Optional stub keys for local testing without external HTTP calls.
|
||||
'test_keys' => array_values(array_filter(array_map('trim', explode(',', (string) env('DEWEMOJI_GUMROAD_TEST_KEYS', ''))))),
|
||||
],
|
||||
'mayar' => [
|
||||
'enabled' => filter_var(env('DEWEMOJI_MAYAR_ENABLED', false), FILTER_VALIDATE_BOOL),
|
||||
'verify_url' => env('DEWEMOJI_MAYAR_VERIFY_URL', ''),
|
||||
'api_base' => env('DEWEMOJI_MAYAR_API_BASE', ''),
|
||||
'endpoint_verify' => env('DEWEMOJI_MAYAR_ENDPOINT_VERIFY', '/v1/license/verify'),
|
||||
'product_ids' => array_values(array_filter(array_map('trim', explode(',', (string) env('DEWEMOJI_MAYAR_PRODUCT_IDS', ''))))),
|
||||
'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', ''))))),
|
||||
],
|
||||
'paypal' => [
|
||||
'enabled' => filter_var(env('DEWEMOJI_PAYPAL_ENABLED', false), FILTER_VALIDATE_BOOL),
|
||||
'timeout' => (int) env('DEWEMOJI_PAYPAL_TIMEOUT', 10),
|
||||
@@ -87,7 +56,7 @@ return [
|
||||
'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-Api-Key, X-Admin-Token, X-Account-Id, X-Dewemoji-Frontend, X-Extension-Token',
|
||||
'allow_headers' => 'Content-Type, Authorization, X-Api-Key, X-Admin-Token, X-Account-Id, X-Dewemoji-Frontend, X-Extension-Token',
|
||||
],
|
||||
|
||||
'admin' => [
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>License key issued</title>
|
||||
</head>
|
||||
<body style="margin:0; padding:0; background:#f5f7fb;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f5f7fb; padding:32px 16px;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="width:600px; max-width:100%; background:#ffffff; border-radius:24px; overflow:hidden; box-shadow:0 20px 50px rgba(15,23,42,0.10); border:1px solid #e6ebf5;">
|
||||
<tr>
|
||||
<td style="padding:26px 28px 10px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td>
|
||||
<table role="presentation" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style="width:40px; height:40px; border-radius:12px; background:#f1f5ff; text-align:center; border:1px solid #e2e8f0;">
|
||||
<img src="/assets/logo/logo-mark-128.png" alt="Dewemoji" width=40 height=40 style="display:block; border-radius:12px;" />
|
||||
</td>
|
||||
<td style="padding-left:12px; font-family:Arial, sans-serif; color:#0f172a; font-size:18px; font-weight:700;">
|
||||
Dewemoji
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
<td align="right" style="font-family:Arial, sans-serif; color:#6b7280; font-size:12px;">
|
||||
License
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td style="padding:12px 28px 0;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f8fafc; border-radius:18px; padding:26px; border:1px solid #e2e8f0;">
|
||||
<tr>
|
||||
<td style="padding-bottom:14px;">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style="width:34px; height:34px; border-radius:10px; background:#e2e8f0; text-align:center;">
|
||||
<span style="display:inline-block; font-size:18px; line-height:34px;">🔑</span>
|
||||
</td>
|
||||
<td style="padding-left:10px; font-family:Arial, sans-serif; color:#0f172a; font-size:22px; font-weight:700;">
|
||||
License key issued
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-family:Arial, sans-serif; color:#475569; font-size:14px; line-height:1.7; padding-bottom:16px;">
|
||||
Your license key is ready. Use it to unlock Dewemoji in the extension and API.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-family:Arial, sans-serif; color:#0f172a; font-size:13px; line-height:1.7; padding-bottom:18px;">
|
||||
License: {{ license_key }}<br />
|
||||
Tier: {{ tier }}<br />
|
||||
Devices: {{ max_devices }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" style="padding-bottom:18px;">
|
||||
<a href="{{ dashboard_url }}" style="display:inline-block; padding:12px 20px; background:#111827; color:#ffffff; text-decoration:none; border-radius:999px; font-family:Arial, sans-serif; font-size:14px; font-weight:700;">
|
||||
Manage License
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-family:Arial, sans-serif; color:#64748b; font-size:12px; line-height:1.6;">
|
||||
Keep your license secure. You can rotate it anytime from your dashboard.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td style="padding:20px 28px 28px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#ffffff; border-radius:16px; padding:16px; border:1px dashed #e2e8f0;">
|
||||
<tr>
|
||||
<td style="font-family:Arial, sans-serif; color:#64748b; font-size:12px; line-height:1.6;">
|
||||
Need help getting started? Visit your API docs from the dashboard.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-family:Arial, sans-serif; color:#94a3b8; font-size:11px; padding-top:8px;">
|
||||
Dewemoji • Emoji discovery and keywords for creators
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,7 +1,7 @@
|
||||
@extends('site.layout')
|
||||
|
||||
@section('title', 'API Docs - Dewemoji')
|
||||
@section('meta_description', 'Dewemoji API docs for emoji search, categories, license verification, activation, and system endpoints.')
|
||||
@section('meta_description', 'Dewemoji API docs for emoji search, categories, account API keys, and system endpoints.')
|
||||
|
||||
@push('head')
|
||||
<style>
|
||||
@@ -122,7 +122,6 @@
|
||||
</div>
|
||||
<ul class="mt-3 list-disc pl-5 text-sm text-gray-300 space-y-1">
|
||||
<li><code>Authorization</code> or <code>X-Api-Key</code> for Personal API keys</li>
|
||||
<li><code>X-License-Key</code> or <code>?key=</code> is only used for <code>/license/*</code> endpoints</li>
|
||||
<li><code>X-Account-Id</code> (optional usage association)</li>
|
||||
</ul>
|
||||
</section>
|
||||
@@ -145,7 +144,7 @@
|
||||
<td class="py-2 pr-6"><strong>Free (public)</strong></td>
|
||||
<td class="py-2 pr-6 text-right">20</td>
|
||||
<td class="py-2 pr-6">None</td>
|
||||
<td class="py-2 pr-6">Hourly public limit (if enabled)</td>
|
||||
<td class="py-2 pr-6">Server-level only</td>
|
||||
<td class="py-2">Public dataset (EN + ID) only.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -167,9 +166,9 @@
|
||||
<li><code>/emoji?slug=<slug></code> — single emoji (query)</li>
|
||||
<li><code>/emoji/<slug></code> — single emoji (pretty URL)</li>
|
||||
<li><code>/categories</code> — category map</li>
|
||||
<li><code>/license/verify</code> — license check</li>
|
||||
<li><code>/license/activate</code> — bind profile</li>
|
||||
<li><code>/license/deactivate</code> — unbind profile</li>
|
||||
<li><code>/user/register</code> — create account</li>
|
||||
<li><code>/user/login</code> — account login</li>
|
||||
<li><code>/user/apikeys</code> — manage API keys (Personal)</li>
|
||||
</ul>
|
||||
|
||||
<div class="mt-5">
|
||||
|
||||
@@ -14,8 +14,8 @@
|
||||
"description": "Personal plan unlocks Dewemoji extension and API access.",
|
||||
"brand": {"@@type": "Brand", "name": "Dewemoji"},
|
||||
"offers": [
|
||||
{"@@type":"Offer","price":"30000","priceCurrency":"IDR","availability":"https://schema.org/InStock","url":"https://dwindown.gumroad.com/l/dewemoji-pro-subscription"},
|
||||
{"@@type":"Offer","price":"300000","priceCurrency":"IDR","availability":"https://schema.org/InStock","url":"https://dwindown.gumroad.com/l/dewemoji-pro-subscription"}
|
||||
{"@@type":"Offer","price":"30000","priceCurrency":"IDR","availability":"https://schema.org/InStock","url":"{{ route('pricing') }}"},
|
||||
{"@@type":"Offer","price":"300000","priceCurrency":"IDR","availability":"https://schema.org/InStock","url":"{{ route('pricing') }}"}
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -23,7 +23,7 @@
|
||||
"name": "Dewemoji Lifetime",
|
||||
"description": "Lifetime access to Dewemoji extension and API.",
|
||||
"brand": {"@@type": "Brand", "name": "Dewemoji"},
|
||||
"offers": {"@@type":"Offer","price":"900000","priceCurrency":"IDR","availability":"https://schema.org/InStock","url":"https://dwindown.gumroad.com/l/dewemoji-pro-lifetime"}
|
||||
"offers": {"@@type":"Offer","price":"900000","priceCurrency":"IDR","availability":"https://schema.org/InStock","url":"{{ route('pricing') }}"}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -120,7 +120,7 @@
|
||||
<div class="max-w-5xl mx-auto">
|
||||
<div class="text-center mb-12">
|
||||
<h1 class="font-display text-4xl md:text-5xl font-bold mb-3">Supercharge your <span class="text-gradient">emoji workflow</span></h1>
|
||||
<p class="text-gray-400">Use Dewemoji for search, extension, and API in one license system.</p>
|
||||
<p class="text-gray-400">Use Dewemoji for search, extension, and API in one account system.</p>
|
||||
</div>
|
||||
|
||||
@php
|
||||
|
||||
@@ -71,14 +71,14 @@
|
||||
<ul class="legal-ul">
|
||||
<li>API usage metadata (endpoint, timing, status)</li>
|
||||
<li>Basic device/browser information</li>
|
||||
<li>License validation and activation metadata</li>
|
||||
<li>Account and subscription metadata needed for access control</li>
|
||||
</ul>
|
||||
|
||||
<h2 class="legal-h2">2. How we use information</h2>
|
||||
<ul class="legal-ul">
|
||||
<li>Provide and improve search quality and API performance</li>
|
||||
<li>Prevent abuse and enforce fair usage limits</li>
|
||||
<li>Support license verification and activation workflows</li>
|
||||
<li>Support account, billing, and API-key workflows</li>
|
||||
</ul>
|
||||
|
||||
<h2 class="legal-h2">3. Cookies and local storage</h2>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
@extends('site.layout')
|
||||
|
||||
@section('title', 'Support - Dewemoji')
|
||||
@section('meta_description', 'Get help with Dewemoji installation, Pro activation, API usage, billing, and common troubleshooting.')
|
||||
@section('meta_description', 'Get help with Dewemoji account setup, API usage, billing, and common troubleshooting.')
|
||||
|
||||
@push('jsonld')
|
||||
<script type="application/ld+json">
|
||||
@@ -9,9 +9,9 @@
|
||||
"@@context": "https://schema.org",
|
||||
"@@type": "FAQPage",
|
||||
"mainEntity": [
|
||||
{"@@type":"Question","name":"How do I activate Pro?","acceptedAnswer":{"@@type":"Answer","text":"Open Dewemoji settings, paste your license key in Pro tab, and activate."}},
|
||||
{"@@type":"Question","name":"How many devices can I use?","acceptedAnswer":{"@@type":"Answer","text":"One license can activate up to 3 Chrome profiles."}},
|
||||
{"@@type":"Question","name":"How do I use my key in API?","acceptedAnswer":{"@@type":"Answer","text":"Send Authorization: Bearer YOUR_LICENSE_KEY header in requests."}}
|
||||
{"@@type":"Question","name":"How do I upgrade to Personal?","acceptedAnswer":{"@@type":"Answer","text":"Open Pricing, choose Personal Monthly or Annual, then complete checkout with PayPal or QRIS."}},
|
||||
{"@@type":"Question","name":"Can free accounts use API keys?","acceptedAnswer":{"@@type":"Answer","text":"No. API key management is available on Personal only."}},
|
||||
{"@@type":"Question","name":"How do I use API in Personal plan?","acceptedAnswer":{"@@type":"Answer","text":"Create an API key in Dashboard and send Authorization: Bearer YOUR_API_KEY in requests."}}
|
||||
]
|
||||
}
|
||||
</script>
|
||||
@@ -62,25 +62,24 @@
|
||||
<div class="flex-1 overflow-y-auto p-6 md:p-10">
|
||||
<div class="max-w-5xl mx-auto grid gap-4 md:grid-cols-2">
|
||||
<section class="glass-card rounded-2xl p-5">
|
||||
<h2 class="font-semibold mb-2">Install & activate</h2>
|
||||
<h2 class="font-semibold mb-2">Install & get started</h2>
|
||||
<ol class="list-decimal pl-5 text-sm text-gray-300 space-y-1">
|
||||
<li>Install Dewemoji extension.</li>
|
||||
<li>Open settings and go to Pro tab.</li>
|
||||
<li>Paste your license key and activate.</li>
|
||||
<li>Sign in with your Dewemoji account.</li>
|
||||
<li>Upgrade in Pricing if you need Personal features.</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section class="glass-card rounded-2xl p-5">
|
||||
<h2 class="font-semibold mb-2">API quick start</h2>
|
||||
<pre class="text-xs overflow-x-auto bg-black/30 rounded-lg p-3"><code>curl -H "Authorization: Bearer YOUR_LICENSE_KEY" \
|
||||
<pre class="text-xs overflow-x-auto bg-black/30 rounded-lg p-3"><code>curl -H "Authorization: Bearer YOUR_API_KEY" \
|
||||
"{{ url('/v1/emojis') }}?q=love&limit=20"</code></pre>
|
||||
</section>
|
||||
|
||||
<section class="glass-card rounded-2xl p-5 md:col-span-2">
|
||||
<h2 class="font-semibold mb-2">Common issues</h2>
|
||||
<ul class="list-disc pl-5 text-sm text-gray-300 space-y-1">
|
||||
<li><strong>License limit reached:</strong> deactivate old profile first.</li>
|
||||
<li><strong>API 401 invalid key:</strong> ensure key is active and valid.</li>
|
||||
<li><strong>API 401 invalid key:</strong> ensure key is active and belongs to a Personal account.</li>
|
||||
<li><strong>Insert not working:</strong> focus target input and retry.</li>
|
||||
</ul>
|
||||
<p class="mt-4 text-sm text-gray-400">Need direct help? Email <a class="text-brand-oceanSoft hover:text-white" href="mailto:hello@dewemoji.com">hello@dewemoji.com</a>.</p>
|
||||
|
||||
@@ -68,12 +68,12 @@
|
||||
<h2 class="legal-h2">1. Acceptable use</h2>
|
||||
<ul class="legal-ul">
|
||||
<li>Use the service lawfully and without abuse.</li>
|
||||
<li>Do not attempt to bypass license controls or request limits.</li>
|
||||
<li>Do not attempt to bypass account controls or request limits.</li>
|
||||
<li>Do not scrape or redistribute restricted content in violation of terms.</li>
|
||||
</ul>
|
||||
|
||||
<h2 class="legal-h2">2. Accounts and licenses</h2>
|
||||
<p class="legal-p">Paid features require a valid license key. Device/profile activation limits apply based on plan rules.</p>
|
||||
<h2 class="legal-h2">2. Accounts and subscriptions</h2>
|
||||
<p class="legal-p">Paid features require an active Personal subscription on your account.</p>
|
||||
|
||||
<h2 class="legal-h2">3. Service availability</h2>
|
||||
<p class="legal-p">We continuously improve stability, but uptime is not guaranteed. Features may change over time.</p>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\Api\V1\EmojiApiController;
|
||||
use App\Http\Controllers\Api\V1\LicenseController;
|
||||
use App\Http\Controllers\Api\V1\SystemController;
|
||||
use App\Http\Controllers\Api\V1\AdminUserController;
|
||||
use App\Http\Controllers\Api\V1\AdminPricingController;
|
||||
@@ -21,7 +20,7 @@ use Illuminate\Support\Facades\Route;
|
||||
Route::options('/v1/{any}', function () {
|
||||
$headers = [
|
||||
'Access-Control-Allow-Methods' => 'GET, POST, PUT, DELETE, OPTIONS',
|
||||
'Access-Control-Allow-Headers' => 'Content-Type, Authorization, X-License-Key, X-Api-Key, X-Admin-Token, X-Account-Id, X-Dewemoji-Frontend, X-Extension-Token',
|
||||
'Access-Control-Allow-Headers' => 'Content-Type, Authorization, X-Api-Key, X-Admin-Token, X-Account-Id, X-Dewemoji-Frontend, X-Extension-Token',
|
||||
'Vary' => 'Origin',
|
||||
];
|
||||
$origin = request()->headers->get('Origin', '');
|
||||
@@ -46,10 +45,6 @@ Route::prefix('v1')->group(function () {
|
||||
Route::post('/extension/verify', [ExtensionController::class, 'verify']);
|
||||
Route::get('/extension/search', [ExtensionController::class, 'search']);
|
||||
|
||||
Route::post('/license/verify', [LicenseController::class, 'verify']);
|
||||
Route::post('/license/activate', [LicenseController::class, 'activate']);
|
||||
Route::post('/license/deactivate', [LicenseController::class, 'deactivate']);
|
||||
|
||||
Route::post('/user/register', [UserController::class, 'register']);
|
||||
Route::post('/user/login', [UserController::class, 'login']);
|
||||
Route::post('/user/logout', [UserController::class, 'logout']);
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\ApiKeyService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ApiV1EndpointsTest extends TestCase
|
||||
@@ -39,135 +40,8 @@ class ApiV1EndpointsTest extends TestCase
|
||||
$byQuery->assertJsonPath('items.0.slug', 'grinning-face');
|
||||
}
|
||||
|
||||
public function test_license_verify_returns_ok_when_accept_all_is_enabled(): void
|
||||
{
|
||||
config()->set('dewemoji.license.accept_all', true);
|
||||
config()->set('dewemoji.billing.mode', 'live');
|
||||
|
||||
$response = $this->postJson('/v1/license/verify', [
|
||||
'key' => 'dummy-key',
|
||||
'account_id' => 'acct_123',
|
||||
'version' => '1.0.0',
|
||||
]);
|
||||
|
||||
$response
|
||||
->assertOk()
|
||||
->assertHeader('X-Dewemoji-Tier', 'pro')
|
||||
->assertJson([
|
||||
'ok' => true,
|
||||
'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_license_verify_uses_gumroad_live_payload_mapping(): 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.verify_url', 'https://api.gumroad.com/v2/licenses/verify');
|
||||
config()->set('dewemoji.billing.providers.gumroad.product_ids', ['prod_123']);
|
||||
config()->set('dewemoji.billing.providers.mayar.enabled', false);
|
||||
|
||||
Http::fake([
|
||||
'https://api.gumroad.com/*' => Http::response([
|
||||
'success' => true,
|
||||
'purchase' => [
|
||||
'product_id' => 'prod_123',
|
||||
'recurrence' => 'monthly',
|
||||
],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$response = $this->postJson('/v1/license/verify', [
|
||||
'key' => 'gum-live-key',
|
||||
]);
|
||||
|
||||
$response
|
||||
->assertOk()
|
||||
->assertJsonPath('ok', true)
|
||||
->assertJsonPath('source', 'gumroad')
|
||||
->assertJsonPath('product_id', 'prod_123');
|
||||
}
|
||||
|
||||
public function test_license_verify_uses_mayar_live_payload_mapping(): 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', true);
|
||||
config()->set('dewemoji.billing.providers.mayar.verify_url', 'https://api.mayar.id/v1/license/verify');
|
||||
config()->set('dewemoji.billing.providers.mayar.api_key', 'secret');
|
||||
|
||||
Http::fake([
|
||||
'https://api.mayar.id/*' => Http::response([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'valid' => true,
|
||||
'product_id' => 'mayar_prod_1',
|
||||
'type' => 'lifetime',
|
||||
'expires_at' => null,
|
||||
],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$response = $this->postJson('/v1/license/verify', [
|
||||
'key' => 'mayar-live-key',
|
||||
]);
|
||||
|
||||
$response
|
||||
->assertOk()
|
||||
->assertJsonPath('ok', true)
|
||||
->assertJsonPath('source', 'mayar')
|
||||
->assertJsonPath('product_id', 'mayar_prod_1');
|
||||
}
|
||||
|
||||
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
|
||||
@@ -186,78 +60,14 @@ class ApiV1EndpointsTest extends TestCase
|
||||
->assertJsonPath('supports_skin_tone', true);
|
||||
}
|
||||
|
||||
public function test_license_activate_and_deactivate_in_sandbox_mode(): void
|
||||
public function test_emojis_endpoint_returns_pro_tier_for_authenticated_personal_user(): 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',
|
||||
$user = User::factory()->create([
|
||||
'tier' => 'personal',
|
||||
]);
|
||||
|
||||
$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'])
|
||||
->actingAs($user)
|
||||
->getJson('/v1/emojis?q=grinning');
|
||||
|
||||
$response
|
||||
@@ -265,14 +75,16 @@ class ApiV1EndpointsTest extends TestCase
|
||||
->assertHeader('X-Dewemoji-Tier', 'pro');
|
||||
}
|
||||
|
||||
public function test_emojis_endpoint_accepts_x_license_key_header(): void
|
||||
public function test_emojis_endpoint_returns_pro_tier_for_personal_api_key(): void
|
||||
{
|
||||
config()->set('dewemoji.billing.mode', 'live');
|
||||
config()->set('dewemoji.license.accept_all', false);
|
||||
config()->set('dewemoji.license.pro_keys', ['header-pro-key']);
|
||||
$user = User::factory()->create([
|
||||
'tier' => 'personal',
|
||||
]);
|
||||
|
||||
$issued = app(ApiKeyService::class)->issueKey($user, 'test');
|
||||
|
||||
$response = $this
|
||||
->withHeaders(['X-License-Key' => 'header-pro-key'])
|
||||
->withHeaders(['Authorization' => 'Bearer '.$issued['plain']])
|
||||
->getJson('/v1/emojis?q=grinning');
|
||||
|
||||
$response
|
||||
|
||||
79
production-env.md
Normal file
79
production-env.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# Dewemoji Production Env (Minimal, Coolify)
|
||||
# Scope: only keys used by current code paths.
|
||||
# Fill all `CHANGE_ME` values.
|
||||
|
||||
APP_NAME=Dewemoji
|
||||
APP_ENV=production
|
||||
APP_KEY=CHANGE_ME
|
||||
APP_DEBUG=false
|
||||
APP_URL=https://dewemoji.backoffice.biz.id
|
||||
|
||||
LOG_CHANNEL=stack
|
||||
LOG_STACK=single
|
||||
LOG_LEVEL=info
|
||||
|
||||
DB_CONNECTION=mysql
|
||||
DB_HOST=CHANGE_ME
|
||||
DB_PORT=3306
|
||||
DB_DATABASE=CHANGE_ME
|
||||
DB_USERNAME=CHANGE_ME
|
||||
DB_PASSWORD=CHANGE_ME
|
||||
|
||||
SESSION_DRIVER=database
|
||||
SESSION_LIFETIME=120
|
||||
SESSION_PATH=/
|
||||
SESSION_DOMAIN=.backoffice.biz.id
|
||||
SESSION_SECURE_COOKIE=true
|
||||
SESSION_SAME_SITE=lax
|
||||
|
||||
CACHE_STORE=database
|
||||
QUEUE_CONNECTION=database
|
||||
|
||||
MAIL_MAILER=mailketing
|
||||
MAIL_FROM_ADDRESS=hello@dewemoji.com
|
||||
MAIL_FROM_NAME="Dwindi from Dewemoji"
|
||||
MAILKETING_API_URL=https://api.mailketing.co.id/api/v1/send
|
||||
MAILKETING_API_TOKEN=CHANGE_ME
|
||||
MAILKETING_TIMEOUT=10
|
||||
|
||||
DEWEMOJI_DEFAULT_LIMIT=20
|
||||
DEWEMOJI_MAX_LIMIT=50
|
||||
DEWEMOJI_FREE_MAX_LIMIT=20
|
||||
DEWEMOJI_PRO_MAX_LIMIT=50
|
||||
DEWEMOJI_USD_RATE=15000
|
||||
|
||||
DEWEMOJI_BILLING_MODE=live
|
||||
DEWEMOJI_ALLOWED_ORIGINS=https://dewemoji.backoffice.biz.id,https://dewemoji.com,https://www.dewemoji.com
|
||||
DEWEMOJI_FRONTEND_HEADER=web-v1
|
||||
|
||||
DEWEMOJI_ADMIN_TOKEN=CHANGE_ME
|
||||
|
||||
DEWEMOJI_EXTENSION_IDS=jnmmlbcdihhjabhfhcljoppjjommnhfe
|
||||
DEWEMOJI_GOOGLE_PROJECT_ID=dewemoji-api
|
||||
DEWEMOJI_GOOGLE_SERVER_KEY=CHANGE_ME
|
||||
|
||||
DEWEMOJI_METRICS_ENABLED=true
|
||||
DEWEMOJI_METRICS_TOKEN=CHANGE_ME
|
||||
DEWEMOJI_METRICS_ALLOW_IPS=127.0.0.1,::1
|
||||
|
||||
DEWEMOJI_PAYPAL_ENABLED=true
|
||||
DEWEMOJI_PAYPAL_TIMEOUT=10
|
||||
DEWEMOJI_PAYPAL_LIVE_API_BASE=https://api-m.paypal.com
|
||||
DEWEMOJI_PAYPAL_LIVE_WEB_BASE=https://www.paypal.com
|
||||
DEWEMOJI_PAYPAL_LIVE_CLIENT_ID=CHANGE_ME
|
||||
DEWEMOJI_PAYPAL_LIVE_CLIENT_SECRET=CHANGE_ME
|
||||
DEWEMOJI_PAYPAL_LIVE_WEBHOOK_ID=CHANGE_ME
|
||||
# Required if plan IDs are not saved in pricing_plans.meta.paypal.live.plan.id
|
||||
DEWEMOJI_PAYPAL_LIVE_PLAN_PERSONAL_MONTHLY=CHANGE_ME
|
||||
DEWEMOJI_PAYPAL_LIVE_PLAN_PERSONAL_ANNUAL=CHANGE_ME
|
||||
|
||||
DEWEMOJI_PAKASIR_ENABLED=true
|
||||
DEWEMOJI_PAKASIR_API_BASE=https://app.pakasir.com
|
||||
DEWEMOJI_PAKASIR_PROJECT=dewemoji
|
||||
DEWEMOJI_PAKASIR_API_KEY=CHANGE_ME
|
||||
DEWEMOJI_PAKASIR_TIMEOUT=10
|
||||
|
||||
# Optional (only if you want config fallback for admin settings page)
|
||||
# DEWEMOJI_PUBLIC_ENFORCE=true
|
||||
# DEWEMOJI_PUBLIC_ORIGINS=https://dewemoji.backoffice.biz.id,https://dewemoji.com,https://www.dewemoji.com
|
||||
# DEWEMOJI_PUBLIC_HOURLY_LIMIT=5000
|
||||
Reference in New Issue
Block a user