From 726f5a842f549bff3c0329e70e116e9c3834a7d9 Mon Sep 17 00:00:00 2001 From: dwindown Date: Sun, 31 Aug 2025 20:45:24 +0700 Subject: [PATCH] polish the api route, response, health, cache, and metrics --- app/controllers/Emojis.php | 205 ++++++++++++----------- app/controllers/License.php | 161 ++++++++++++++++++ app/controllers/Metrics.php | 142 ++++++++++++++++ app/controllers/MetricsLite.php | 33 ++++ app/helpers/cors.php | 17 ++ app/helpers/http.php | 34 ++++ public/error_log => app/helpers/json.php | 0 app/helpers/logger.php | 28 ++++ app/models/license_store.php | 40 +++++ config/env.php | 21 ++- public/index.php | 43 +++++ storage/logs/access.log | 0 12 files changed, 627 insertions(+), 97 deletions(-) create mode 100644 app/controllers/Metrics.php create mode 100644 app/controllers/MetricsLite.php create mode 100644 app/helpers/http.php rename public/error_log => app/helpers/json.php (100%) create mode 100644 app/helpers/logger.php create mode 100644 app/models/license_store.php create mode 100644 storage/logs/access.log diff --git a/app/controllers/Emojis.php b/app/controllers/Emojis.php index 1091469..45d9747 100644 --- a/app/controllers/Emojis.php +++ b/app/controllers/Emojis.php @@ -14,6 +14,11 @@ $acct = $planInfo['account']; $key = $planInfo['key']; $planName = $isPro ? 'pro' : ($isWl ? 'whitelist' : 'free'); +// Minimal Pro signal (omit for whitelisted site) +if ($isPro && !$isWl) { + header('X-Dewemoji-Tier: pro'); +} + // Caps $maxLimit = $isPro ? 50 : 20; $maxPages = $isPro ? 20 : 5; @@ -28,101 +33,69 @@ $limit = min(max((int)($_GET['limit'] ?? $maxLimit), 1), $maxLimit); // Category map (slugs → labels) $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 = ''; if ($catParam !== '' && strtolower($catParam) !== 'all') { - $catLabel = $CATEGORY_MAP[strtolower($catParam)] ?? $catParam; + $catLabel = $CATEGORY_MAP[strtolower($catParam)] ?? $catParam; } $subSlug = $subParam !== '' ? dw_slugify($subParam) : ''; $q = $qParam; $cat = $catLabel; $sub = $subSlug; // 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); $ymd = day_key(); $usage = usage_load($bucket, $ymd); if ($limitDaily !== null) $usage['limit'] = $limitDaily; -$signature = sig_query($q, $cat, $sub); -$sigKey = $signature.'|p1'; // only page 1 increments $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) { - echo json_encode(['items'=>[],'page'=>$page,'limit'=>$limit,'total'=>0,'plan'=>$planName,'message'=>'Page limit reached for your plan.']); - 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; + $resp = ['items'=>[],'page'=>$page,'limit'=>$limit,'total'=>0,'message'=>'Page limit reached for your plan.']; + if (!$isWl && !$isPro) { $resp['plan'] = $planName; } + echo json_encode($resp); + exit; } // Load data $DATA_PATHS = [ - __DIR__.'/../data/emojis.json', - __DIR__.'/../../data/emojis.json', // fallback if you keep old path + __DIR__.'/../data/emojis.json', + __DIR__.'/../../data/emojis.json', // fallback if you keep old path ]; $json = null; 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) ?: []; $items = $data['emojis'] ?? []; // Filter $items = array_values(array_filter($items, function($e) use ($q,$cat,$sub) { - if ($cat !== '') { - 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 ($cat !== '') { + if (dw_slugify($e['category'] ?? '') !== dw_slugify($cat)) return false; } - } - return true; + 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; + } + } + return true; })); $total = count($items); @@ -131,17 +104,17 @@ $items = array_slice($items, $offset, $limit); // Shape + tone $items = array_map(function($raw) use ($planName, $isPro) { - $e = filterEmoji($raw, $planName, true); - $e['supports_skin_tone'] = dw_supports_skin_tone($raw) || dw_supports_skin_tone($e); - $e['skin_tone_modifiers'] = dw_skin_tone_modifiers(); - if ($e['supports_skin_tone'] && !empty($e['emoji'])) { - $base = dw_strip_tone($e['emoji']); - if ($base !== '') { - $e['emoji_base'] = $base; - if ($isPro) { $e['variants'] = dw_build_tone_variants($base); } + $e = filterEmoji($raw, $planName, true); + $e['supports_skin_tone'] = dw_supports_skin_tone($raw) || dw_supports_skin_tone($e); + $e['skin_tone_modifiers'] = dw_skin_tone_modifiers(); + if ($e['supports_skin_tone'] && !empty($e['emoji'])) { + $base = dw_strip_tone($e['emoji']); + if ($base !== '') { + $e['emoji_base'] = $base; + if ($isPro) { $e['variants'] = dw_build_tone_variants($base); } + } } - } - return $e; + return $e; }, $items); // ETag (exclude dynamic usage) @@ -150,29 +123,69 @@ header('ETag: '.$etag); header('Cache-Control: public, max-age=120'); if (($_SERVER['HTTP_IF_NONE_MATCH'] ?? '') === $etag) { http_response_code(304); exit; } -// Rate-limit headers -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: '.max(0, $usage['limit'] - (int)$usage['used'])); +// Only count usage now if Free, page 1, and not cached +$signature = sig_query($q, $cat, $sub); +$sigKey = $signature . '|p1'; +if ($limitDaily !== null && $page === 1 && ($_SERVER['HTTP_IF_NONE_MATCH'] ?? '') !== $etag) { + if (!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 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 -echo json_encode([ - 'items'=>$items, - 'page'=>$page, - 'limit'=>$limit, - 'total'=>$total, - 'plan'=>$isPro ? 'pro' : 'free', - '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_UNESCAPED_UNICODE); \ No newline at end of file +$resp = [ + 'items'=>$items, + 'page'=>$page, + 'limit'=>$limit, + 'total'=>$total, +]; +if (!$isWl && !$isPro) { + $resp['plan'] = $planName; + $resp['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' + ]; +} +echo json_encode($resp, JSON_UNESCAPED_UNICODE); \ No newline at end of file diff --git a/app/controllers/License.php b/app/controllers/License.php index e69de29..75d1c6f 100644 --- a/app/controllers/License.php +++ b/app/controllers/License.php @@ -0,0 +1,161 @@ + $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] + ]]; +} \ No newline at end of file diff --git a/app/controllers/Metrics.php b/app/controllers/Metrics.php new file mode 100644 index 0000000..dc5894d --- /dev/null +++ b/app/controllers/Metrics.php @@ -0,0 +1,142 @@ +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 = '/^\[(?[^\]]+)\]\s+(?\S+)\s+(?\S+)\s+(?\S+)\s+plan=(?\S+)\s+bucket=(?\S+)\s+(?\d{3})\s+(?\d+)ms(?:\s+ua="(?[^"]*)")?(?:\s+ref="(?[^"]*)")?$/'; +$re_legacy = '/^\[(?[^\]]+)\]\s+(?\S+)\s+(?\S+)\s+(?\S+)\s+(?\d{3})\s+(?\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); \ No newline at end of file diff --git a/app/controllers/MetricsLite.php b/app/controllers/MetricsLite.php new file mode 100644 index 0000000..5cc162d --- /dev/null +++ b/app/controllers/MetricsLite.php @@ -0,0 +1,33 @@ +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')), + ], +]); \ No newline at end of file diff --git a/app/helpers/cors.php b/app/helpers/cors.php index e69de29..d145c80 100644 --- a/app/helpers/cors.php +++ b/app/helpers/cors.php @@ -0,0 +1,17 @@ + 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]; +} \ No newline at end of file diff --git a/public/error_log b/app/helpers/json.php similarity index 100% rename from public/error_log rename to app/helpers/json.php diff --git a/app/helpers/logger.php b/app/helpers/logger.php new file mode 100644 index 0000000..ac8238c --- /dev/null +++ b/app/helpers/logger.php @@ -0,0 +1,28 @@ += 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); +} \ No newline at end of file diff --git a/app/models/license_store.php b/app/models/license_store.php new file mode 100644 index 0000000..c16abfe --- /dev/null +++ b/app/models/license_store.php @@ -0,0 +1,40 @@ +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, + ]; +} \ No newline at end of file diff --git a/config/env.php b/config/env.php index 21ac796..533f765 100644 --- a/config/env.php +++ b/config/env.php @@ -21,5 +21,24 @@ return [ 'frontend_header' => 'web-v1', // your SPA sets: X-Dewemoji-Frontend: web-v1 // 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' + ], ]; \ No newline at end of file diff --git a/public/index.php b/public/index.php index e69de29..33034c6 100644 --- a/public/index.php +++ b/public/index.php @@ -0,0 +1,43 @@ +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]); \ No newline at end of file diff --git a/storage/logs/access.log b/storage/logs/access.log new file mode 100644 index 0000000..e69de29