Initial commit: Uruk Prize Platform architecture, backend core, mobile app, gateway caller & deployment pipeline
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core;
|
||||
|
||||
use PDO;
|
||||
use PDOException;
|
||||
use RuntimeException;
|
||||
|
||||
class Database
|
||||
{
|
||||
private static ?PDO $instance = null;
|
||||
|
||||
private function __construct() {}
|
||||
private function __clone() {}
|
||||
|
||||
public static function getConnection(): PDO
|
||||
{
|
||||
if (self::$instance === null) {
|
||||
$config = require __DIR__ . '/../config/database.php';
|
||||
$dsn = sprintf(
|
||||
'mysql:host=%s;port=%d;dbname=%s;charset=%s',
|
||||
$config['host'],
|
||||
$config['port'],
|
||||
$config['database'],
|
||||
$config['charset']
|
||||
);
|
||||
|
||||
try {
|
||||
self::$instance = new PDO(
|
||||
$dsn,
|
||||
$config['username'],
|
||||
$config['password'],
|
||||
$config['options']
|
||||
);
|
||||
} catch (PDOException $e) {
|
||||
throw new RuntimeException('Database Connection Failed: ' . $e->getMessage(), (int)$e->getCode());
|
||||
}
|
||||
}
|
||||
|
||||
return self::$instance;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core;
|
||||
|
||||
abstract class Middleware
|
||||
{
|
||||
abstract public function handle(Request $request, callable $next): void;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core;
|
||||
|
||||
use Redis;
|
||||
use Throwable;
|
||||
|
||||
class RedisClient
|
||||
{
|
||||
private static ?Redis $instance = null;
|
||||
private static bool $connectionFailed = false;
|
||||
|
||||
private function __construct() {}
|
||||
private function __clone() {}
|
||||
|
||||
public static function getInstance(): ?Redis
|
||||
{
|
||||
if (self::$connectionFailed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (self::$instance === null) {
|
||||
if (!class_exists('Redis')) {
|
||||
self::$connectionFailed = true;
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$config = require __DIR__ . '/../config/redis.php';
|
||||
$redis = new Redis();
|
||||
$connected = $redis->connect($config['host'], $config['port'], (float)$config['timeout']);
|
||||
|
||||
if ($connected) {
|
||||
if (!empty($config['password'])) {
|
||||
$redis->auth($config['password']);
|
||||
}
|
||||
if (!empty($config['database'])) {
|
||||
$redis->select((int)$config['database']);
|
||||
}
|
||||
$redis->setOption(Redis::OPT_PREFIX, $config['prefix']);
|
||||
self::$instance = $redis;
|
||||
} else {
|
||||
self::$connectionFailed = true;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
self::$connectionFailed = true;
|
||||
error_log('[Redis Error] ' . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
public static function get(string $key): ?string
|
||||
{
|
||||
$redis = self::getInstance();
|
||||
if (!$redis) return null;
|
||||
$val = $redis->get($key);
|
||||
return $val !== false ? (string)$val : null;
|
||||
}
|
||||
|
||||
public static function set(string $key, string $value, int $ttlSeconds = 0): bool
|
||||
{
|
||||
$redis = self::getInstance();
|
||||
if (!$redis) return false;
|
||||
return $ttlSeconds > 0 ? (bool)$redis->setex($key, $ttlSeconds, $value) : (bool)$redis->set($key, $value);
|
||||
}
|
||||
|
||||
public static function del(string $key): bool
|
||||
{
|
||||
$redis = self::getInstance();
|
||||
if (!$redis) return false;
|
||||
return (bool)$redis->del($key);
|
||||
}
|
||||
|
||||
public static function has(string $key): bool
|
||||
{
|
||||
$redis = self::getInstance();
|
||||
if (!$redis) return false;
|
||||
return (bool)$redis->exists($key);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core;
|
||||
|
||||
class Request
|
||||
{
|
||||
private string $method;
|
||||
private string $path;
|
||||
private array $headers;
|
||||
private array $queryParams;
|
||||
private array $body;
|
||||
private string $rawBody;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->method = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET');
|
||||
$uri = $_SERVER['REQUEST_URI'] ?? '/';
|
||||
$this->path = parse_url($uri, PHP_URL_PATH) ?: '/';
|
||||
$this->queryParams = $_GET ?? [];
|
||||
$this->headers = $this->extractHeaders();
|
||||
|
||||
$this->rawBody = file_get_contents('php://input') ?: '';
|
||||
$contentType = $this->getHeader('content-type');
|
||||
|
||||
if (str_contains($contentType, 'application/json') && !empty($this->rawBody)) {
|
||||
$decoded = json_decode($this->rawBody, true);
|
||||
$this->body = is_array($decoded) ? $decoded : [];
|
||||
} else {
|
||||
$this->body = $_POST ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
private function extractHeaders(): array
|
||||
{
|
||||
$headers = [];
|
||||
foreach ($_SERVER as $key => $value) {
|
||||
if (str_starts_with($key, 'HTTP_')) {
|
||||
$headerName = strtolower(str_replace('_', '-', substr($key, 5)));
|
||||
$headers[$headerName] = (string)$value;
|
||||
} elseif (in_array($key, ['CONTENT_TYPE', 'CONTENT_LENGTH'], true)) {
|
||||
$headerName = strtolower(str_replace('_', '-', $key));
|
||||
$headers[$headerName] = (string)$value;
|
||||
}
|
||||
}
|
||||
return $headers;
|
||||
}
|
||||
|
||||
public function getMethod(): string
|
||||
{
|
||||
return $this->method;
|
||||
}
|
||||
|
||||
public function getPath(): string
|
||||
{
|
||||
return $this->path;
|
||||
}
|
||||
|
||||
public function getHeader(string $name, string $default = ''): string
|
||||
{
|
||||
$name = strtolower($name);
|
||||
return $this->headers[$name] ?? $default;
|
||||
}
|
||||
|
||||
public function getHeaders(): array
|
||||
{
|
||||
return $this->headers;
|
||||
}
|
||||
|
||||
public function getQueryParams(): array
|
||||
{
|
||||
return $this->queryParams;
|
||||
}
|
||||
|
||||
public function getQuery(string $key, $default = null)
|
||||
{
|
||||
return $this->queryParams[$key] ?? $default;
|
||||
}
|
||||
|
||||
public function getBody(): array
|
||||
{
|
||||
return $this->body;
|
||||
}
|
||||
|
||||
public function get(string $key, $default = null)
|
||||
{
|
||||
return $this->body[$key] ?? $default;
|
||||
}
|
||||
|
||||
public function getRawBody(): string
|
||||
{
|
||||
return $this->rawBody;
|
||||
}
|
||||
|
||||
public function getIp(): string
|
||||
{
|
||||
if (!empty($_SERVER['HTTP_CF_CONNECTING_IP'])) {
|
||||
return $_SERVER['HTTP_CF_CONNECTING_IP'];
|
||||
}
|
||||
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
|
||||
$ips = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']);
|
||||
return trim($ips[0]);
|
||||
}
|
||||
return $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1';
|
||||
}
|
||||
|
||||
public function getUserAgent(): string
|
||||
{
|
||||
return $_SERVER['HTTP_USER_AGENT'] ?? 'Unknown';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core;
|
||||
|
||||
class Response
|
||||
{
|
||||
public static function json(array $data, int $statusCode = 200, array $headers = []): void
|
||||
{
|
||||
http_response_code($statusCode);
|
||||
|
||||
$defaultHeaders = [
|
||||
'Content-Type' => 'application/json; charset=utf-8',
|
||||
'X-Content-Type-Options' => 'nosniff',
|
||||
'X-Frame-Options' => 'DENY',
|
||||
'X-XSS-Protection' => '1; mode=block',
|
||||
'Access-Control-Allow-Origin' => '*',
|
||||
'Access-Control-Allow-Methods' => 'GET, POST, PUT, DELETE, OPTIONS',
|
||||
'Access-Control-Allow-Headers' => 'Content-Type, Authorization, X-User-Id, X-Device-Fingerprint, X-Timestamp, X-Nonce, X-Signature',
|
||||
];
|
||||
|
||||
foreach (array_merge($defaultHeaders, $headers) as $name => $val) {
|
||||
header("$name: $val");
|
||||
}
|
||||
|
||||
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
exit;
|
||||
}
|
||||
|
||||
public static function success(mixed $data = null, string $message = 'Success', int $statusCode = 200): void
|
||||
{
|
||||
self::json([
|
||||
'success' => true,
|
||||
'status' => 'success',
|
||||
'message' => $message,
|
||||
'data' => $data,
|
||||
'timestamp' => time(),
|
||||
], $statusCode);
|
||||
}
|
||||
|
||||
public static function error(string $message = 'An error occurred', int $statusCode = 400, array $errors = []): void
|
||||
{
|
||||
self::json([
|
||||
'success' => false,
|
||||
'status' => 'error',
|
||||
'message' => $message,
|
||||
'errors' => $errors,
|
||||
'timestamp' => time(),
|
||||
], $statusCode);
|
||||
}
|
||||
|
||||
public static function unauthorized(string $message = 'Unauthorized access'): void
|
||||
{
|
||||
self::error($message, 401);
|
||||
}
|
||||
|
||||
public static function forbidden(string $message = 'Forbidden'): void
|
||||
{
|
||||
self::error($message, 403);
|
||||
}
|
||||
|
||||
public static function notFound(string $message = 'Resource not found'): void
|
||||
{
|
||||
self::error($message, 404);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core;
|
||||
|
||||
class Router
|
||||
{
|
||||
private array $routes = [];
|
||||
|
||||
public function get(string $path, array|callable $handler, array $middlewares = []): void
|
||||
{
|
||||
$this->addRoute('GET', $path, $handler, $middlewares);
|
||||
}
|
||||
|
||||
public function post(string $path, array|callable $handler, array $middlewares = []): void
|
||||
{
|
||||
$this->addRoute('POST', $path, $handler, $middlewares);
|
||||
}
|
||||
|
||||
public function put(string $path, array|callable $handler, array $middlewares = []): void
|
||||
{
|
||||
$this->addRoute('PUT', $path, $handler, $middlewares);
|
||||
}
|
||||
|
||||
public function delete(string $path, array|callable $handler, array $middlewares = []): void
|
||||
{
|
||||
$this->addRoute('DELETE', $path, $handler, $middlewares);
|
||||
}
|
||||
|
||||
public function options(string $path, array|callable $handler): void
|
||||
{
|
||||
$this->addRoute('OPTIONS', $path, $handler, []);
|
||||
}
|
||||
|
||||
private function addRoute(string $method, string $path, array|callable $handler, array $middlewares): void
|
||||
{
|
||||
$pattern = preg_replace('/\{([a-zA-Z0-9_]+)\}/', '(?P<$1>[^/]+)', $path);
|
||||
$pattern = '#^' . $pattern . '$#';
|
||||
|
||||
$this->routes[] = [
|
||||
'method' => $method,
|
||||
'path' => $path,
|
||||
'pattern' => $pattern,
|
||||
'handler' => $handler,
|
||||
'middlewares' => $middlewares,
|
||||
];
|
||||
}
|
||||
|
||||
public function dispatch(Request $request): void
|
||||
{
|
||||
// Handle preflight CORS OPTIONS requests immediately
|
||||
if ($request->getMethod() === 'OPTIONS') {
|
||||
Response::json(['status' => 'ok']);
|
||||
}
|
||||
|
||||
$method = $request->getMethod();
|
||||
$path = $request->getPath();
|
||||
|
||||
foreach ($this->routes as $route) {
|
||||
if ($route['method'] !== $method) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (preg_match($route['pattern'], $path, $matches)) {
|
||||
$params = [];
|
||||
foreach ($matches as $key => $value) {
|
||||
if (is_string($key)) {
|
||||
$params[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
$this->runMiddlewares($route['middlewares'], $request, function () use ($route, $request, $params) {
|
||||
$this->executeHandler($route['handler'], $request, $params);
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Response::notFound("Endpoint not found: [{$method}] {$path}");
|
||||
}
|
||||
|
||||
private function runMiddlewares(array $middlewares, Request $request, callable $target): void
|
||||
{
|
||||
$pipeline = array_reduce(
|
||||
array_reverse($middlewares),
|
||||
function ($next, $middlewareClass) {
|
||||
return function ($req) use ($next, $middlewareClass) {
|
||||
$instance = new $middlewareClass();
|
||||
$instance->handle($req, $next);
|
||||
};
|
||||
},
|
||||
$target
|
||||
);
|
||||
|
||||
$pipeline($request);
|
||||
}
|
||||
|
||||
private function executeHandler(array|callable $handler, Request $request, array $params): void
|
||||
{
|
||||
if (is_callable($handler)) {
|
||||
call_user_func($handler, $request, $params);
|
||||
return;
|
||||
}
|
||||
|
||||
[$controllerClass, $methodName] = $handler;
|
||||
if (!class_exists($controllerClass)) {
|
||||
Response::error("Controller class {$controllerClass} not found", 500);
|
||||
}
|
||||
|
||||
$controller = new $controllerClass();
|
||||
if (!method_exists($controller, $methodName)) {
|
||||
Response::error("Method {$methodName} not found on controller {$controllerClass}", 500);
|
||||
}
|
||||
|
||||
$controller->$methodName($request, $params);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core;
|
||||
|
||||
class Security
|
||||
{
|
||||
public static function hashPassword(string $password): string
|
||||
{
|
||||
$algo = defined('PASSWORD_ARGON2ID') ? PASSWORD_ARGON2ID : PASSWORD_BCRYPT;
|
||||
return password_hash($password, $algo);
|
||||
}
|
||||
|
||||
public static function verifyPassword(string $password, string $hash): bool
|
||||
{
|
||||
return password_verify($password, $hash);
|
||||
}
|
||||
|
||||
public static function generateRandomHex(int $bytes = 32): string
|
||||
{
|
||||
return bin2hex(random_bytes($bytes));
|
||||
}
|
||||
|
||||
public static function generateHmacSignature(string $data, string $secretKey): string
|
||||
{
|
||||
return hash_hmac('sha256', $data, $secretKey);
|
||||
}
|
||||
|
||||
public static function verifyHmacSignature(string $data, string $signature, string $secretKey): bool
|
||||
{
|
||||
$calculated = self::generateHmacSignature($data, $secretKey);
|
||||
return hash_equals($calculated, $signature);
|
||||
}
|
||||
|
||||
public static function encryptAesGcm(string $plaintext, string $key): string
|
||||
{
|
||||
$cipher = 'aes-256-gcm';
|
||||
$keyHash = hash('sha256', $key, true);
|
||||
$iv = random_bytes(12); // 96-bit IV recommended for GCM
|
||||
$tag = '';
|
||||
|
||||
$ciphertext = openssl_encrypt(
|
||||
$plaintext,
|
||||
$cipher,
|
||||
$keyHash,
|
||||
OPENSSL_RAW_DATA,
|
||||
$iv,
|
||||
$tag,
|
||||
'',
|
||||
16
|
||||
);
|
||||
|
||||
return base64_encode($iv . $tag . $ciphertext);
|
||||
}
|
||||
|
||||
public static function decryptAesGcm(string $encryptedPackage, string $key): ?string
|
||||
{
|
||||
$data = base64_decode($encryptedPackage, true);
|
||||
if (!$data || strlen($data) < 28) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$cipher = 'aes-256-gcm';
|
||||
$keyHash = hash('sha256', $key, true);
|
||||
|
||||
$iv = substr($data, 0, 12);
|
||||
$tag = substr($data, 12, 16);
|
||||
$ciphertext = substr($data, 28);
|
||||
|
||||
$decrypted = openssl_decrypt(
|
||||
$ciphertext,
|
||||
$cipher,
|
||||
$keyHash,
|
||||
OPENSSL_RAW_DATA,
|
||||
$iv,
|
||||
$tag
|
||||
);
|
||||
|
||||
return $decrypted !== false ? $decrypted : null;
|
||||
}
|
||||
|
||||
public static function generateDynamicQrToken(int $userId, string $membershipNumber, int $expiresInSeconds, string $secretKey): string
|
||||
{
|
||||
$payload = [
|
||||
'uid' => $userId,
|
||||
'mem' => $membershipNumber,
|
||||
'exp' => time() + $expiresInSeconds,
|
||||
'rnd' => self::generateRandomHex(8),
|
||||
];
|
||||
|
||||
$json = json_encode($payload, JSON_UNESCAPED_SLASHES);
|
||||
$base64Payload = rtrim(strtr(base64_encode($json), '+/', '-_'), '=');
|
||||
$signature = self::generateHmacSignature($base64Payload, $secretKey);
|
||||
|
||||
return $base64Payload . '.' . $signature;
|
||||
}
|
||||
|
||||
public static function verifyDynamicQrToken(string $token, string $secretKey): ?array
|
||||
{
|
||||
$parts = explode('.', $token);
|
||||
if (count($parts) !== 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
[$base64Payload, $signature] = $parts;
|
||||
|
||||
if (!self::verifyHmacSignature($base64Payload, $signature, $secretKey)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$json = base64_decode(strtr($base64Payload, '-_', '+/'));
|
||||
if (!$json) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$payload = json_decode($json, true);
|
||||
if (!is_array($payload) || !isset($payload['exp']) || $payload['exp'] < time()) {
|
||||
return null; // Expired or invalid format
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user