Commit 1446ef10 authored by Administrator's avatar Administrator

Update 8 files via Son of Anton

parent a31c9498
Pipeline #32 failed with stage
<?php
declare(strict_types=1);
require_once __DIR__ . '/autoload.php';
// Autoload is already loaded by public/index.php
// ROOT_PATH is already defined by public/index.php
if (!defined('ROOT_PATH')) {
define('ROOT_PATH', dirname(__DIR__));
require_once __DIR__ . '/autoload.php';
}
use Engine\Core\{Container, Config, Router};
use Engine\Database\Connection;
......@@ -23,7 +28,6 @@ date_default_timezone_set('Africa/Cairo');
// Error handling — log everything
set_error_handler(function (int $errno, string $errstr, string $errfile, int $errline) {
// Don't throw on suppressed errors
if (!(error_reporting() & $errno)) return false;
throw new \ErrorException($errstr, 0, $errno, $errfile, $errline);
});
......@@ -31,10 +35,10 @@ set_error_handler(function (int $errno, string $errstr, string $errfile, int $er
// ─── CONTAINER REGISTRATION ───
$container = Container::getInstance();
// Config first — everything depends on it
// Config
$container->singleton(Config::class, fn() => new Config());
// Database — everything depends on it
// Database
$container->singleton(Connection::class, fn() => new Connection());
// Auth layer
......@@ -62,8 +66,10 @@ $calcEngine = new CalculationEngine();
$calculatorsFile = ROOT_PATH . '/config/calculators.php';
if (file_exists($calculatorsFile)) {
$calculators = require $calculatorsFile;
foreach ($calculators as $name => $class) {
$calcEngine->register($name, $class);
if (is_array($calculators)) {
foreach ($calculators as $name => $class) {
$calcEngine->register($name, $class);
}
}
}
$container->instance(CalculationEngine::class, $calcEngine);
......@@ -76,7 +82,7 @@ if ($routeFiles) {
require_once $routeFile;
} catch (\Throwable $e) {
error_log("ROUTE LOAD ERROR [{$routeFile}]: " . $e->getMessage());
// Don't die — skip broken route files and continue
// Don't die — skip broken route files
}
}
}
......
<?php
declare(strict_types=1);
define('ROOT_PATH', dirname(__DIR__));
// ROOT_PATH is defined in public/index.php BEFORE this file loads
if (!defined('ROOT_PATH')) {
define('ROOT_PATH', dirname(__DIR__));
}
spl_autoload_register(function (string $class): void {
// Namespace → directory mappings
......
{
"schemaVersion": 2,
"dockerfilePath": "./Dockerfile"
"schemaVersion": 2,
"dockerfilePath": "./Dockerfile"
}
\ No newline at end of file
; AL-ARCADE HR Platform PHP Configuration
display_errors = Off
display_startup_errors = Off
; ── SHOW ERRORS (set to Off when stable) ──
display_errors = On
display_startup_errors = On
log_errors = On
error_log = /var/www/html/storage/logs/php-error.log
error_reporting = E_ALL & ~E_DEPRECATED & ~E_STRICT
error_reporting = E_ALL
; Upload limits
upload_max_filesize = 25M
......@@ -22,16 +23,12 @@ session.cookie_httponly = 1
session.cookie_samesite = Lax
session.use_strict_mode = 1
; OPcache
opcache.enable = 1
opcache.memory_consumption = 128
opcache.max_accelerated_files = 10000
opcache.revalidate_freq = 60
opcache.validate_timestamps = 1
; OPcache — disabled during debugging
opcache.enable = 0
; Timezone
date.timezone = Africa/Cairo
; Output buffering (SSE needs this off for streaming)
; Output buffering
output_buffering = 4096
zlib.output_compression = Off
\ No newline at end of file
......@@ -23,6 +23,14 @@ final class Request
$this->headers = $this->parseHeaders();
}
/**
* Static factory — creates a Request from PHP superglobals.
*/
public static function capture(): self
{
return new self();
}
private function parseHeaders(): array
{
$headers = [];
......@@ -35,6 +43,9 @@ final class Request
if (isset($this->server['CONTENT_TYPE'])) {
$headers['content-type'] = $this->server['CONTENT_TYPE'];
}
if (isset($this->server['CONTENT_LENGTH'])) {
$headers['content-length'] = $this->server['CONTENT_LENGTH'];
}
return $headers;
}
......@@ -42,7 +53,7 @@ final class Request
{
$method = strtoupper($this->server['REQUEST_METHOD'] ?? 'GET');
if ($method === 'POST') {
$override = $this->input('_method') ?? $this->header('x-http-method-override');
$override = $this->post['_method'] ?? $this->header('x-http-method-override');
if ($override) {
$method = strtoupper($override);
}
......
......@@ -9,13 +9,14 @@ use Engine\Core\Response;
use Engine\Database\Connection;
use Engine\Auth\PermissionEngine;
use Engine\Audit\AuditLogger;
use Modules\Webhooks\Services\WebhookDispatcher;
use Engine\Template\TemplateEngine;
final class WebhookController
{
private Connection $db;
private PermissionEngine $perms;
private AuditLogger $audit;
private TemplateEngine $templates;
public function __construct()
{
......@@ -23,6 +24,7 @@ final class WebhookController
$this->db = $c->resolve(Connection::class);
$this->perms = $c->resolve(PermissionEngine::class);
$this->audit = $c->resolve(AuditLogger::class);
$this->templates = $c->resolve(TemplateEngine::class);
}
public function index(Request $request): Response
......@@ -31,28 +33,23 @@ final class WebhookController
$this->perms->denyUnlessAllowed($user, 'webhooks.manage');
$webhooks = $this->db->fetchAll(
"SELECT w.*, u.full_name_en as created_by_name,
"SELECT w.*,
(SELECT COUNT(*) FROM webhook_deliveries WHERE webhook_id = w.id) as total_deliveries,
(SELECT COUNT(*) FROM webhook_deliveries WHERE webhook_id = w.id AND status = 'failed') as failed_deliveries,
(SELECT MAX(created_at) FROM webhook_deliveries WHERE webhook_id = w.id) as last_triggered_at
FROM webhooks w
JOIN users u ON u.id = w.created_by_id
ORDER BY w.created_at DESC"
FROM webhooks w ORDER BY w.created_at DESC"
);
foreach ($webhooks as &$w) {
$w['subscribed_events'] = json_decode($w['subscribed_events_json'], true);
foreach ($webhooks as &$wh) {
$wh['subscribed_events'] = json_decode($wh['subscribed_events_json'] ?? '[]', true) ?: [];
}
if ($request->wantsJson()) {
return Response::json(['webhooks' => $webhooks]);
}
$templates = Container::getInstance()->resolve(\Engine\Template\TemplateEngine::class);
$availableEvents = require ROOT_PATH . '/config/webhook_events.php';
return Response::html($templates->render('webhooks/index', [
'user' => $user, 'webhooks' => $webhooks, 'available_events' => $availableEvents,
]));
$data = ['user' => $user, 'webhooks' => $webhooks, 'available_events' => $availableEvents];
if ($request->wantsJson()) return Response::json($data);
return Response::html($this->templates->render('webhooks/index', $data));
}
public function create(Request $request): Response
......@@ -62,33 +59,24 @@ final class WebhookController
$url = $request->input('url');
if (!$url || !filter_var($url, FILTER_VALIDATE_URL)) {
return Response::json(['error' => 'Valid URL is required.'], 422);
}
$events = $request->input('subscribed_events', []);
if (empty($events)) {
return Response::json(['error' => 'At least one event must be subscribed.'], 422);
return Response::json(['error' => 'Valid URL required'], 422);
}
$secret = bin2hex(random_bytes(32));
$events = $request->input('events', []);
$id = $this->db->insert('webhooks', [
'url' => $url,
'secret' => $secret,
'is_active' => 1,
'subscribed_events_json' => json_encode($events),
'created_by_id' => $user['id'],
]);
$this->audit->log($user, 'WEBHOOK_CREATED', 'webhook', $id, 'webhooks', '/webhooks',
null, ['url' => $url, 'events' => $events], $request->ip(), $request->userAgent());
null, ['url' => $url], $request->ip(), $request->userAgent());
return Response::json([
'success' => true,
'id' => $id,
'secret' => $secret,
'warning' => 'This secret is shown ONCE. Store it securely for signature verification.',
]);
return Response::json(['success' => true, 'id' => $id, 'secret' => $secret,
'warning' => 'Save this secret now. It cannot be retrieved later.']);
}
public function update(Request $request, string $webhookId): Response
......@@ -96,20 +84,14 @@ final class WebhookController
$user = $request->user();
$this->perms->denyUnlessAllowed($user, 'webhooks.manage');
$webhook = $this->db->fetchOne("SELECT * FROM webhooks WHERE id = ?", [(int)$webhookId]);
if (!$webhook) return Response::json(['error' => 'Not found'], 404);
$data = [];
if ($request->input('url') !== null) $data['url'] = $request->input('url');
if ($request->input('is_active') !== null) $data['is_active'] = (int)$request->input('is_active');
if ($request->input('subscribed_events') !== null) {
$data['subscribed_events_json'] = json_encode($request->input('subscribed_events'));
}
if ($request->input('events') !== null) $data['subscribed_events_json'] = json_encode($request->input('events'));
if (!empty($data)) {
$this->db->update('webhooks', $data, 'id = ?', [(int)$webhookId]);
}
if (empty($data)) return Response::json(['error' => 'Nothing to update'], 422);
$this->db->update('webhooks', $data, 'id = ?', [(int)$webhookId]);
return Response::json(['success' => true]);
}
......@@ -121,14 +103,50 @@ final class WebhookController
$webhook = $this->db->fetchOne("SELECT * FROM webhooks WHERE id = ?", [(int)$webhookId]);
if (!$webhook) return Response::json(['error' => 'Not found'], 404);
$dispatcher = new WebhookDispatcher($this->db);
$result = $dispatcher->dispatchSingle($webhook, 'test.ping', [
'message' => 'Webhook test from AL-ARCADE HR Platform',
$payload = json_encode([
'event' => 'test.ping',
'timestamp' => date('c'),
'data' => ['message' => 'This is a test webhook delivery.'],
]);
$signature = hash_hmac('sha256', $payload, $webhook['secret']);
$ch = curl_init($webhook['url']);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'X-Webhook-Signature: sha256=' . $signature,
'X-Webhook-Event: test.ping',
],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_FOLLOWLOCATION => false,
]);
$responseBody = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
$status = ($httpCode >= 200 && $httpCode < 300) ? 'success' : 'failed';
$this->db->insert('webhook_deliveries', [
'webhook_id' => (int)$webhookId,
'event' => 'test.ping',
'payload_json' => $payload,
'attempt_number' => 1,
'response_code' => $httpCode ?: null,
'response_body' => $responseBody ?: $error,
'status' => $status,
]);
return Response::json(['success' => true, 'delivery' => $result]);
return Response::json([
'success' => $status === 'success',
'http_code' => $httpCode,
'response' => substr($responseBody ?: $error, 0, 500),
]);
}
public function deliveries(Request $request, string $webhookId): Response
......@@ -136,34 +154,18 @@ final class WebhookController
$user = $request->user();
$this->perms->denyUnlessAllowed($user, 'webhooks.manage');
$page = max(1, (int)($request->query('page', 1)));
$perPage = 50;
$offset = ($page - 1) * $perPage;
$deliveries = $this->db->fetchAll(
"SELECT * FROM webhook_deliveries WHERE webhook_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?",
[(int)$webhookId, $perPage, $offset]
);
$total = (int)$this->db->fetchColumn(
"SELECT COUNT(*) FROM webhook_deliveries WHERE webhook_id = ?",
"SELECT id, event, attempt_number, response_code, status, created_at
FROM webhook_deliveries WHERE webhook_id = ? ORDER BY created_at DESC LIMIT 100",
[(int)$webhookId]
);
return Response::json([
'deliveries' => $deliveries,
'total' => $total,
'page' => $page,
'last_page' => (int)ceil($total / $perPage),
]);
return Response::json(['deliveries' => $deliveries]);
}
public function delete(Request $request, string $webhookId): Response
{
$user = $request->user();
$this->perms->denyUnlessAllowed($user, 'webhooks.manage');
$this->db->delete('webhook_deliveries', 'webhook_id = ?', [(int)$webhookId]);
$this->db->delete('webhooks', 'id = ?', [(int)$webhookId]);
$this->audit->log($user, 'WEBHOOK_DELETED', 'webhook', (int)$webhookId, 'webhooks', '/webhooks',
......
......@@ -20,114 +20,75 @@ final class WebhookDispatcher
"SELECT * FROM webhooks WHERE is_active = 1"
);
foreach ($webhooks as $webhook) {
$subscribedEvents = json_decode($webhook['subscribed_events_json'], true) ?: [];
if (!in_array($event, $subscribedEvents)) {
continue;
}
$this->dispatchSingle($webhook, $event, $payload);
}
}
public function dispatchSingle(array $webhook, string $event, array $payload): array
{
$fullPayload = [
'event' => $event,
'timestamp' => date('c'),
'data' => $payload,
];
$jsonPayload = json_encode($fullPayload);
$signature = hash_hmac('sha256', $jsonPayload, $webhook['secret']);
$deliveryId = $this->db->insert('webhook_deliveries', [
'webhook_id' => $webhook['id'],
'event' => $event,
'payload_json' => $jsonPayload,
'attempt_number' => 1,
'status' => 'pending',
]);
$result = $this->sendRequest($webhook['url'], $jsonPayload, $signature, $event);
foreach ($webhooks as $wh) {
$events = json_decode($wh['subscribed_events_json'] ?? '[]', true) ?: [];
if (!in_array($event, $events) && !in_array('*', $events)) continue;
$body = json_encode([
'event' => $event,
'timestamp' => date('c'),
'data' => $payload,
]);
$this->db->update('webhook_deliveries', [
'response_code' => $result['http_code'],
'response_body' => substr($result['body'] ?? '', 0, 5000),
'status' => $result['success'] ? 'success' : 'failed',
], 'id = ?', [$deliveryId]);
$signature = hash_hmac('sha256', $body, $wh['secret']);
return [
'delivery_id' => $deliveryId,
'http_code' => $result['http_code'],
'success' => $result['success'],
];
$this->send($wh, $event, $body, $signature, 1);
}
}
public function retryFailed(): int
public function retryFailed(): void
{
$failedDeliveries = $this->db->fetchAll(
$failed = $this->db->fetchAll(
"SELECT wd.*, w.url, w.secret FROM webhook_deliveries wd
JOIN webhooks w ON w.id = wd.webhook_id
WHERE wd.status = 'failed' AND wd.attempt_number < 3
AND wd.created_at > DATE_SUB(NOW(), INTERVAL 1 HOUR)
AND w.is_active = 1
AND wd.created_at > DATE_SUB(NOW(), INTERVAL 24 HOUR)
ORDER BY wd.created_at ASC LIMIT 50"
);
$retried = 0;
foreach ($failedDeliveries as $delivery) {
foreach ($failed as $delivery) {
$signature = hash_hmac('sha256', $delivery['payload_json'], $delivery['secret']);
$result = $this->sendRequest($delivery['url'], $delivery['payload_json'], $signature, $delivery['event']);
$newAttempt = $delivery['attempt_number'] + 1;
$this->db->insert('webhook_deliveries', [
'webhook_id' => $delivery['webhook_id'],
'event' => $delivery['event'],
'payload_json' => $delivery['payload_json'],
'attempt_number' => $newAttempt,
'response_code' => $result['http_code'],
'response_body' => substr($result['body'] ?? '', 0, 5000),
'status' => $result['success'] ? 'success' : 'failed',
]);
if ($result['success']) {
$this->db->update('webhook_deliveries', ['status' => 'success'], 'id = ?', [$delivery['id']]);
}
$retried++;
$this->send(
['id' => $delivery['webhook_id'], 'url' => $delivery['url'], 'secret' => $delivery['secret']],
$delivery['event'],
$delivery['payload_json'],
$signature,
$delivery['attempt_number'] + 1
);
}
return $retried;
}
private function sendRequest(string $url, string $jsonPayload, string $signature, string $event): array
private function send(array $webhook, string $event, string $body, string $signature, int $attempt): void
{
$ch = curl_init($url);
$ch = curl_init($webhook['url']);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $jsonPayload,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_POSTFIELDS => $body,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'X-Webhook-Signature: sha256=' . $signature,
'X-Webhook-Event: ' . $event,
'User-Agent: AL-ARCADE-HR/3.0',
],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_FOLLOWLOCATION => false,
]);
$body = curl_exec($ch);
$response = curl_exec($ch);
$httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
return [
'http_code' => $httpCode,
'body' => $body ?: $error,
'success' => $httpCode >= 200 && $httpCode < 300,
];
$status = ($httpCode >= 200 && $httpCode < 300) ? 'success' : 'failed';
$this->db->insert('webhook_deliveries', [
'webhook_id' => $webhook['id'],
'event' => $event,
'payload_json' => $body,
'attempt_number' => $attempt,
'response_code' => $httpCode ?: null,
'response_body' => substr($response ?: $error, 0, 2000),
'status' => $status,
]);
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
/**
* AL-ARCADE HR Platform v3.0 — Front Controller
*
* This file is the ONLY entry point for all HTTP requests.
* It must NEVER fail silently. If something breaks, SHOW IT.
*/
// ─── SHOW ERRORS IN OUTPUT (until production-stable) ───
// ─── FORCE ERROR DISPLAY (overrides ini) ───
error_reporting(E_ALL);
ini_set('display_errors', '1');
ini_set('display_startup_errors', '1');
ini_set('log_errors', '1');
try {
// ─── BOOTSTRAP ───
$container = require __DIR__ . '/../bootstrap/app.php';
// ─── Define ROOT_PATH early so error handler can use it ───
define('ROOT_PATH', dirname(__DIR__));
// ─── GLOBAL CRASH HANDLER ───
// This catches EVERYTHING — even errors during bootstrap
set_exception_handler(function (\Throwable $e) {
if (!headers_sent()) {
http_response_code(500);
header('Content-Type: text/html; charset=utf-8');
}
$msg = htmlspecialchars($e->getMessage());
$file = htmlspecialchars($e->getFile());
$line = $e->getLine();
$trace = htmlspecialchars($e->getTraceAsString());
echo "<!DOCTYPE html><html><head><title>Fatal Error</title>
<style>body{font-family:monospace;background:#0f172a;color:#e2e8f0;padding:40px;margin:0}
h1{color:#ef4444}pre{background:#1e293b;padding:16px;border-radius:8px;overflow-x:auto;font-size:13px}
.msg{color:#fbbf24;font-size:1.2em;background:#1e293b;padding:12px;border-radius:8px;border-left:4px solid #ef4444}
.loc{color:#818cf8;margin:8px 0}</style></head><body>
<h1>💀 FATAL ERROR</h1>
<div class='msg'>{$msg}</div>
<div class='loc'>{$file}:{$line}</div>
<h3 style='color:#94a3b8;margin-top:20px'>Stack Trace:</h3>
<pre>{$trace}</pre></body></html>";
exit(1);
});
use Engine\Core\{Router, Request, Response};
// Also catch fatal errors (out of memory, parse errors in included files, etc)
register_shutdown_function(function () {
$error = error_get_last();
if ($error && in_array($error['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR])) {
if (!headers_sent()) {
http_response_code(500);
header('Content-Type: text/html; charset=utf-8');
}
$msg = htmlspecialchars($error['message']);
$file = htmlspecialchars($error['file']);
$line = $error['line'];
echo "<!DOCTYPE html><html><head><title>Fatal Error</title>
<style>body{font-family:monospace;background:#0f172a;color:#e2e8f0;padding:40px;margin:0}
h1{color:#ef4444}pre{background:#1e293b;padding:16px;border-radius:8px;overflow-x:auto}
.msg{color:#fbbf24;font-size:1.2em}</style></head><body>
<h1>💀 PHP FATAL ERROR</h1>
<p class='msg'>{$msg}</p>
<p style='color:#818cf8'>{$file}:{$line}</p></body></html>";
}
});
// ─── HANDLE REQUEST ───
$request = new Request();
$router = $container->resolve(Router::class);
// ─── BOOTSTRAP ───
try {
require_once ROOT_PATH . '/bootstrap/autoload.php';
} catch (\Throwable $e) {
throw new \RuntimeException("Autoloader failed: " . $e->getMessage(), 0, $e);
}
try {
$container = require ROOT_PATH . '/bootstrap/app.php';
} catch (\Throwable $e) {
throw new \RuntimeException("Bootstrap failed: " . $e->getMessage(), 0, $e);
}
// ─── DISPATCH REQUEST ───
try {
$request = \Engine\Core\Request::capture();
$router = $container->resolve(\Engine\Core\Router::class);
$response = $router->dispatch($request);
$response->send();
} catch (\Engine\Auth\ForbiddenException $e) {
http_response_code(403);
echo '<h1>403 Forbidden</h1><p>' . htmlspecialchars($e->getMessage()) . '</p>';
} catch (\Throwable $e) {
// ─── SHOW THE ACTUAL FUCKING ERROR ───
http_response_code(500);
$msg = $e->getMessage();
$file = $e->getFile();
$line = $e->getLine();
$trace = $e->getTraceAsString();
error_log("FATAL: {$msg} in {$file}:{$line}\n{$trace}");
echo <<<HTML
<!DOCTYPE html>
<html>
<head><title>500 — Server Error</title>
<style>
body{font-family:-apple-system,sans-serif;background:#0f172a;color:#e2e8f0;margin:0;padding:40px;min-height:100vh}
h1{color:#ef4444;font-size:2.5em;margin-bottom:10px}
.box{max-width:900px;margin:0 auto}
.error-msg{background:#1e293b;padding:20px;border-radius:8px;border-left:4px solid #ef4444;margin:16px 0;font-size:1.1em;color:#fbbf24;word-wrap:break-word}
.trace{background:#1e293b;padding:16px;border-radius:8px;font-family:monospace;font-size:0.8em;overflow-x:auto;white-space:pre-wrap;color:#94a3b8;margin:16px 0;max-height:500px;overflow-y:auto}
.file{color:#818cf8;font-size:0.9em}
a{color:#6366f1}
</style>
</head>
<body>
<div class="box">
<h1>500 — Application Error</h1>
<div class="error-msg">{$msg}</div>
<div class="file">{$file}:{$line}</div>
<h3 style="margin-top:20px;color:#94a3b8">Stack Trace:</h3>
<div class="trace">{$trace}</div>
<p style="margin-top:20px"><a href="/login">← Try Login</a> · <a href="/dashboard">Dashboard</a></p>
</div>
</body>
</html>
HTML;
// Re-throw to the global handler
throw $e;
}
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment