polish the api route, response, health, cache, and metrics
This commit is contained in:
@@ -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;
|
||||
@@ -40,53 +45,19 @@ $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.'
|
||||
]);
|
||||
$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;
|
||||
}
|
||||
|
||||
@@ -97,7 +68,9 @@ $DATA_PATHS = [
|
||||
];
|
||||
$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'] ?? [];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
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'=>[
|
||||
// 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_UNESCAPED_UNICODE);
|
||||
];
|
||||
}
|
||||
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'));
|
||||
}
|
||||
|
||||
// Response
|
||||
$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);
|
||||
@@ -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
|
||||
|
||||
// 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