polish the api route, response, health, cache, and metrics
This commit is contained in:
@@ -14,6 +14,11 @@ $acct = $planInfo['account'];
|
|||||||
$key = $planInfo['key'];
|
$key = $planInfo['key'];
|
||||||
$planName = $isPro ? 'pro' : ($isWl ? 'whitelist' : 'free');
|
$planName = $isPro ? 'pro' : ($isWl ? 'whitelist' : 'free');
|
||||||
|
|
||||||
|
// Minimal Pro signal (omit for whitelisted site)
|
||||||
|
if ($isPro && !$isWl) {
|
||||||
|
header('X-Dewemoji-Tier: pro');
|
||||||
|
}
|
||||||
|
|
||||||
// Caps
|
// Caps
|
||||||
$maxLimit = $isPro ? 50 : 20;
|
$maxLimit = $isPro ? 50 : 20;
|
||||||
$maxPages = $isPro ? 20 : 5;
|
$maxPages = $isPro ? 20 : 5;
|
||||||
@@ -28,101 +33,69 @@ $limit = min(max((int)($_GET['limit'] ?? $maxLimit), 1), $maxLimit);
|
|||||||
|
|
||||||
// Category map (slugs → labels)
|
// Category map (slugs → labels)
|
||||||
$CATEGORY_MAP = [
|
$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'
|
'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'
|
||||||
];
|
];
|
||||||
|
|
||||||
$catLabel = '';
|
$catLabel = '';
|
||||||
if ($catParam !== '' && strtolower($catParam) !== 'all') {
|
if ($catParam !== '' && strtolower($catParam) !== 'all') {
|
||||||
$catLabel = $CATEGORY_MAP[strtolower($catParam)] ?? $catParam;
|
$catLabel = $CATEGORY_MAP[strtolower($catParam)] ?? $catParam;
|
||||||
}
|
}
|
||||||
$subSlug = $subParam !== '' ? dw_slugify($subParam) : '';
|
$subSlug = $subParam !== '' ? dw_slugify($subParam) : '';
|
||||||
|
|
||||||
$q = $qParam; $cat = $catLabel; $sub = $subSlug;
|
$q = $qParam; $cat = $catLabel; $sub = $subSlug;
|
||||||
|
|
||||||
// Usage (Free counted, Pro/WL unlimited)
|
// Usage (Free counted, Pro/WL unlimited)
|
||||||
$limitDaily = $isWl ? null : ($isPro ? null : (int)cfg('free_daily_limit', 50));
|
$limitDaily = $isWl ? null : ($isPro ? null : (int)cfg('free_daily_limit', 30));
|
||||||
|
|
||||||
$bucket = bucket_id($key);
|
$bucket = bucket_id($key);
|
||||||
$ymd = day_key();
|
$ymd = day_key();
|
||||||
$usage = usage_load($bucket, $ymd);
|
$usage = usage_load($bucket, $ymd);
|
||||||
if ($limitDaily !== null) $usage['limit'] = $limitDaily;
|
if ($limitDaily !== null) $usage['limit'] = $limitDaily;
|
||||||
|
|
||||||
$signature = sig_query($q, $cat, $sub);
|
|
||||||
$sigKey = $signature.'|p1'; // only page 1 increments
|
|
||||||
$atCap = false;
|
$atCap = false;
|
||||||
|
|
||||||
if ($limitDaily !== null && $page === 1 && !isset($usage['seen'][$sigKey])) {
|
|
||||||
if ($usage['used'] >= $limitDaily) {
|
|
||||||
$atCap = true;
|
|
||||||
} else {
|
|
||||||
$usage['used'] = (int)$usage['used'] + 1;
|
|
||||||
$usage['seen'][$sigKey] = 1;
|
|
||||||
usage_save($bucket, $ymd, $usage);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($page > $maxPages) {
|
if ($page > $maxPages) {
|
||||||
echo json_encode(['items'=>[],'page'=>$page,'limit'=>$limit,'total'=>0,'plan'=>$planName,'message'=>'Page limit reached for your plan.']);
|
$resp = ['items'=>[],'page'=>$page,'limit'=>$limit,'total'=>0,'message'=>'Page limit reached for your plan.'];
|
||||||
exit;
|
if (!$isWl && !$isPro) { $resp['plan'] = $planName; }
|
||||||
}
|
echo json_encode($resp);
|
||||||
|
exit;
|
||||||
// Soft cap
|
|
||||||
if ($atCap) {
|
|
||||||
header('X-Dewemoji-Plan: '.$planName);
|
|
||||||
if ($limitDaily === null) {
|
|
||||||
header('X-RateLimit-Limit: unlimited'); header('X-RateLimit-Remaining: unlimited');
|
|
||||||
} else {
|
|
||||||
header('X-RateLimit-Limit: '.$usage['limit']); header('X-RateLimit-Remaining: 0');
|
|
||||||
}
|
|
||||||
header('X-RateLimit-Reset: '.strtotime('tomorrow 00:00:00 UTC'));
|
|
||||||
echo json_encode([
|
|
||||||
'items'=>[],'page'=>$page,'limit'=>$limit,'total'=>0,'plan'=>$planName,
|
|
||||||
'usage'=>[
|
|
||||||
'used'=>(int)$usage['used'],
|
|
||||||
'limit'=>$limitDaily,
|
|
||||||
'remaining'=> $limitDaily===null ? null : max(0, $limitDaily - (int)$usage['used']),
|
|
||||||
'window'=>'daily',
|
|
||||||
'window_ends_at'=>gmdate('c', strtotime('tomorrow 00:00:00 UTC')),
|
|
||||||
'count_basis'=>'distinct_query'
|
|
||||||
],
|
|
||||||
'message'=>'Daily free limit reached. Upgrade to Pro for unlimited usage.'
|
|
||||||
]);
|
|
||||||
exit;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load data
|
// Load data
|
||||||
$DATA_PATHS = [
|
$DATA_PATHS = [
|
||||||
__DIR__.'/../data/emojis.json',
|
__DIR__.'/../data/emojis.json',
|
||||||
__DIR__.'/../../data/emojis.json', // fallback if you keep old path
|
__DIR__.'/../../data/emojis.json', // fallback if you keep old path
|
||||||
];
|
];
|
||||||
$json = null;
|
$json = null;
|
||||||
foreach ($DATA_PATHS as $p) { if (is_file($p)) { $json = file_get_contents($p); break; } }
|
foreach ($DATA_PATHS as $p) { if (is_file($p)) { $json = file_get_contents($p); break; } }
|
||||||
if ($json === null) { http_response_code(500); echo json_encode(['error'=>'data_not_found']); exit; }
|
if ($json === null) {
|
||||||
|
json_error(500, 'data_not_found');
|
||||||
|
}
|
||||||
$data = json_decode($json, true) ?: [];
|
$data = json_decode($json, true) ?: [];
|
||||||
$items = $data['emojis'] ?? [];
|
$items = $data['emojis'] ?? [];
|
||||||
|
|
||||||
// Filter
|
// Filter
|
||||||
$items = array_values(array_filter($items, function($e) use ($q,$cat,$sub) {
|
$items = array_values(array_filter($items, function($e) use ($q,$cat,$sub) {
|
||||||
if ($cat !== '') {
|
if ($cat !== '') {
|
||||||
if (dw_slugify($e['category'] ?? '') !== dw_slugify($cat)) return false;
|
if (dw_slugify($e['category'] ?? '') !== dw_slugify($cat)) return false;
|
||||||
}
|
|
||||||
if ($sub !== '') {
|
|
||||||
if (dw_slugify($e['subcategory'] ?? '') !== $sub) return false;
|
|
||||||
}
|
|
||||||
if ($q !== '') {
|
|
||||||
$hay = strtolower(implode(' ', [
|
|
||||||
$e['emoji'] ?? '', $e['name'] ?? '', $e['slug'] ?? '',
|
|
||||||
$e['category'] ?? '', $e['subcategory'] ?? '',
|
|
||||||
implode(' ', $e['keywords_en'] ?? []),
|
|
||||||
implode(' ', $e['keywords_id'] ?? []),
|
|
||||||
implode(' ', $e['aliases'] ?? []),
|
|
||||||
implode(' ', $e['shortcodes'] ?? []),
|
|
||||||
]));
|
|
||||||
foreach (preg_split('/\s+/', strtolower($q)) as $tok) {
|
|
||||||
if ($tok !== '' && strpos($hay, $tok) === false) return false;
|
|
||||||
}
|
}
|
||||||
}
|
if ($sub !== '') {
|
||||||
return true;
|
if (dw_slugify($e['subcategory'] ?? '') !== $sub) return false;
|
||||||
|
}
|
||||||
|
if ($q !== '') {
|
||||||
|
$hay = strtolower(implode(' ', [
|
||||||
|
$e['emoji'] ?? '', $e['name'] ?? '', $e['slug'] ?? '',
|
||||||
|
$e['category'] ?? '', $e['subcategory'] ?? '',
|
||||||
|
implode(' ', $e['keywords_en'] ?? []),
|
||||||
|
implode(' ', $e['keywords_id'] ?? []),
|
||||||
|
implode(' ', $e['aliases'] ?? []),
|
||||||
|
implode(' ', $e['shortcodes'] ?? []),
|
||||||
|
]));
|
||||||
|
foreach (preg_split('/\s+/', strtolower($q)) as $tok) {
|
||||||
|
if ($tok !== '' && strpos($hay, $tok) === false) return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}));
|
}));
|
||||||
|
|
||||||
$total = count($items);
|
$total = count($items);
|
||||||
@@ -131,17 +104,17 @@ $items = array_slice($items, $offset, $limit);
|
|||||||
|
|
||||||
// Shape + tone
|
// Shape + tone
|
||||||
$items = array_map(function($raw) use ($planName, $isPro) {
|
$items = array_map(function($raw) use ($planName, $isPro) {
|
||||||
$e = filterEmoji($raw, $planName, true);
|
$e = filterEmoji($raw, $planName, true);
|
||||||
$e['supports_skin_tone'] = dw_supports_skin_tone($raw) || dw_supports_skin_tone($e);
|
$e['supports_skin_tone'] = dw_supports_skin_tone($raw) || dw_supports_skin_tone($e);
|
||||||
$e['skin_tone_modifiers'] = dw_skin_tone_modifiers();
|
$e['skin_tone_modifiers'] = dw_skin_tone_modifiers();
|
||||||
if ($e['supports_skin_tone'] && !empty($e['emoji'])) {
|
if ($e['supports_skin_tone'] && !empty($e['emoji'])) {
|
||||||
$base = dw_strip_tone($e['emoji']);
|
$base = dw_strip_tone($e['emoji']);
|
||||||
if ($base !== '') {
|
if ($base !== '') {
|
||||||
$e['emoji_base'] = $base;
|
$e['emoji_base'] = $base;
|
||||||
if ($isPro) { $e['variants'] = dw_build_tone_variants($base); }
|
if ($isPro) { $e['variants'] = dw_build_tone_variants($base); }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
return $e;
|
||||||
return $e;
|
|
||||||
}, $items);
|
}, $items);
|
||||||
|
|
||||||
// ETag (exclude dynamic usage)
|
// ETag (exclude dynamic usage)
|
||||||
@@ -150,29 +123,69 @@ header('ETag: '.$etag);
|
|||||||
header('Cache-Control: public, max-age=120');
|
header('Cache-Control: public, max-age=120');
|
||||||
if (($_SERVER['HTTP_IF_NONE_MATCH'] ?? '') === $etag) { http_response_code(304); exit; }
|
if (($_SERVER['HTTP_IF_NONE_MATCH'] ?? '') === $etag) { http_response_code(304); exit; }
|
||||||
|
|
||||||
// Rate-limit headers
|
// Only count usage now if Free, page 1, and not cached
|
||||||
header('X-Dewemoji-Plan: '.$planName);
|
$signature = sig_query($q, $cat, $sub);
|
||||||
if ($limitDaily === null) {
|
$sigKey = $signature . '|p1';
|
||||||
header('X-RateLimit-Limit: unlimited'); header('X-RateLimit-Remaining: unlimited');
|
if ($limitDaily !== null && $page === 1 && ($_SERVER['HTTP_IF_NONE_MATCH'] ?? '') !== $etag) {
|
||||||
} else {
|
if (!isset($usage['seen'][$sigKey])) {
|
||||||
header('X-RateLimit-Limit: '.$usage['limit']);
|
if ($usage['used'] >= $limitDaily) {
|
||||||
header('X-RateLimit-Remaining: '.max(0, $usage['limit'] - (int)$usage['used']));
|
$atCap = true;
|
||||||
|
} else {
|
||||||
|
$usage['used'] = (int)$usage['used'] + 1;
|
||||||
|
$usage['seen'][$sigKey] = 1;
|
||||||
|
usage_save($bucket, $ymd, $usage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we just detected cap on this request, return a capped response now
|
||||||
|
if ($atCap) {
|
||||||
|
// Only Free responses expose plan/usage headers
|
||||||
|
if (!$isWl && !$isPro) {
|
||||||
|
header('X-Dewemoji-Plan: '.$planName);
|
||||||
|
header('X-RateLimit-Limit: '.$usage['limit']);
|
||||||
|
header('X-RateLimit-Remaining: 0');
|
||||||
|
header('X-RateLimit-Reset: '.strtotime('tomorrow 00:00:00 UTC'));
|
||||||
|
}
|
||||||
|
$extra = [];
|
||||||
|
if (!$isWl && !$isPro) {
|
||||||
|
$extra['plan'] = $planName;
|
||||||
|
$extra['usage'] = [
|
||||||
|
'used'=>(int)$usage['used'],
|
||||||
|
'limit'=>$limitDaily,
|
||||||
|
'remaining'=> $limitDaily===null ? null : max(0, $limitDaily - (int)$usage['used']),
|
||||||
|
'window'=>'daily',
|
||||||
|
'window_ends_at'=>gmdate('c', strtotime('tomorrow 00:00:00 UTC')),
|
||||||
|
'count_basis'=>'distinct_query'
|
||||||
|
];
|
||||||
|
}
|
||||||
|
json_error(429, 'Daily free limit reached. Upgrade to Pro for unlimited usage.', $extra);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate-limit headers (only for Free; omit for Whitelisted and Pro)
|
||||||
|
if (!$isWl && !$isPro) {
|
||||||
|
header('X-Dewemoji-Plan: '.$planName);
|
||||||
|
header('X-RateLimit-Limit: '.$usage['limit']);
|
||||||
|
header('X-RateLimit-Remaining: '.max(0, $usage['limit'] - (int)$usage['used']));
|
||||||
|
header('X-RateLimit-Reset: '.strtotime('tomorrow 00:00:00 UTC'));
|
||||||
}
|
}
|
||||||
header('X-RateLimit-Reset: '.strtotime('tomorrow 00:00:00 UTC'));
|
|
||||||
|
|
||||||
// Response
|
// Response
|
||||||
echo json_encode([
|
$resp = [
|
||||||
'items'=>$items,
|
'items'=>$items,
|
||||||
'page'=>$page,
|
'page'=>$page,
|
||||||
'limit'=>$limit,
|
'limit'=>$limit,
|
||||||
'total'=>$total,
|
'total'=>$total,
|
||||||
'plan'=>$isPro ? 'pro' : 'free',
|
];
|
||||||
'usage'=>[
|
if (!$isWl && !$isPro) {
|
||||||
'used'=>(int)$usage['used'],
|
$resp['plan'] = $planName;
|
||||||
'limit'=>$limitDaily,
|
$resp['usage'] = [
|
||||||
'remaining'=> $limitDaily===null ? null : max(0, $limitDaily - (int)$usage['used']),
|
'used'=>(int)$usage['used'],
|
||||||
'window'=>'daily',
|
'limit'=>$limitDaily,
|
||||||
'window_ends_at'=>gmdate('c', strtotime('tomorrow 00:00:00 UTC')),
|
'remaining'=> $limitDaily===null ? null : max(0, $limitDaily - (int)$usage['used']),
|
||||||
'count_basis'=>'distinct_query'
|
'window'=>'daily',
|
||||||
],
|
'window_ends_at'=>gmdate('c', strtotime('tomorrow 00:00:00 UTC')),
|
||||||
], JSON_UNESCAPED_UNICODE);
|
'count_basis'=>'distinct_query'
|
||||||
|
];
|
||||||
|
}
|
||||||
|
echo json_encode($resp, JSON_UNESCAPED_UNICODE);
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
<?php
|
||||||
|
// app/controllers/License.php
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../helpers/cors.php';
|
||||||
|
require_once __DIR__ . '/../helpers/http.php';
|
||||||
|
require_once __DIR__ . '/../models/license_store.php';
|
||||||
|
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
|
||||||
|
$path = $_GET['_action'] ?? 'verify'; // e.g., routed as /license/verify
|
||||||
|
|
||||||
|
if ($method === 'OPTIONS') { cors_preflight(); exit; }
|
||||||
|
|
||||||
|
switch ($path) {
|
||||||
|
case 'verify':
|
||||||
|
handle_verify();
|
||||||
|
break;
|
||||||
|
|
||||||
|
// You likely already have activate/deactivate; leaving them out here.
|
||||||
|
default:
|
||||||
|
json_error(404, 'not_found');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handle_verify() {
|
||||||
|
cors_allow();
|
||||||
|
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||||
|
$key = trim((string)($input['key'] ?? ''));
|
||||||
|
$acct = trim((string)($input['account_id'] ?? ''));
|
||||||
|
|
||||||
|
if ($key === '') { json_error(400, 'missing_key'); }
|
||||||
|
|
||||||
|
$mode = cfg('gateway_mode', 'sandbox');
|
||||||
|
|
||||||
|
// Sandbox: accept anything
|
||||||
|
if ($mode !== 'live') {
|
||||||
|
$norm = [
|
||||||
|
'key' => $key,
|
||||||
|
'source' => preg_match('/^G|^GMR|^GUM/i', $key) ? 'gumroad' : (preg_match('/^M|^MYR/i',$key) ? 'mayar' : 'sandbox'),
|
||||||
|
'plan' => 'pro', // treat as Pro
|
||||||
|
'product_id' => null,
|
||||||
|
'valid' => true,
|
||||||
|
'expires_at' => null,
|
||||||
|
'meta' => ['mode'=>'sandbox']
|
||||||
|
];
|
||||||
|
$row = license_upsert_from_verification($norm);
|
||||||
|
json_ok(['source'=>$norm['source'],'plan'=>$norm['plan'],'license'=>$row]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// LIVE mode: try Gumroad first, then Mayar
|
||||||
|
$gum = verify_gumroad($key);
|
||||||
|
if ($gum['ok']) {
|
||||||
|
$row = license_upsert_from_verification($gum['norm']);
|
||||||
|
json_ok(['source'=>'gumroad','plan'=>$gum['norm']['plan'],'license'=>$row]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$may = verify_mayar($key);
|
||||||
|
if ($may['ok']) {
|
||||||
|
$row = license_upsert_from_verification($may['norm']);
|
||||||
|
json_ok(['source'=>'mayar','plan'=>$may['norm']['plan'],'license'=>$row]);
|
||||||
|
}
|
||||||
|
|
||||||
|
json_error(401, 'invalid_license', ['details'=>['gumroad'=>$gum['err']??null,'mayar'=>$may['err']??null]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Providers ---
|
||||||
|
|
||||||
|
function verify_gumroad($licenseKey) {
|
||||||
|
$cfg = cfg('gumroad', []);
|
||||||
|
$url = $cfg['verify_url'] ?? 'https://api.gumroad.com/v2/licenses/verify';
|
||||||
|
$pids = $cfg['product_ids'] ?? [];
|
||||||
|
|
||||||
|
// Gumroad expects POST form with license_key (+ product_id if you want to restrict)
|
||||||
|
$fields = ['license_key' => $licenseKey];
|
||||||
|
if (!empty($pids)) {
|
||||||
|
// If you have multiple products, you can loop; for simplicity, try them in order
|
||||||
|
foreach ($pids as $pid) {
|
||||||
|
$try = $fields + ['product_id' => $pid];
|
||||||
|
[$code,$body,$err] = http_post_form($url, $try);
|
||||||
|
if ($code >= 200 && $code < 300) {
|
||||||
|
$json = json_decode($body, true);
|
||||||
|
if (isset($json['success']) && $json['success'] === true) {
|
||||||
|
// Normalize
|
||||||
|
$p = $json['purchase'] ?? [];
|
||||||
|
$isRecurring = !empty($p['recurrence']);
|
||||||
|
$plan = $isRecurring ? 'subscription' : 'lifetime';
|
||||||
|
$expires = null; // Gumroad verify doesn’t usually return expiry for subs; optional to compute
|
||||||
|
return ['ok'=>true,'norm'=>[
|
||||||
|
'key' => $licenseKey,
|
||||||
|
'source' => 'gumroad',
|
||||||
|
'plan' => 'pro', // Your product => Pro features; if you want to store type, add meta
|
||||||
|
'product_id' => $pid,
|
||||||
|
'valid' => true,
|
||||||
|
'expires_at' => $expires,
|
||||||
|
'meta' => ['raw'=>$json,'plan_type'=>$plan]
|
||||||
|
]];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ['ok'=>false,'err'=>'gumroad_no_match'];
|
||||||
|
} else {
|
||||||
|
// No product restriction
|
||||||
|
[$code,$body,$err] = http_post_form($url, $fields);
|
||||||
|
if ($code >= 200 && $code < 300) {
|
||||||
|
$json = json_decode($body, true);
|
||||||
|
if (isset($json['success']) && $json['success'] === true) {
|
||||||
|
$p = $json['purchase'] ?? [];
|
||||||
|
$isRecurring = !empty($p['recurrence']);
|
||||||
|
$plan = $isRecurring ? 'subscription' : 'lifetime';
|
||||||
|
return ['ok'=>true,'norm'=>[
|
||||||
|
'key' => $licenseKey,
|
||||||
|
'source' => 'gumroad',
|
||||||
|
'plan' => 'pro',
|
||||||
|
'product_id' => $p['product_id'] ?? null,
|
||||||
|
'valid' => true,
|
||||||
|
'expires_at' => null,
|
||||||
|
'meta' => ['raw'=>$json,'plan_type'=>$plan]
|
||||||
|
]];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ['ok'=>false,'err'=>'gumroad_verify_failed'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function verify_mayar($licenseKey) {
|
||||||
|
$cfg = cfg('mayar', []);
|
||||||
|
$base = rtrim($cfg['api_base'] ?? '', '/');
|
||||||
|
$ep = $cfg['endpoint_verify'] ?? '/v1/license/verify';
|
||||||
|
$url = $base . $ep;
|
||||||
|
|
||||||
|
// Most vendors use JSON body; adjust per your doc.
|
||||||
|
// If Mayar requires Authorization, add it here.
|
||||||
|
$headers = [];
|
||||||
|
if (!empty($cfg['secret_key'])) {
|
||||||
|
$headers[] = 'Authorization: Bearer ' . $cfg['secret_key'];
|
||||||
|
}
|
||||||
|
|
||||||
|
[$code,$body,$err] = http_post_json($url, ['license_key'=>$licenseKey], $headers);
|
||||||
|
if (!($code >= 200 && $code < 300)) {
|
||||||
|
return ['ok'=>false,'err'=>'mayar_http_'.$code];
|
||||||
|
}
|
||||||
|
$json = json_decode($body, true);
|
||||||
|
|
||||||
|
// Normalize based on Mayar’s response shape
|
||||||
|
// Expect something like: { success:true, data:{ valid:true, product_id:'...', type:'lifetime|subscription', expires_at: '...' } }
|
||||||
|
$valid = (bool)($json['success'] ?? false);
|
||||||
|
if (!$valid) return ['ok'=>false,'err'=>'mayar_invalid'];
|
||||||
|
|
||||||
|
$data = $json['data'] ?? [];
|
||||||
|
$planType = strtolower((string)($data['type'] ?? 'lifetime')); // lifetime or subscription
|
||||||
|
$expiresAt = $data['expires_at'] ?? null;
|
||||||
|
|
||||||
|
return ['ok'=>true,'norm'=>[
|
||||||
|
'key' => $licenseKey,
|
||||||
|
'source' => 'mayar',
|
||||||
|
'plan' => 'pro',
|
||||||
|
'product_id' => $data['product_id'] ?? null,
|
||||||
|
'valid' => true,
|
||||||
|
'expires_at' => $expiresAt,
|
||||||
|
'meta' => ['raw'=>$json,'plan_type'=>$planType]
|
||||||
|
]];
|
||||||
|
}
|
||||||
142
app/controllers/Metrics.php
Normal file
142
app/controllers/Metrics.php
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
<?php
|
||||||
|
require_once __DIR__.'/../helpers/json.php';
|
||||||
|
|
||||||
|
// Optional DB health
|
||||||
|
$db_ok = null;
|
||||||
|
try {
|
||||||
|
require_once __DIR__.'/../db.php';
|
||||||
|
$pdo = getPDO();
|
||||||
|
$pdo->query('SELECT 1');
|
||||||
|
$db_ok = true;
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$db_ok = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tail N lines from access log to compute quick metrics
|
||||||
|
$logFile = __DIR__.'/../../storage/logs/access.log';
|
||||||
|
$lookback = 1000; // number of lines to parse
|
||||||
|
|
||||||
|
function tail_lines($fname, $lines = 1000, $buffer = 4096) {
|
||||||
|
if (!is_file($fname)) return [];
|
||||||
|
$f = fopen($fname, 'rb');
|
||||||
|
if ($f === false) return [];
|
||||||
|
fseek($f, 0, SEEK_END);
|
||||||
|
$pos = ftell($f);
|
||||||
|
$data = '';
|
||||||
|
$linecnt = 0;
|
||||||
|
while ($pos > 0 && $linecnt <= $lines) {
|
||||||
|
$step = ($pos - $buffer) >= 0 ? $buffer : $pos;
|
||||||
|
$pos -= $step;
|
||||||
|
fseek($f, $pos);
|
||||||
|
$chunk = fread($f, $step);
|
||||||
|
$data = $chunk . $data;
|
||||||
|
$linecnt = substr_count($data, "\n");
|
||||||
|
}
|
||||||
|
fclose($f);
|
||||||
|
$arr = explode("\n", trim($data));
|
||||||
|
if (count($arr) > $lines) $arr = array_slice($arr, -$lines);
|
||||||
|
return $arr;
|
||||||
|
}
|
||||||
|
|
||||||
|
$total = 0; $s2=0; $s3=0; $s4=0; $s5=0; $durations=[]; $paths=[]; $lastTs=null;
|
||||||
|
|
||||||
|
// Support new logger format (with plan/bucket/ua/ref) and legacy fallback
|
||||||
|
$re_new = '/^\[(?<ts>[^\]]+)\]\s+(?<id>\S+)\s+(?<ip>\S+)\s+(?<path>\S+)\s+plan=(?<plan>\S+)\s+bucket=(?<bucket>\S+)\s+(?<status>\d{3})\s+(?<ms>\d+)ms(?:\s+ua="(?<ua>[^"]*)")?(?:\s+ref="(?<ref>[^"]*)")?$/';
|
||||||
|
$re_legacy = '/^\[(?<ts>[^\]]+)\]\s+(?<id>\S+)\s+(?<ip>\S+)\s+(?<path>\S+)\s+(?<status>\d{3})\s+(?<ms>\d+)ms$/';
|
||||||
|
|
||||||
|
$lines = tail_lines($logFile, $lookback);
|
||||||
|
$log_size = is_file($logFile) ? filesize($logFile) : 0;
|
||||||
|
|
||||||
|
$by_plan = [];
|
||||||
|
$by_prefix = []; // changed to map prefix -> status counts
|
||||||
|
$prefix_depth = isset($_GET['prefix_depth']) ? max(1, (int)$_GET['prefix_depth']) : 2;
|
||||||
|
|
||||||
|
function path_prefix(string $path, int $depth): string {
|
||||||
|
// strip query string
|
||||||
|
$p = explode('?', $path, 2)[0];
|
||||||
|
// normalize leading slashes and split
|
||||||
|
$parts = array_values(array_filter(explode('/', $p), fn($s) => $s !== ''));
|
||||||
|
if (empty($parts)) return '/';
|
||||||
|
$keep = array_slice($parts, 0, $depth);
|
||||||
|
return '/' . implode('/', $keep);
|
||||||
|
}
|
||||||
|
$matched = 0;
|
||||||
|
foreach ($lines as $ln) {
|
||||||
|
$m = [];
|
||||||
|
if (!preg_match($re_new, $ln, $m)) {
|
||||||
|
$m = [];
|
||||||
|
if (!preg_match($re_legacy, $ln, $m)) continue; // skip non-matching line
|
||||||
|
}
|
||||||
|
$matched++;
|
||||||
|
$total++;
|
||||||
|
$st = (int)($m['status'] ?? 0);
|
||||||
|
if ($st >=200 && $st <300) $s2++;
|
||||||
|
else if($st >=300 && $st <400) $s3++;
|
||||||
|
else if($st >=400 && $st <500) $s4++;
|
||||||
|
else if($st >=500) $s5++;
|
||||||
|
|
||||||
|
$ms = (int)($m['ms'] ?? 0);
|
||||||
|
if ($ms > 0) $durations[] = $ms;
|
||||||
|
|
||||||
|
$p = $m['path'] ?? '-';
|
||||||
|
$paths[$p] = ($paths[$p] ?? 0) + 1;
|
||||||
|
$pref = path_prefix($p, $prefix_depth);
|
||||||
|
if (!isset($by_prefix[$pref])) $by_prefix[$pref] = ['total'=>0,'2xx'=>0,'3xx'=>0,'4xx'=>0,'5xx'=>0];
|
||||||
|
$by_prefix[$pref]['total']++;
|
||||||
|
if ($st >=200 && $st<300) $by_prefix[$pref]['2xx']++;
|
||||||
|
else if($st >=300 && $st<400) $by_prefix[$pref]['3xx']++;
|
||||||
|
else if($st >=400 && $st<500) $by_prefix[$pref]['4xx']++;
|
||||||
|
else if($st >=500) $by_prefix[$pref]['5xx']++;
|
||||||
|
|
||||||
|
$lastTs = $m['ts'] ?? $lastTs;
|
||||||
|
|
||||||
|
$plan = $m['plan'] ?? 'unknown';
|
||||||
|
$by_plan[$plan] = ($by_plan[$plan] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
sort($durations);
|
||||||
|
$avg = count($durations) ? array_sum($durations)/count($durations) : 0;
|
||||||
|
function pct($arr, $p){ if (!$arr) return 0; $k = (int)ceil($p/100*count($arr))-1; $k = max(0,min($k,count($arr)-1)); return $arr[$k]; }
|
||||||
|
$p50 = pct($durations,50); $p95 = pct($durations,95); $p99 = pct($durations,99);
|
||||||
|
arsort($paths);
|
||||||
|
$top_paths = [];
|
||||||
|
foreach (array_slice($paths, 0, 5, true) as $p=>$c) { $top_paths[] = ['path'=>$p,'count'=>$c]; }
|
||||||
|
|
||||||
|
$loadavg_raw = function_exists('sys_getloadavg') ? sys_getloadavg() : null;
|
||||||
|
$loadavg = null;
|
||||||
|
if (is_array($loadavg_raw)) {
|
||||||
|
$loadavg = array_map(function($v){
|
||||||
|
// Output as strings to avoid jq numeric parsing issues on some shells
|
||||||
|
return number_format((float)$v, 2, '.', ''); // e.g., "6.03"
|
||||||
|
}, $loadavg_raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
$resp = [
|
||||||
|
'ok' => true,
|
||||||
|
'time' => gmdate('c'),
|
||||||
|
'php_version' => PHP_VERSION,
|
||||||
|
'memory_used_bytes' => memory_get_usage(true),
|
||||||
|
'db' => $db_ok ? 'ok' : 'down',
|
||||||
|
'log' => [
|
||||||
|
'file_exists' => is_file($logFile),
|
||||||
|
'size_bytes' => $log_size,
|
||||||
|
'lookback_lines' => $lookback,
|
||||||
|
'last_request_at' => $lastTs,
|
||||||
|
],
|
||||||
|
'requests' => [
|
||||||
|
'total' => $total,
|
||||||
|
'parsed_lines' => $matched,
|
||||||
|
'by_status' => [ '2xx'=>$s2, '3xx'=>$s3, '4xx'=>$s4, '5xx'=>$s5 ],
|
||||||
|
'by_plan' => $by_plan,
|
||||||
|
'by_path_prefix' => $by_prefix,
|
||||||
|
'latency_ms' => [ 'avg'=>round($avg,2), 'p50'=>$p50, 'p95'=>$p95, 'p99'=>$p99 ],
|
||||||
|
'top_paths' => $top_paths,
|
||||||
|
],
|
||||||
|
'system' => [
|
||||||
|
'loadavg' => $loadavg,
|
||||||
|
'uptime' => @trim(shell_exec('uptime')),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
// --- safe JSON emit (prevent extra bytes after JSON) ---
|
||||||
|
json_ok($resp);
|
||||||
33
app/controllers/MetricsLite.php
Normal file
33
app/controllers/MetricsLite.php
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
require_once __DIR__.'/../helpers/json.php';
|
||||||
|
|
||||||
|
// DB quick check
|
||||||
|
$db_ok = null;
|
||||||
|
try {
|
||||||
|
require_once __DIR__.'/../db.php';
|
||||||
|
$pdo = getPDO();
|
||||||
|
$pdo->query('SELECT 1');
|
||||||
|
$db_ok = true;
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$db_ok = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rounded loadavg as STRINGS to avoid jq float parsing edge cases
|
||||||
|
$loadavg_raw = function_exists('sys_getloadavg') ? sys_getloadavg() : null;
|
||||||
|
$loadavg = null;
|
||||||
|
if (is_array($loadavg_raw)) {
|
||||||
|
$loadavg = array_map(function($v){
|
||||||
|
return number_format((float)$v, 2, '.', ''); // string "6.03"
|
||||||
|
}, $loadavg_raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
json_ok([
|
||||||
|
'time' => gmdate('c'),
|
||||||
|
'php_version' => PHP_VERSION,
|
||||||
|
'memory_used_bytes' => memory_get_usage(true),
|
||||||
|
'db' => $db_ok ? 'ok' : 'down',
|
||||||
|
'system' => [
|
||||||
|
'loadavg' => $loadavg,
|
||||||
|
'uptime' => @trim(shell_exec('uptime')),
|
||||||
|
],
|
||||||
|
]);
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
// app/helpers/cors.php
|
||||||
|
function cors_allow() {
|
||||||
|
$allowed = cfg('allowed_origins', []);
|
||||||
|
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
|
||||||
|
if ($origin && in_array($origin, $allowed, true)) {
|
||||||
|
header("Access-Control-Allow-Origin: {$origin}");
|
||||||
|
header("Vary: Origin");
|
||||||
|
}
|
||||||
|
header("Access-Control-Allow-Headers: Content-Type, X-Account-Id, X-License-Key, X-Dewemoji-Frontend, X-Dewemoji-Plan");
|
||||||
|
header("Access-Control-Allow-Methods: GET, POST, OPTIONS");
|
||||||
|
}
|
||||||
|
function cors_preflight() {
|
||||||
|
cors_allow();
|
||||||
|
http_response_code(204);
|
||||||
|
}
|
||||||
|
cors_allow();
|
||||||
34
app/helpers/http.php
Normal file
34
app/helpers/http.php
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
// app/helpers/http.php
|
||||||
|
function http_post_form($url, array $fields, array $headers = [], $timeout = 10) {
|
||||||
|
$ch = curl_init($url);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_POSTFIELDS => http_build_query($fields),
|
||||||
|
CURLOPT_HTTPHEADER => $headers,
|
||||||
|
CURLOPT_TIMEOUT => $timeout,
|
||||||
|
]);
|
||||||
|
$body = curl_exec($ch);
|
||||||
|
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
$err = curl_error($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
return [$code, $body, $err];
|
||||||
|
}
|
||||||
|
|
||||||
|
function http_post_json($url, array $json, array $headers = [], $timeout = 10) {
|
||||||
|
$hdrs = array_merge(['Content-Type: application/json'], $headers);
|
||||||
|
$ch = curl_init($url);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_POSTFIELDS => json_encode($json),
|
||||||
|
CURLOPT_HTTPHEADER => $hdrs,
|
||||||
|
CURLOPT_TIMEOUT => $timeout,
|
||||||
|
]);
|
||||||
|
$body = curl_exec($ch);
|
||||||
|
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
$err = curl_error($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
return [$code, $body, $err];
|
||||||
|
}
|
||||||
28
app/helpers/logger.php
Normal file
28
app/helpers/logger.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
// Simple file logger
|
||||||
|
function log_start() {
|
||||||
|
$id = bin2hex(random_bytes(4));
|
||||||
|
$GLOBALS['req_start'] = microtime(true);
|
||||||
|
header("X-Request-Id: $id");
|
||||||
|
return $id;
|
||||||
|
}
|
||||||
|
function log_end($id, $status=200) {
|
||||||
|
$ms = round((microtime(true)-$GLOBALS['req_start'])*1000);
|
||||||
|
$ip = $_SERVER['REMOTE_ADDR'] ?? '-';
|
||||||
|
$path = $_SERVER['REQUEST_URI'] ?? '-';
|
||||||
|
$plan = $_SERVER['HTTP_X_DEWEMOJI_PLAN'] ?? ($GLOBALS['bucket_plan'] ?? '-');
|
||||||
|
$bucket = $GLOBALS['bucket_id'] ?? '-';
|
||||||
|
$ua = $_SERVER['HTTP_USER_AGENT'] ?? '-';
|
||||||
|
$ref = $_SERVER['HTTP_REFERER'] ?? '-';
|
||||||
|
$errorMsg = '';
|
||||||
|
if ($status >= 400) {
|
||||||
|
$errorMsg = $GLOBALS['json_error_msg'] ?? '';
|
||||||
|
}
|
||||||
|
$line = sprintf("[%s] %s %s %s plan=%s bucket=%s %d %dms ua=\"%s\" ref=\"%s\"%s\n",
|
||||||
|
gmdate('c'), $id, $ip, $path, $plan, $bucket, $status, $ms, $ua, $ref,
|
||||||
|
$errorMsg ? " error=\"".addslashes($errorMsg)."\"" : ''
|
||||||
|
);
|
||||||
|
$logFile = __DIR__.'/../../storage/logs/access.log';
|
||||||
|
@mkdir(dirname($logFile), 0775, true);
|
||||||
|
error_log($line, 3, $logFile);
|
||||||
|
}
|
||||||
40
app/models/license_store.php
Normal file
40
app/models/license_store.php
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
// app/models/license_store.php
|
||||||
|
require_once __DIR__ . '/../db.php';
|
||||||
|
|
||||||
|
function license_upsert_from_verification(array $norm) {
|
||||||
|
// $norm: [
|
||||||
|
// 'key','source','plan','product_id','valid'=>bool,'expires_at'=>null|iso,'meta'=>array
|
||||||
|
// ]
|
||||||
|
$pdo = getPDO();
|
||||||
|
|
||||||
|
// 1) upsert into licenses
|
||||||
|
$stmt = $pdo->prepare("
|
||||||
|
INSERT INTO licenses
|
||||||
|
(license_key, source, plan, product_id, status, expires_at, meta_json, last_verified_at, created_at, updated_at)
|
||||||
|
VALUES
|
||||||
|
(:k, :src, :plan, :pid, :status, :exp, :meta, NOW(), NOW(), NOW())
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
source=:src, plan=:plan, product_id=:pid, status=:status,
|
||||||
|
expires_at=:exp, meta_json=:meta, last_verified_at=NOW(), updated_at=NOW()
|
||||||
|
");
|
||||||
|
$stmt->execute([
|
||||||
|
':k' => $norm['key'],
|
||||||
|
':src' => $norm['source'],
|
||||||
|
':plan' => $norm['plan'],
|
||||||
|
':pid' => $norm['product_id'] ?? null,
|
||||||
|
':status'=> $norm['valid'] ? 'active' : 'invalid',
|
||||||
|
':exp' => $norm['expires_at'] ?? null,
|
||||||
|
':meta' => json_encode($norm['meta'] ?? [], JSON_UNESCAPED_UNICODE),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 2) Return row snapshot (minimal)
|
||||||
|
return [
|
||||||
|
'key' => $norm['key'],
|
||||||
|
'source' => $norm['source'],
|
||||||
|
'plan' => $norm['plan'],
|
||||||
|
'status' => $norm['valid'] ? 'active' : 'invalid',
|
||||||
|
'product_id' => $norm['product_id'] ?? null,
|
||||||
|
'expires_at' => $norm['expires_at'] ?? null,
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -21,5 +21,24 @@ return [
|
|||||||
'frontend_header' => 'web-v1', // your SPA sets: X-Dewemoji-Frontend: web-v1
|
'frontend_header' => 'web-v1', // your SPA sets: X-Dewemoji-Frontend: web-v1
|
||||||
|
|
||||||
// Free daily limit for API (Pro/Whitelist are unlimited)
|
// Free daily limit for API (Pro/Whitelist are unlimited)
|
||||||
'free_daily_limit' => 50,
|
'free_daily_limit' => 30,
|
||||||
|
|
||||||
|
'gateway_mode' => 'sandbox', // 'sandbox' or 'live'
|
||||||
|
|
||||||
|
// Gumroad
|
||||||
|
'gumroad' => [
|
||||||
|
// product_ids allowed (array); if empty => accept any product (useful for sandbox)
|
||||||
|
'product_ids' => ['YOUR_GUMROAD_PRODUCT_ID_SUB', 'YOUR_GUMROAD_PRODUCT_ID_LIFETIME'],
|
||||||
|
'verify_url' => 'https://api.gumroad.com/v2/licenses/verify',
|
||||||
|
],
|
||||||
|
|
||||||
|
// Mayar
|
||||||
|
'mayar' => [
|
||||||
|
'api_base' => 'https://api.mayar.id', // adjust if your doc shows a different base
|
||||||
|
'secret_key' => 'sk_test_xxx', // if required for Mayar verify
|
||||||
|
// Endpoints — keep configurable; adjust to exact spec later
|
||||||
|
'endpoint_verify' => '/v1/license/verify', // software license code verify
|
||||||
|
'endpoint_activate' => '/v1/license/activate', // for subscription type only
|
||||||
|
'endpoint_deactivate'=> '/v1/license/deactivate'
|
||||||
|
],
|
||||||
];
|
];
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
// public/index.php — tiny router
|
||||||
|
require __DIR__.'/../app/bootstrap.php';
|
||||||
|
require __DIR__.'/../app/helpers/cors.php';
|
||||||
|
require __DIR__.'/../app/helpers/logger.php';
|
||||||
|
require __DIR__.'/../app/helpers/json.php';
|
||||||
|
$reqId = log_start();
|
||||||
|
|
||||||
|
// Log at shutdown with final status code
|
||||||
|
register_shutdown_function(function() use ($reqId) {
|
||||||
|
$status = http_response_code();
|
||||||
|
if (function_exists('log_end')) log_end($reqId, $status ?: 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) ?: '/';
|
||||||
|
|
||||||
|
// CORS preflight
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { cors_preflight(); exit; }
|
||||||
|
|
||||||
|
// Routes (v1)
|
||||||
|
if ($path === '/v1/emojis') { require __DIR__.'/../app/controllers/Emojis.php'; exit; }
|
||||||
|
if (preg_match('#^/v1/emoji/([^/]+)$#', $path, $m)) {
|
||||||
|
$_GET['slug'] = urldecode($m[1] ?? '');
|
||||||
|
require __DIR__.'/../app/controllers/Emojis.php';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
if ($path === '/v1/license/activate') { require __DIR__.'/../app/controllers/License.php'; exit; }
|
||||||
|
if ($path === '/v1/license/verify') { require __DIR__.'/../app/controllers/License.php'; exit; }
|
||||||
|
if ($path === '/v1/license/deactivate') { require __DIR__.'/../app/controllers/License.php'; exit; }
|
||||||
|
|
||||||
|
// Health
|
||||||
|
if ($path === '/v1/health') {
|
||||||
|
cors_allow();
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
header('Vary: Origin,User-Agent');
|
||||||
|
echo json_encode(['ok'=>true,'time'=>gmdate('c')]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($path === '/v1/metrics') { require __DIR__.'/../app/controllers/Metrics.php'; exit; }
|
||||||
|
if ($path === '/v1/metrics-lite') { require __DIR__.'/../app/controllers/MetricsLite.php'; exit; }
|
||||||
|
|
||||||
|
json_error(404, 'not_found', ['path' => $path]);
|
||||||
0
storage/logs/access.log
Normal file
0
storage/logs/access.log
Normal file
Reference in New Issue
Block a user