polish the api route, response, health, cache, and metrics

This commit is contained in:
dwindown
2025-08-31 20:45:24 +07:00
parent ce001bf9ce
commit 726f5a842f
12 changed files with 627 additions and 97 deletions

View File

@@ -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);

View File

@@ -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 doesnt 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 Mayars 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
View 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);

View 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')),
],
]);

View File

@@ -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
View 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
View 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);
}

View 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,
];
}

View File

@@ -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'
],
]; ];

View File

@@ -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
View File