Release v1.5.1
This commit is contained in:
@@ -1,22 +1,47 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/http/Response.php';
|
||||
require_once __DIR__ . '/http/Router.php';
|
||||
require_once __DIR__ . '/views/View.php';
|
||||
require_once __DIR__ . '/services/Database.php';
|
||||
require_once __DIR__ . '/services/Auth.php';
|
||||
require_once __DIR__ . '/services/Csrf.php';
|
||||
require_once __DIR__ . '/services/Settings.php';
|
||||
require_once __DIR__ . '/services/Audit.php';
|
||||
require_once __DIR__ . '/services/Permissions.php';
|
||||
require_once __DIR__ . '/services/Shortcodes.php';
|
||||
require_once __DIR__ . '/services/Nav.php';
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/http/Response.php';
|
||||
require_once __DIR__ . '/http/Router.php';
|
||||
require_once __DIR__ . '/views/View.php';
|
||||
require_once __DIR__ . '/services/Database.php';
|
||||
require_once __DIR__ . '/services/Auth.php';
|
||||
require_once __DIR__ . '/services/Csrf.php';
|
||||
require_once __DIR__ . '/services/Settings.php';
|
||||
require_once __DIR__ . '/services/Audit.php';
|
||||
require_once __DIR__ . '/services/ApiLayer.php';
|
||||
require_once __DIR__ . '/services/Permissions.php';
|
||||
require_once __DIR__ . '/services/Shortcodes.php';
|
||||
require_once __DIR__ . '/services/Nav.php';
|
||||
require_once __DIR__ . '/services/Mailer.php';
|
||||
require_once __DIR__ . '/services/RateLimiter.php';
|
||||
require_once __DIR__ . '/services/Plugins.php';
|
||||
require_once __DIR__ . '/services/Updater.php';
|
||||
|
||||
Core\Services\Auth::init();
|
||||
Core\Services\Settings::init(__DIR__ . '/../storage/settings.php');
|
||||
Core\Services\Plugins::init(__DIR__ . '/../plugins');
|
||||
Core\Services\Audit::ensureTable();
|
||||
|
||||
Core\Services\Auth::init();
|
||||
Core\Services\Settings::init(__DIR__ . '/../storage/settings.php');
|
||||
Core\Services\Plugins::init(__DIR__ . '/../plugins');
|
||||
Core\Services\Audit::ensureTable();
|
||||
|
||||
if (!function_exists('csrf_token')) {
|
||||
function csrf_token(): string
|
||||
{
|
||||
return \Core\Services\Csrf::token();
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('csrf_input')) {
|
||||
function csrf_input(): string
|
||||
{
|
||||
$token = htmlspecialchars(\Core\Services\Csrf::token(), ENT_QUOTES, 'UTF-8');
|
||||
return '<input type="hidden" name="csrf_token" value="' . $token . '">';
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('csrf_meta')) {
|
||||
function csrf_meta(): string
|
||||
{
|
||||
$token = htmlspecialchars(\Core\Services\Csrf::token(), ENT_QUOTES, 'UTF-8');
|
||||
return '<meta name="csrf-token" content="' . $token . '">';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,185 +1,197 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Http;
|
||||
|
||||
use Core\Services\Auth;
|
||||
use Core\Services\Csrf;
|
||||
use Core\Services\Database;
|
||||
use Core\Services\Permissions;
|
||||
use Core\Services\Shortcodes;
|
||||
use Core\Views\View;
|
||||
use PDO;
|
||||
use Throwable;
|
||||
|
||||
class Router
|
||||
{
|
||||
private array $routes = [];
|
||||
|
||||
public function get(string $path, callable $handler): void
|
||||
{
|
||||
$this->routes['GET'][$path] = $handler;
|
||||
}
|
||||
|
||||
public function post(string $path, callable $handler): void
|
||||
{
|
||||
$this->routes['POST'][$path] = $handler;
|
||||
}
|
||||
|
||||
public function registerModules(string $modulesPath): void
|
||||
{
|
||||
foreach (glob($modulesPath . '/*/module.php') as $moduleFile) {
|
||||
$module = require $moduleFile;
|
||||
if (is_callable($module)) {
|
||||
$module($this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function dispatch(string $uri, string $method): Response
|
||||
{
|
||||
$path = parse_url($uri, PHP_URL_PATH) ?: '/';
|
||||
if ($path !== '/' && str_ends_with($path, '/')) {
|
||||
$path = rtrim($path, '/');
|
||||
}
|
||||
$method = strtoupper($method);
|
||||
|
||||
|
||||
if ($method === 'POST' && $this->requiresCsrf($path) && !Csrf::verifyRequest()) {
|
||||
return new Response('Invalid request token. Please refresh and try again.', 419);
|
||||
}
|
||||
|
||||
if ($method === 'GET') {
|
||||
$redirect = $this->matchRedirect($path);
|
||||
if ($redirect !== null) {
|
||||
return $redirect;
|
||||
}
|
||||
}
|
||||
|
||||
if (str_starts_with($path, '/admin')) {
|
||||
$permission = Permissions::routePermission($path);
|
||||
if ($permission !== null && Auth::check() && !Auth::can($permission)) {
|
||||
return new Response('', 302, ['Location' => '/admin?denied=1']);
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($this->routes[$method][$path])) {
|
||||
$handler = $this->routes[$method][$path];
|
||||
return $handler();
|
||||
}
|
||||
|
||||
if ($method === 'GET') {
|
||||
if (str_starts_with($path, '/news/')) {
|
||||
$postSlug = trim(substr($path, strlen('/news/')));
|
||||
if ($postSlug !== '' && strpos($postSlug, '/') === false) {
|
||||
$db = Database::get();
|
||||
if ($db instanceof PDO) {
|
||||
try {
|
||||
$stmt = $db->prepare("
|
||||
SELECT title, content_html, published_at, featured_image_url, author_name, category, tags
|
||||
FROM ac_posts
|
||||
WHERE slug = :slug AND is_published = 1
|
||||
LIMIT 1
|
||||
");
|
||||
$stmt->execute([':slug' => $postSlug]);
|
||||
$post = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
if ($post) {
|
||||
$view = new View(__DIR__ . '/../../modules/blog/views');
|
||||
return new Response($view->render('site/show.php', [
|
||||
'title' => (string)$post['title'],
|
||||
'content_html' => (string)$post['content_html'],
|
||||
'published_at' => (string)($post['published_at'] ?? ''),
|
||||
'featured_image_url' => (string)($post['featured_image_url'] ?? ''),
|
||||
'author_name' => (string)($post['author_name'] ?? ''),
|
||||
'category' => (string)($post['category'] ?? ''),
|
||||
'tags' => (string)($post['tags'] ?? ''),
|
||||
]));
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$slug = ltrim($path, '/');
|
||||
if ($slug !== '' && strpos($slug, '/') === false) {
|
||||
$db = Database::get();
|
||||
if ($db instanceof PDO) {
|
||||
try {
|
||||
$stmt = $db->prepare("SELECT title, content_html FROM ac_pages WHERE slug = :slug AND is_published = 1 LIMIT 1");
|
||||
$stmt->execute([':slug' => $slug]);
|
||||
$page = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
if ($page) {
|
||||
$view = new View(__DIR__ . '/../../modules/pages/views');
|
||||
return new Response($view->render('site/show.php', [
|
||||
'title' => (string)$page['title'],
|
||||
'content_html' => Shortcodes::render((string)$page['content_html'], [
|
||||
'page_slug' => (string)$slug,
|
||||
]),
|
||||
]));
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$view = new View();
|
||||
return new Response($view->render('site/404.php', [
|
||||
'title' => 'Not Found',
|
||||
'message' => 'Page not found.',
|
||||
]), 404);
|
||||
}
|
||||
|
||||
|
||||
|
||||
private function requiresCsrf(string $path): bool
|
||||
{
|
||||
// All browser-initiated POST routes require CSRF protection.
|
||||
return true;
|
||||
}
|
||||
|
||||
private function matchRedirect(string $path): ?Response
|
||||
{
|
||||
$db = Database::get();
|
||||
if (!($db instanceof PDO)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$db->exec("
|
||||
CREATE TABLE IF NOT EXISTS ac_redirects (
|
||||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
source_path VARCHAR(255) NOT NULL UNIQUE,
|
||||
target_url VARCHAR(1000) NOT NULL,
|
||||
status_code SMALLINT NOT NULL DEFAULT 301,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
");
|
||||
$stmt = $db->prepare("
|
||||
SELECT target_url, status_code
|
||||
FROM ac_redirects
|
||||
WHERE source_path = :path AND is_active = 1
|
||||
LIMIT 1
|
||||
");
|
||||
$stmt->execute([':path' => $path]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
$code = (int)($row['status_code'] ?? 301);
|
||||
if (!in_array($code, [301, 302, 307, 308], true)) {
|
||||
$code = 301;
|
||||
}
|
||||
$target = trim((string)($row['target_url'] ?? ''));
|
||||
if ($target === '' || $target === $path) {
|
||||
return null;
|
||||
}
|
||||
return new Response('', $code, ['Location' => $target]);
|
||||
} catch (Throwable $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Http;
|
||||
|
||||
use Core\Services\Auth;
|
||||
use Core\Services\Database;
|
||||
use Core\Services\Csrf;
|
||||
use Core\Services\Permissions;
|
||||
use Core\Services\Shortcodes;
|
||||
use Core\Views\View;
|
||||
use PDO;
|
||||
use Throwable;
|
||||
|
||||
class Router
|
||||
{
|
||||
private array $routes = [];
|
||||
|
||||
public function get(string $path, callable $handler): void
|
||||
{
|
||||
$this->routes['GET'][$path] = $handler;
|
||||
}
|
||||
|
||||
public function post(string $path, callable $handler): void
|
||||
{
|
||||
$this->routes['POST'][$path] = $handler;
|
||||
}
|
||||
|
||||
public function registerModules(string $modulesPath): void
|
||||
{
|
||||
foreach (glob($modulesPath . '/*/module.php') as $moduleFile) {
|
||||
$module = require $moduleFile;
|
||||
if (is_callable($module)) {
|
||||
$module($this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function dispatch(string $uri, string $method): Response
|
||||
{
|
||||
$path = parse_url($uri, PHP_URL_PATH) ?: '/';
|
||||
if ($path !== '/' && str_ends_with($path, '/')) {
|
||||
$path = rtrim($path, '/');
|
||||
}
|
||||
$method = strtoupper($method);
|
||||
|
||||
if ($method === 'GET') {
|
||||
$redirect = $this->matchRedirect($path);
|
||||
if ($redirect !== null) {
|
||||
return $redirect;
|
||||
}
|
||||
}
|
||||
|
||||
if (str_starts_with($path, '/admin')) {
|
||||
$permission = Permissions::routePermission($path);
|
||||
if ($permission !== null && Auth::check() && !Auth::can($permission)) {
|
||||
return new Response('', 302, ['Location' => '/admin?denied=1']);
|
||||
}
|
||||
}
|
||||
|
||||
if (in_array($method, ['POST', 'PUT', 'PATCH', 'DELETE'], true) && !$this->isCsrfExempt($path) && !Csrf::verifyRequest()) {
|
||||
return $this->csrfFailureResponse($path);
|
||||
}
|
||||
|
||||
if (isset($this->routes[$method][$path])) {
|
||||
$handler = $this->routes[$method][$path];
|
||||
return $handler();
|
||||
}
|
||||
|
||||
if ($method === 'GET') {
|
||||
if (str_starts_with($path, '/news/')) {
|
||||
$postSlug = trim(substr($path, strlen('/news/')));
|
||||
if ($postSlug !== '' && strpos($postSlug, '/') === false) {
|
||||
$db = Database::get();
|
||||
if ($db instanceof PDO) {
|
||||
try {
|
||||
$stmt = $db->prepare("
|
||||
SELECT title, content_html, published_at, featured_image_url, author_name, category, tags
|
||||
FROM ac_posts
|
||||
WHERE slug = :slug AND is_published = 1
|
||||
LIMIT 1
|
||||
");
|
||||
$stmt->execute([':slug' => $postSlug]);
|
||||
$post = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
if ($post) {
|
||||
$view = new View(__DIR__ . '/../../modules/blog/views');
|
||||
return new Response($view->render('site/show.php', [
|
||||
'title' => (string)$post['title'],
|
||||
'content_html' => (string)$post['content_html'],
|
||||
'published_at' => (string)($post['published_at'] ?? ''),
|
||||
'featured_image_url' => (string)($post['featured_image_url'] ?? ''),
|
||||
'author_name' => (string)($post['author_name'] ?? ''),
|
||||
'category' => (string)($post['category'] ?? ''),
|
||||
'tags' => (string)($post['tags'] ?? ''),
|
||||
]));
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$slug = ltrim($path, '/');
|
||||
if ($slug !== '' && strpos($slug, '/') === false) {
|
||||
$db = Database::get();
|
||||
if ($db instanceof PDO) {
|
||||
try {
|
||||
$stmt = $db->prepare("SELECT title, content_html FROM ac_pages WHERE slug = :slug AND is_published = 1 LIMIT 1");
|
||||
$stmt->execute([':slug' => $slug]);
|
||||
$page = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
if ($page) {
|
||||
$view = new View(__DIR__ . '/../../modules/pages/views');
|
||||
return new Response($view->render('site/show.php', [
|
||||
'title' => (string)$page['title'],
|
||||
'content_html' => Shortcodes::render((string)$page['content_html'], [
|
||||
'page_slug' => (string)$slug,
|
||||
]),
|
||||
]));
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$view = new View();
|
||||
return new Response($view->render('site/404.php', [
|
||||
'title' => 'Not Found',
|
||||
'message' => 'Page not found.',
|
||||
]), 404);
|
||||
}
|
||||
|
||||
private function isCsrfExempt(string $path): bool
|
||||
{
|
||||
return str_starts_with($path, '/api/');
|
||||
}
|
||||
|
||||
private function csrfFailureResponse(string $path): Response
|
||||
{
|
||||
$accept = (string)($_SERVER['HTTP_ACCEPT'] ?? '');
|
||||
$contentType = (string)($_SERVER['CONTENT_TYPE'] ?? '');
|
||||
if (stripos($accept, 'application/json') !== false || stripos($contentType, 'application/json') !== false) {
|
||||
return new Response(json_encode(['ok' => false, 'error' => 'Invalid CSRF token.']), 419, ['Content-Type' => 'application/json']);
|
||||
}
|
||||
|
||||
$target = (string)($_SERVER['HTTP_REFERER'] ?? $path);
|
||||
if ($target === '') {
|
||||
$target = '/';
|
||||
}
|
||||
$separator = str_contains($target, '?') ? '&' : '?';
|
||||
return new Response('', 302, ['Location' => $target . $separator . 'error=csrf']);
|
||||
}
|
||||
|
||||
private function matchRedirect(string $path): ?Response
|
||||
{
|
||||
$db = Database::get();
|
||||
if (!($db instanceof PDO)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$db->exec("
|
||||
CREATE TABLE IF NOT EXISTS ac_redirects (
|
||||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
source_path VARCHAR(255) NOT NULL UNIQUE,
|
||||
target_url VARCHAR(1000) NOT NULL,
|
||||
status_code SMALLINT NOT NULL DEFAULT 301,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
");
|
||||
$stmt = $db->prepare("
|
||||
SELECT target_url, status_code
|
||||
FROM ac_redirects
|
||||
WHERE source_path = :path AND is_active = 1
|
||||
LIMIT 1
|
||||
");
|
||||
$stmt->execute([':path' => $path]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
$code = (int)($row['status_code'] ?? 301);
|
||||
if (!in_array($code, [301, 302, 307, 308], true)) {
|
||||
$code = 301;
|
||||
}
|
||||
$target = trim((string)($row['target_url'] ?? ''));
|
||||
if ($target === '' || $target === $path) {
|
||||
return null;
|
||||
}
|
||||
return new Response('', $code, ['Location' => $target]);
|
||||
} catch (Throwable $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
560
core/services/ApiLayer.php
Normal file
560
core/services/ApiLayer.php
Normal file
@@ -0,0 +1,560 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Services;
|
||||
|
||||
use PDO;
|
||||
use Throwable;
|
||||
|
||||
class ApiLayer
|
||||
{
|
||||
private static bool $schemaEnsured = false;
|
||||
private static bool $schemaEnsuring = false;
|
||||
|
||||
public static function ensureSchema(?PDO $db = null): void
|
||||
{
|
||||
if (self::$schemaEnsured || self::$schemaEnsuring) {
|
||||
return;
|
||||
}
|
||||
|
||||
$db = $db instanceof PDO ? $db : Database::get();
|
||||
if (!($db instanceof PDO)) {
|
||||
return;
|
||||
}
|
||||
|
||||
self::$schemaEnsuring = true;
|
||||
|
||||
try {
|
||||
try {
|
||||
$db->exec("
|
||||
CREATE TABLE IF NOT EXISTS ac_api_clients (
|
||||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(150) NOT NULL,
|
||||
api_key_hash CHAR(64) NOT NULL UNIQUE,
|
||||
api_key_prefix VARCHAR(24) NOT NULL,
|
||||
webhook_url VARCHAR(1000) NULL,
|
||||
webhook_secret VARCHAR(128) NULL,
|
||||
scopes_json TEXT NULL,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
last_used_at DATETIME NULL,
|
||||
last_used_ip VARCHAR(64) NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
KEY idx_api_clients_active (is_active)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
");
|
||||
} catch (Throwable $e) {
|
||||
}
|
||||
|
||||
try {
|
||||
$db->exec("
|
||||
CREATE TABLE IF NOT EXISTS ac_store_order_item_allocations (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
order_id INT UNSIGNED NOT NULL,
|
||||
order_item_id INT UNSIGNED NOT NULL,
|
||||
artist_id INT UNSIGNED NULL,
|
||||
release_id INT UNSIGNED NULL,
|
||||
track_id INT UNSIGNED NULL,
|
||||
source_item_type ENUM('track','release','bundle') NOT NULL,
|
||||
source_item_id INT UNSIGNED NOT NULL,
|
||||
title_snapshot VARCHAR(255) NOT NULL,
|
||||
qty INT UNSIGNED NOT NULL DEFAULT 1,
|
||||
gross_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00,
|
||||
currency_snapshot CHAR(3) NOT NULL DEFAULT 'GBP',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
KEY idx_store_allocations_order (order_id),
|
||||
KEY idx_store_allocations_item (order_item_id),
|
||||
KEY idx_store_allocations_artist (artist_id),
|
||||
KEY idx_store_allocations_release (release_id),
|
||||
KEY idx_store_allocations_track (track_id),
|
||||
KEY idx_store_allocations_created (created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
");
|
||||
} catch (Throwable $e) {
|
||||
}
|
||||
|
||||
try {
|
||||
$db->exec("ALTER TABLE ac_releases ADD COLUMN artist_id INT UNSIGNED NULL AFTER slug");
|
||||
} catch (Throwable $e) {
|
||||
}
|
||||
try {
|
||||
$db->exec("ALTER TABLE ac_store_order_items ADD COLUMN artist_id INT UNSIGNED NULL AFTER item_id");
|
||||
} catch (Throwable $e) {
|
||||
}
|
||||
try {
|
||||
$db->exec("ALTER TABLE ac_releases ADD KEY idx_releases_artist_id (artist_id)");
|
||||
} catch (Throwable $e) {
|
||||
}
|
||||
try {
|
||||
$db->exec("ALTER TABLE ac_store_order_items ADD KEY idx_store_order_items_artist_id (artist_id)");
|
||||
} catch (Throwable $e) {
|
||||
}
|
||||
|
||||
self::backfillReleaseArtists($db);
|
||||
self::backfillOrderItemArtists($db);
|
||||
self::backfillAllocations($db);
|
||||
self::$schemaEnsured = true;
|
||||
} finally {
|
||||
self::$schemaEnsuring = false;
|
||||
}
|
||||
}
|
||||
|
||||
public static function issueClient(PDO $db, string $name, string $webhookUrl = '', string $scopesJson = '[]'): string
|
||||
{
|
||||
self::ensureSchema($db);
|
||||
$plainKey = 'ac_' . bin2hex(random_bytes(24));
|
||||
$stmt = $db->prepare("
|
||||
INSERT INTO ac_api_clients (name, api_key_hash, api_key_prefix, webhook_url, scopes_json, is_active)
|
||||
VALUES (:name, :hash, :prefix, :webhook_url, :scopes_json, 1)
|
||||
");
|
||||
$stmt->execute([
|
||||
':name' => $name,
|
||||
':hash' => hash('sha256', $plainKey),
|
||||
':prefix' => substr($plainKey, 0, 16),
|
||||
':webhook_url' => $webhookUrl !== '' ? $webhookUrl : null,
|
||||
':scopes_json' => $scopesJson,
|
||||
]);
|
||||
return $plainKey;
|
||||
}
|
||||
|
||||
public static function verifyRequest(?PDO $db = null): ?array
|
||||
{
|
||||
$db = $db instanceof PDO ? $db : Database::get();
|
||||
if (!($db instanceof PDO)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
self::ensureSchema($db);
|
||||
$key = self::extractApiKey();
|
||||
if ($key === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$stmt = $db->prepare("
|
||||
SELECT id, name, api_key_prefix, webhook_url, webhook_secret, scopes_json, is_active
|
||||
FROM ac_api_clients
|
||||
WHERE api_key_hash = :hash
|
||||
AND is_active = 1
|
||||
LIMIT 1
|
||||
");
|
||||
$stmt->execute([':hash' => hash('sha256', $key)]);
|
||||
$client = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$client) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$touch = $db->prepare("
|
||||
UPDATE ac_api_clients
|
||||
SET last_used_at = NOW(), last_used_ip = :ip
|
||||
WHERE id = :id
|
||||
");
|
||||
$touch->execute([
|
||||
':ip' => self::clientIp(),
|
||||
':id' => (int)$client['id'],
|
||||
]);
|
||||
|
||||
return $client;
|
||||
} catch (Throwable $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static function syncOrderItemAllocations(PDO $db, int $orderId, int $orderItemId): void
|
||||
{
|
||||
self::ensureSchema($db);
|
||||
if ($orderId <= 0 || $orderItemId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$stmt = $db->prepare("
|
||||
SELECT id, order_id, item_type, item_id, artist_id, title_snapshot, qty, line_total, currency_snapshot
|
||||
FROM ac_store_order_items
|
||||
WHERE id = :id AND order_id = :order_id
|
||||
LIMIT 1
|
||||
");
|
||||
$stmt->execute([
|
||||
':id' => $orderItemId,
|
||||
':order_id' => $orderId,
|
||||
]);
|
||||
$item = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$item) {
|
||||
return;
|
||||
}
|
||||
|
||||
$db->prepare("DELETE FROM ac_store_order_item_allocations WHERE order_item_id = :order_item_id")
|
||||
->execute([':order_item_id' => $orderItemId]);
|
||||
|
||||
$type = (string)($item['item_type'] ?? '');
|
||||
$sourceId = (int)($item['item_id'] ?? 0);
|
||||
$qty = max(1, (int)($item['qty'] ?? 1));
|
||||
$lineTotal = (float)($item['line_total'] ?? 0);
|
||||
$currency = strtoupper(trim((string)($item['currency_snapshot'] ?? 'GBP')));
|
||||
if (!preg_match('/^[A-Z]{3}$/', $currency)) {
|
||||
$currency = 'GBP';
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
if ($type === 'track') {
|
||||
$track = self::loadTrackArtistContext($db, $sourceId);
|
||||
if ($track) {
|
||||
$rows[] = [
|
||||
'artist_id' => (int)($track['artist_id'] ?? 0),
|
||||
'release_id' => (int)($track['release_id'] ?? 0),
|
||||
'track_id' => $sourceId,
|
||||
'title_snapshot' => (string)($track['title'] ?? $item['title_snapshot'] ?? 'Track'),
|
||||
'gross_amount' => $lineTotal,
|
||||
];
|
||||
}
|
||||
} elseif ($type === 'release') {
|
||||
$release = self::loadReleaseArtistContext($db, $sourceId);
|
||||
if ($release) {
|
||||
$rows[] = [
|
||||
'artist_id' => (int)($release['artist_id'] ?? 0),
|
||||
'release_id' => $sourceId,
|
||||
'track_id' => null,
|
||||
'title_snapshot' => (string)($release['title'] ?? $item['title_snapshot'] ?? 'Release'),
|
||||
'gross_amount' => $lineTotal,
|
||||
];
|
||||
}
|
||||
} elseif ($type === 'bundle') {
|
||||
$rows = self::buildBundleAllocationRows($db, $sourceId, $lineTotal);
|
||||
}
|
||||
|
||||
if (!$rows) {
|
||||
return;
|
||||
}
|
||||
|
||||
$insert = $db->prepare("
|
||||
INSERT INTO ac_store_order_item_allocations
|
||||
(order_id, order_item_id, artist_id, release_id, track_id, source_item_type, source_item_id, title_snapshot, qty, gross_amount, currency_snapshot, created_at)
|
||||
VALUES (:order_id, :order_item_id, :artist_id, :release_id, :track_id, :source_item_type, :source_item_id, :title_snapshot, :qty, :gross_amount, :currency_snapshot, NOW())
|
||||
");
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$artistId = (int)($row['artist_id'] ?? 0);
|
||||
$releaseId = (int)($row['release_id'] ?? 0);
|
||||
$trackId = (int)($row['track_id'] ?? 0);
|
||||
$insert->execute([
|
||||
':order_id' => $orderId,
|
||||
':order_item_id' => $orderItemId,
|
||||
':artist_id' => $artistId > 0 ? $artistId : null,
|
||||
':release_id' => $releaseId > 0 ? $releaseId : null,
|
||||
':track_id' => $trackId > 0 ? $trackId : null,
|
||||
':source_item_type' => $type,
|
||||
':source_item_id' => $sourceId,
|
||||
':title_snapshot' => (string)($row['title_snapshot'] ?? $item['title_snapshot'] ?? 'Item'),
|
||||
':qty' => $qty,
|
||||
':gross_amount' => (float)($row['gross_amount'] ?? 0),
|
||||
':currency_snapshot' => $currency,
|
||||
]);
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
}
|
||||
}
|
||||
|
||||
public static function dispatchSaleWebhooksForOrder(int $orderId): void
|
||||
{
|
||||
$db = Database::get();
|
||||
if (!($db instanceof PDO) || $orderId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
self::ensureSchema($db);
|
||||
|
||||
try {
|
||||
$stmt = $db->query("
|
||||
SELECT id, name, webhook_url, webhook_secret
|
||||
FROM ac_api_clients
|
||||
WHERE is_active = 1
|
||||
AND webhook_url IS NOT NULL
|
||||
AND webhook_url <> ''
|
||||
ORDER BY id ASC
|
||||
");
|
||||
$clients = $stmt ? ($stmt->fetchAll(PDO::FETCH_ASSOC) ?: []) : [];
|
||||
if (!$clients) {
|
||||
return;
|
||||
}
|
||||
|
||||
$payload = self::buildOrderWebhookPayload($db, $orderId);
|
||||
if (!$payload) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($clients as $client) {
|
||||
$url = trim((string)($client['webhook_url'] ?? ''));
|
||||
if ($url === '') {
|
||||
continue;
|
||||
}
|
||||
$json = json_encode($payload, JSON_UNESCAPED_SLASHES);
|
||||
if (!is_string($json) || $json === '') {
|
||||
continue;
|
||||
}
|
||||
$headers = [
|
||||
'Content-Type: application/json',
|
||||
'Content-Length: ' . strlen($json),
|
||||
'X-AudioCore-Event: sale.paid',
|
||||
'X-AudioCore-Client: ' . (string)($client['name'] ?? 'AudioCore API'),
|
||||
];
|
||||
$secret = trim((string)($client['webhook_secret'] ?? ''));
|
||||
if ($secret !== '') {
|
||||
$headers[] = 'X-AudioCore-Signature: sha256=' . hash_hmac('sha256', $json, $secret);
|
||||
}
|
||||
self::postJson($url, $json, $headers);
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
}
|
||||
}
|
||||
|
||||
private static function buildOrderWebhookPayload(PDO $db, int $orderId): array
|
||||
{
|
||||
try {
|
||||
$orderStmt = $db->prepare("
|
||||
SELECT id, order_no, email, status, currency, subtotal, total, discount_code, discount_amount, payment_provider, payment_ref, created_at, updated_at
|
||||
FROM ac_store_orders
|
||||
WHERE id = :id
|
||||
LIMIT 1
|
||||
");
|
||||
$orderStmt->execute([':id' => $orderId]);
|
||||
$order = $orderStmt->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$order) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$allocStmt = $db->prepare("
|
||||
SELECT id, artist_id, release_id, track_id, source_item_type, source_item_id, title_snapshot, qty, gross_amount, currency_snapshot, created_at
|
||||
FROM ac_store_order_item_allocations
|
||||
WHERE order_id = :order_id
|
||||
ORDER BY id ASC
|
||||
");
|
||||
$allocStmt->execute([':order_id' => $orderId]);
|
||||
$allocations = $allocStmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
||||
|
||||
return [
|
||||
'event' => 'sale.paid',
|
||||
'order' => $order,
|
||||
'allocations' => $allocations,
|
||||
];
|
||||
} catch (Throwable $e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private static function backfillReleaseArtists(PDO $db): void
|
||||
{
|
||||
try {
|
||||
$db->exec("
|
||||
UPDATE ac_releases r
|
||||
JOIN ac_artists a ON a.name = r.artist_name
|
||||
SET r.artist_id = a.id
|
||||
WHERE r.artist_id IS NULL
|
||||
AND r.artist_name IS NOT NULL
|
||||
AND r.artist_name <> ''
|
||||
");
|
||||
} catch (Throwable $e) {
|
||||
}
|
||||
}
|
||||
|
||||
private static function backfillOrderItemArtists(PDO $db): void
|
||||
{
|
||||
try {
|
||||
$db->exec("
|
||||
UPDATE ac_store_order_items oi
|
||||
JOIN ac_release_tracks t ON oi.item_type = 'track' AND oi.item_id = t.id
|
||||
JOIN ac_releases r ON r.id = t.release_id
|
||||
SET oi.artist_id = r.artist_id
|
||||
WHERE oi.artist_id IS NULL
|
||||
");
|
||||
} catch (Throwable $e) {
|
||||
}
|
||||
try {
|
||||
$db->exec("
|
||||
UPDATE ac_store_order_items oi
|
||||
JOIN ac_releases r ON oi.item_type = 'release' AND oi.item_id = r.id
|
||||
SET oi.artist_id = r.artist_id
|
||||
WHERE oi.artist_id IS NULL
|
||||
");
|
||||
} catch (Throwable $e) {
|
||||
}
|
||||
}
|
||||
|
||||
private static function backfillAllocations(PDO $db): void
|
||||
{
|
||||
try {
|
||||
$stmt = $db->query("
|
||||
SELECT oi.id, oi.order_id
|
||||
FROM ac_store_order_items oi
|
||||
LEFT JOIN ac_store_order_item_allocations a ON a.order_item_id = oi.id
|
||||
WHERE a.id IS NULL
|
||||
ORDER BY oi.id ASC
|
||||
LIMIT 5000
|
||||
");
|
||||
$rows = $stmt ? ($stmt->fetchAll(PDO::FETCH_ASSOC) ?: []) : [];
|
||||
foreach ($rows as $row) {
|
||||
self::syncOrderItemAllocations($db, (int)($row['order_id'] ?? 0), (int)($row['id'] ?? 0));
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
}
|
||||
}
|
||||
|
||||
private static function loadReleaseArtistContext(PDO $db, int $releaseId): ?array
|
||||
{
|
||||
if ($releaseId <= 0) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
$stmt = $db->prepare("
|
||||
SELECT r.id, r.title, r.artist_id
|
||||
FROM ac_releases r
|
||||
WHERE r.id = :id
|
||||
LIMIT 1
|
||||
");
|
||||
$stmt->execute([':id' => $releaseId]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
return $row ?: null;
|
||||
} catch (Throwable $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static function loadTrackArtistContext(PDO $db, int $trackId): ?array
|
||||
{
|
||||
if ($trackId <= 0) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
$stmt = $db->prepare("
|
||||
SELECT t.id, t.title, t.release_id, r.artist_id
|
||||
FROM ac_release_tracks t
|
||||
JOIN ac_releases r ON r.id = t.release_id
|
||||
WHERE t.id = :id
|
||||
LIMIT 1
|
||||
");
|
||||
$stmt->execute([':id' => $trackId]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
return $row ?: null;
|
||||
} catch (Throwable $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static function buildBundleAllocationRows(PDO $db, int $bundleId, float $lineTotal): array
|
||||
{
|
||||
if ($bundleId <= 0) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
$stmt = $db->prepare("
|
||||
SELECT
|
||||
r.id AS release_id,
|
||||
r.title,
|
||||
r.artist_id,
|
||||
COALESCE(SUM(CASE WHEN sp.is_enabled = 1 THEN sp.track_price ELSE 0 END), 0) AS weight
|
||||
FROM ac_store_bundle_items bi
|
||||
JOIN ac_releases r ON r.id = bi.release_id
|
||||
LEFT JOIN ac_release_tracks t ON t.release_id = r.id
|
||||
LEFT JOIN ac_store_track_products sp ON sp.release_track_id = t.id
|
||||
WHERE bi.bundle_id = :bundle_id
|
||||
GROUP BY r.id, r.title, r.artist_id, bi.sort_order, bi.id
|
||||
ORDER BY bi.sort_order ASC, bi.id ASC
|
||||
");
|
||||
$stmt->execute([':bundle_id' => $bundleId]);
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
||||
if (!$rows) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$weights = [];
|
||||
$totalWeight = 0.0;
|
||||
foreach ($rows as $index => $row) {
|
||||
$weight = max(0.0, (float)($row['weight'] ?? 0));
|
||||
if ($weight <= 0) {
|
||||
$weight = 1.0;
|
||||
}
|
||||
$weights[$index] = $weight;
|
||||
$totalWeight += $weight;
|
||||
}
|
||||
if ($totalWeight <= 0) {
|
||||
$totalWeight = (float)count($rows);
|
||||
}
|
||||
|
||||
$result = [];
|
||||
$allocated = 0.0;
|
||||
$lastIndex = count($rows) - 1;
|
||||
foreach ($rows as $index => $row) {
|
||||
if ($index === $lastIndex) {
|
||||
$amount = round($lineTotal - $allocated, 2);
|
||||
} else {
|
||||
$amount = round($lineTotal * ($weights[$index] / $totalWeight), 2);
|
||||
$allocated += $amount;
|
||||
}
|
||||
$result[] = [
|
||||
'artist_id' => (int)($row['artist_id'] ?? 0),
|
||||
'release_id' => (int)($row['release_id'] ?? 0),
|
||||
'track_id' => null,
|
||||
'title_snapshot' => (string)($row['title'] ?? 'Bundle release'),
|
||||
'gross_amount' => max(0.0, $amount),
|
||||
];
|
||||
}
|
||||
return $result;
|
||||
} catch (Throwable $e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private static function extractApiKey(): string
|
||||
{
|
||||
$auth = trim((string)($_SERVER['HTTP_AUTHORIZATION'] ?? ''));
|
||||
if (stripos($auth, 'Bearer ') === 0) {
|
||||
return trim(substr($auth, 7));
|
||||
}
|
||||
$header = trim((string)($_SERVER['HTTP_X_API_KEY'] ?? ''));
|
||||
if ($header !== '') {
|
||||
return $header;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
private static function clientIp(): string
|
||||
{
|
||||
$forwarded = trim((string)($_SERVER['HTTP_X_FORWARDED_FOR'] ?? ''));
|
||||
if ($forwarded !== '') {
|
||||
$parts = explode(',', $forwarded);
|
||||
return trim((string)($parts[0] ?? ''));
|
||||
}
|
||||
return trim((string)($_SERVER['REMOTE_ADDR'] ?? ''));
|
||||
}
|
||||
|
||||
private static function postJson(string $url, string $json, array $headers): void
|
||||
{
|
||||
if (function_exists('curl_init')) {
|
||||
$ch = curl_init($url);
|
||||
if ($ch === false) {
|
||||
return;
|
||||
}
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_HTTPHEADER => $headers,
|
||||
CURLOPT_POSTFIELDS => $json,
|
||||
CURLOPT_TIMEOUT => 8,
|
||||
CURLOPT_CONNECTTIMEOUT => 5,
|
||||
]);
|
||||
curl_exec($ch);
|
||||
curl_close($ch);
|
||||
return;
|
||||
}
|
||||
|
||||
$context = stream_context_create([
|
||||
'http' => [
|
||||
'method' => 'POST',
|
||||
'header' => implode("\r\n", $headers),
|
||||
'content' => $json,
|
||||
'timeout' => 8,
|
||||
'ignore_errors' => true,
|
||||
],
|
||||
]);
|
||||
@file_get_contents($url, false, $context);
|
||||
}
|
||||
}
|
||||
@@ -12,13 +12,7 @@ class Auth
|
||||
public static function init(): void
|
||||
{
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||
$secure = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';
|
||||
session_start([
|
||||
'cookie_httponly' => true,
|
||||
'cookie_secure' => $secure,
|
||||
'cookie_samesite' => 'Lax',
|
||||
'use_strict_mode' => 1,
|
||||
]);
|
||||
session_start();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,9 +42,14 @@ class Auth
|
||||
public static function logout(): void
|
||||
{
|
||||
self::init();
|
||||
unset($_SESSION[self::SESSION_KEY]);
|
||||
unset($_SESSION[self::SESSION_ROLE_KEY]);
|
||||
unset($_SESSION[self::SESSION_NAME_KEY]);
|
||||
$_SESSION = [];
|
||||
if (ini_get('session.use_cookies')) {
|
||||
$params = session_get_cookie_params();
|
||||
setcookie(session_name(), '', time() - 42000, $params['path'], $params['domain'], (bool)$params['secure'], (bool)$params['httponly']);
|
||||
}
|
||||
session_destroy();
|
||||
session_start();
|
||||
session_regenerate_id(true);
|
||||
}
|
||||
|
||||
public static function role(): string
|
||||
|
||||
@@ -1,50 +1,48 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Services;
|
||||
|
||||
class Csrf
|
||||
{
|
||||
private const SESSION_KEY = '_csrf_token';
|
||||
|
||||
public static function token(): string
|
||||
{
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||
@session_start();
|
||||
}
|
||||
$token = (string)($_SESSION[self::SESSION_KEY] ?? '');
|
||||
if ($token === '') {
|
||||
$token = bin2hex(random_bytes(32));
|
||||
$_SESSION[self::SESSION_KEY] = $token;
|
||||
}
|
||||
return $token;
|
||||
}
|
||||
|
||||
public static function verifyRequest(): bool
|
||||
{
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||
@session_start();
|
||||
}
|
||||
|
||||
$sessionToken = (string)($_SESSION[self::SESSION_KEY] ?? '');
|
||||
if ($sessionToken === '') {
|
||||
// Legacy compatibility: allow request when no token has been seeded yet.
|
||||
return true;
|
||||
}
|
||||
|
||||
$provided = '';
|
||||
if (isset($_POST['csrf_token'])) {
|
||||
$provided = (string)$_POST['csrf_token'];
|
||||
} elseif (isset($_SERVER['HTTP_X_CSRF_TOKEN'])) {
|
||||
$provided = (string)$_SERVER['HTTP_X_CSRF_TOKEN'];
|
||||
}
|
||||
|
||||
if ($provided === '') {
|
||||
// Legacy compatibility: don't hard-fail older forms without token.
|
||||
return true;
|
||||
}
|
||||
|
||||
return hash_equals($sessionToken, $provided);
|
||||
}
|
||||
}
|
||||
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Services;
|
||||
|
||||
class Csrf
|
||||
{
|
||||
private const SESSION_KEY = '_csrf_token';
|
||||
|
||||
public static function token(): string
|
||||
{
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||
@session_start();
|
||||
}
|
||||
$token = (string)($_SESSION[self::SESSION_KEY] ?? '');
|
||||
if ($token === '') {
|
||||
$token = bin2hex(random_bytes(32));
|
||||
$_SESSION[self::SESSION_KEY] = $token;
|
||||
}
|
||||
return $token;
|
||||
}
|
||||
|
||||
public static function verifyRequest(): bool
|
||||
{
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||
@session_start();
|
||||
}
|
||||
|
||||
$sessionToken = (string)($_SESSION[self::SESSION_KEY] ?? '');
|
||||
if ($sessionToken === '') {
|
||||
$sessionToken = self::token();
|
||||
}
|
||||
|
||||
$provided = '';
|
||||
if (isset($_POST['csrf_token'])) {
|
||||
$provided = (string)$_POST['csrf_token'];
|
||||
} elseif (isset($_SERVER['HTTP_X_CSRF_TOKEN'])) {
|
||||
$provided = (string)$_SERVER['HTTP_X_CSRF_TOKEN'];
|
||||
}
|
||||
|
||||
if ($provided === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return hash_equals($sessionToken, $provided);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,258 +1,276 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Services;
|
||||
|
||||
use Core\Http\Router;
|
||||
use PDO;
|
||||
use Throwable;
|
||||
|
||||
class Plugins
|
||||
{
|
||||
private static string $path = '';
|
||||
private static array $plugins = [];
|
||||
|
||||
public static function init(string $path): void
|
||||
{
|
||||
self::$path = rtrim($path, '/');
|
||||
self::sync();
|
||||
}
|
||||
|
||||
public static function all(): array
|
||||
{
|
||||
return self::$plugins;
|
||||
}
|
||||
|
||||
public static function enabled(): array
|
||||
{
|
||||
return array_values(array_filter(self::$plugins, static function (array $plugin): bool {
|
||||
return (bool)($plugin['is_enabled'] ?? false);
|
||||
}));
|
||||
}
|
||||
|
||||
public static function isEnabled(string $slug): bool
|
||||
{
|
||||
foreach (self::$plugins as $plugin) {
|
||||
if ((string)($plugin['slug'] ?? '') === $slug) {
|
||||
return (bool)($plugin['is_enabled'] ?? false);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static function adminNav(): array
|
||||
{
|
||||
$items = [];
|
||||
foreach (self::enabled() as $plugin) {
|
||||
$nav = $plugin['admin_nav'] ?? null;
|
||||
if (!is_array($nav)) {
|
||||
continue;
|
||||
}
|
||||
$items[] = [
|
||||
'label' => (string)($nav['label'] ?? ''),
|
||||
'url' => (string)($nav['url'] ?? ''),
|
||||
'roles' => array_values(array_filter((array)($nav['roles'] ?? []))),
|
||||
'icon' => (string)($nav['icon'] ?? ''),
|
||||
'slug' => (string)($plugin['slug'] ?? ''),
|
||||
];
|
||||
}
|
||||
return array_values(array_filter($items, static function (array $item): bool {
|
||||
if ($item['label'] === '' || $item['url'] === '') {
|
||||
return false;
|
||||
}
|
||||
$slug = trim((string)($item['slug'] ?? ''));
|
||||
if ($slug !== '' && Auth::check() && !Permissions::can(Auth::role(), 'plugin.' . $slug)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}));
|
||||
}
|
||||
|
||||
public static function register(Router $router): void
|
||||
{
|
||||
foreach (self::enabled() as $plugin) {
|
||||
$entry = (string)($plugin['entry'] ?? 'plugin.php');
|
||||
$entryPath = rtrim((string)($plugin['path'] ?? ''), '/') . '/' . $entry;
|
||||
if (!is_file($entryPath)) {
|
||||
continue;
|
||||
}
|
||||
$handler = require $entryPath;
|
||||
if (is_callable($handler)) {
|
||||
$handler($router);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static function toggle(string $slug, bool $enabled): void
|
||||
{
|
||||
$db = Database::get();
|
||||
if (!$db instanceof PDO) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
$stmt = $db->prepare("UPDATE ac_plugins SET is_enabled = :enabled, updated_at = NOW() WHERE slug = :slug");
|
||||
$stmt->execute([
|
||||
':enabled' => $enabled ? 1 : 0,
|
||||
':slug' => $slug,
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
}
|
||||
self::sync();
|
||||
if (!$enabled) {
|
||||
$plugin = null;
|
||||
foreach (self::$plugins as $item) {
|
||||
if (($item['slug'] ?? '') === $slug) {
|
||||
$plugin = $item;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($plugin && !empty($plugin['pages'])) {
|
||||
self::removeNavLinks($db, (array)$plugin['pages']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static function sync(): void
|
||||
{
|
||||
$filesystem = self::scanFilesystem();
|
||||
$db = Database::get();
|
||||
$dbRows = [];
|
||||
|
||||
if ($db instanceof PDO) {
|
||||
try {
|
||||
$db->exec("
|
||||
CREATE TABLE IF NOT EXISTS ac_plugins (
|
||||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
slug VARCHAR(120) NOT NULL UNIQUE,
|
||||
name VARCHAR(200) NOT NULL,
|
||||
version VARCHAR(50) NOT NULL DEFAULT '0.0.0',
|
||||
is_enabled TINYINT(1) NOT NULL DEFAULT 0,
|
||||
installed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
");
|
||||
$stmt = $db->query("SELECT slug, is_enabled FROM ac_plugins");
|
||||
$dbRows = $stmt ? $stmt->fetchAll(PDO::FETCH_ASSOC) : [];
|
||||
} catch (Throwable $e) {
|
||||
$dbRows = [];
|
||||
}
|
||||
}
|
||||
|
||||
$dbMap = [];
|
||||
foreach ($dbRows as $row) {
|
||||
$dbMap[(string)$row['slug']] = (int)$row['is_enabled'];
|
||||
}
|
||||
|
||||
foreach ($filesystem as $slug => $plugin) {
|
||||
$plugin['is_enabled'] = (bool)($dbMap[$slug] ?? ($plugin['default_enabled'] ?? false));
|
||||
$filesystem[$slug] = $plugin;
|
||||
if ($db instanceof PDO && !isset($dbMap[$slug])) {
|
||||
try {
|
||||
$stmt = $db->prepare("
|
||||
INSERT INTO ac_plugins (slug, name, version, is_enabled)
|
||||
VALUES (:slug, :name, :version, :enabled)
|
||||
");
|
||||
$stmt->execute([
|
||||
':slug' => $slug,
|
||||
':name' => (string)($plugin['name'] ?? $slug),
|
||||
':version' => (string)($plugin['version'] ?? '0.0.0'),
|
||||
':enabled' => $plugin['is_enabled'] ? 1 : 0,
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
}
|
||||
}
|
||||
if ($db instanceof PDO && $plugin['is_enabled']) {
|
||||
$thisPages = $plugin['pages'] ?? [];
|
||||
if (is_array($thisPages) && $thisPages) {
|
||||
self::ensurePages($db, $thisPages);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self::$plugins = array_values($filesystem);
|
||||
}
|
||||
|
||||
private static function scanFilesystem(): array
|
||||
{
|
||||
if (self::$path === '' || !is_dir(self::$path)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$plugins = [];
|
||||
foreach (glob(self::$path . '/*/plugin.json') as $manifestPath) {
|
||||
$dir = dirname($manifestPath);
|
||||
$slug = basename($dir);
|
||||
$raw = file_get_contents($manifestPath);
|
||||
$decoded = json_decode($raw ?: '', true);
|
||||
if (!is_array($decoded)) {
|
||||
$decoded = [];
|
||||
}
|
||||
|
||||
$plugins[$slug] = [
|
||||
'slug' => $slug,
|
||||
'name' => (string)($decoded['name'] ?? $slug),
|
||||
'version' => (string)($decoded['version'] ?? '0.0.0'),
|
||||
'description' => (string)($decoded['description'] ?? ''),
|
||||
'author' => (string)($decoded['author'] ?? ''),
|
||||
'admin_nav' => is_array($decoded['admin_nav'] ?? null) ? $decoded['admin_nav'] : null,
|
||||
'pages' => is_array($decoded['pages'] ?? null) ? $decoded['pages'] : [],
|
||||
'entry' => (string)($decoded['entry'] ?? 'plugin.php'),
|
||||
'default_enabled' => (bool)($decoded['default_enabled'] ?? false),
|
||||
'path' => $dir,
|
||||
];
|
||||
}
|
||||
|
||||
return $plugins;
|
||||
}
|
||||
|
||||
private static function ensurePages(PDO $db, array $pages): void
|
||||
{
|
||||
foreach ($pages as $page) {
|
||||
if (!is_array($page)) {
|
||||
continue;
|
||||
}
|
||||
$slug = trim((string)($page['slug'] ?? ''));
|
||||
$title = trim((string)($page['title'] ?? ''));
|
||||
$content = (string)($page['content_html'] ?? '');
|
||||
if ($slug === '' || $title === '') {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
$stmt = $db->prepare("SELECT id FROM ac_pages WHERE slug = :slug LIMIT 1");
|
||||
$stmt->execute([':slug' => $slug]);
|
||||
if ($stmt->fetch()) {
|
||||
continue;
|
||||
}
|
||||
$insert = $db->prepare("
|
||||
INSERT INTO ac_pages (title, slug, content_html, is_published, is_home, is_blog_index)
|
||||
VALUES (:title, :slug, :content_html, 1, 0, 0)
|
||||
");
|
||||
$insert->execute([
|
||||
':title' => $title,
|
||||
':slug' => $slug,
|
||||
':content_html' => $content,
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static function removeNavLinks(PDO $db, array $pages): void
|
||||
{
|
||||
foreach ($pages as $page) {
|
||||
if (!is_array($page)) {
|
||||
continue;
|
||||
}
|
||||
$slug = trim((string)($page['slug'] ?? ''));
|
||||
if ($slug === '') {
|
||||
continue;
|
||||
}
|
||||
$url = '/' . ltrim($slug, '/');
|
||||
try {
|
||||
$stmt = $db->prepare("DELETE FROM ac_nav_links WHERE url = :url");
|
||||
$stmt->execute([':url' => $url]);
|
||||
} catch (Throwable $e) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Services;
|
||||
|
||||
use Core\Http\Router;
|
||||
use PDO;
|
||||
use Throwable;
|
||||
|
||||
class Plugins
|
||||
{
|
||||
private static string $path = '';
|
||||
private static array $plugins = [];
|
||||
|
||||
public static function init(string $path): void
|
||||
{
|
||||
self::$path = rtrim($path, '/');
|
||||
self::sync();
|
||||
}
|
||||
|
||||
public static function all(): array
|
||||
{
|
||||
return self::$plugins;
|
||||
}
|
||||
|
||||
public static function enabled(): array
|
||||
{
|
||||
return array_values(array_filter(self::$plugins, static function (array $plugin): bool {
|
||||
return (bool)($plugin['is_enabled'] ?? false);
|
||||
}));
|
||||
}
|
||||
|
||||
public static function isEnabled(string $slug): bool
|
||||
{
|
||||
foreach (self::$plugins as $plugin) {
|
||||
if ((string)($plugin['slug'] ?? '') === $slug) {
|
||||
return (bool)($plugin['is_enabled'] ?? false);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static function adminNav(): array
|
||||
{
|
||||
$items = [];
|
||||
foreach (self::enabled() as $plugin) {
|
||||
$nav = $plugin['admin_nav'] ?? null;
|
||||
if (!is_array($nav)) {
|
||||
continue;
|
||||
}
|
||||
$items[] = [
|
||||
'label' => (string)($nav['label'] ?? ''),
|
||||
'url' => (string)($nav['url'] ?? ''),
|
||||
'roles' => array_values(array_filter((array)($nav['roles'] ?? []))),
|
||||
'icon' => (string)($nav['icon'] ?? ''),
|
||||
'slug' => (string)($plugin['slug'] ?? ''),
|
||||
];
|
||||
}
|
||||
$items = array_values(array_filter($items, static function (array $item): bool {
|
||||
if ($item['label'] === '' || $item['url'] === '') {
|
||||
return false;
|
||||
}
|
||||
$slug = trim((string)($item['slug'] ?? ''));
|
||||
if ($slug !== '' && Auth::check() && !Permissions::can(Auth::role(), 'plugin.' . $slug)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}));
|
||||
$order = [
|
||||
'artists' => 10,
|
||||
'releases' => 20,
|
||||
'store' => 30,
|
||||
'advanced-reporting' => 40,
|
||||
'support' => 50,
|
||||
];
|
||||
usort($items, static function (array $a, array $b) use ($order): int {
|
||||
$aSlug = (string)($a['slug'] ?? '');
|
||||
$bSlug = (string)($b['slug'] ?? '');
|
||||
$aOrder = $order[$aSlug] ?? 1000;
|
||||
$bOrder = $order[$bSlug] ?? 1000;
|
||||
if ($aOrder === $bOrder) {
|
||||
return strcasecmp((string)($a['label'] ?? ''), (string)($b['label'] ?? ''));
|
||||
}
|
||||
return $aOrder <=> $bOrder;
|
||||
});
|
||||
return $items;
|
||||
}
|
||||
|
||||
public static function register(Router $router): void
|
||||
{
|
||||
foreach (self::enabled() as $plugin) {
|
||||
$entry = (string)($plugin['entry'] ?? 'plugin.php');
|
||||
$entryPath = rtrim((string)($plugin['path'] ?? ''), '/') . '/' . $entry;
|
||||
if (!is_file($entryPath)) {
|
||||
continue;
|
||||
}
|
||||
$handler = require $entryPath;
|
||||
if (is_callable($handler)) {
|
||||
$handler($router);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static function toggle(string $slug, bool $enabled): void
|
||||
{
|
||||
$db = Database::get();
|
||||
if (!$db instanceof PDO) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
$stmt = $db->prepare("UPDATE ac_plugins SET is_enabled = :enabled, updated_at = NOW() WHERE slug = :slug");
|
||||
$stmt->execute([
|
||||
':enabled' => $enabled ? 1 : 0,
|
||||
':slug' => $slug,
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
}
|
||||
self::sync();
|
||||
if (!$enabled) {
|
||||
$plugin = null;
|
||||
foreach (self::$plugins as $item) {
|
||||
if (($item['slug'] ?? '') === $slug) {
|
||||
$plugin = $item;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($plugin && !empty($plugin['pages'])) {
|
||||
self::removeNavLinks($db, (array)$plugin['pages']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static function sync(): void
|
||||
{
|
||||
$filesystem = self::scanFilesystem();
|
||||
$db = Database::get();
|
||||
$dbRows = [];
|
||||
|
||||
if ($db instanceof PDO) {
|
||||
try {
|
||||
$db->exec("
|
||||
CREATE TABLE IF NOT EXISTS ac_plugins (
|
||||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
slug VARCHAR(120) NOT NULL UNIQUE,
|
||||
name VARCHAR(200) NOT NULL,
|
||||
version VARCHAR(50) NOT NULL DEFAULT '0.0.0',
|
||||
is_enabled TINYINT(1) NOT NULL DEFAULT 0,
|
||||
installed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
");
|
||||
$stmt = $db->query("SELECT slug, is_enabled FROM ac_plugins");
|
||||
$dbRows = $stmt ? $stmt->fetchAll(PDO::FETCH_ASSOC) : [];
|
||||
} catch (Throwable $e) {
|
||||
$dbRows = [];
|
||||
}
|
||||
}
|
||||
|
||||
$dbMap = [];
|
||||
foreach ($dbRows as $row) {
|
||||
$dbMap[(string)$row['slug']] = (int)$row['is_enabled'];
|
||||
}
|
||||
|
||||
foreach ($filesystem as $slug => $plugin) {
|
||||
$plugin['is_enabled'] = (bool)($dbMap[$slug] ?? ($plugin['default_enabled'] ?? false));
|
||||
$filesystem[$slug] = $plugin;
|
||||
if ($db instanceof PDO && !isset($dbMap[$slug])) {
|
||||
try {
|
||||
$stmt = $db->prepare("
|
||||
INSERT INTO ac_plugins (slug, name, version, is_enabled)
|
||||
VALUES (:slug, :name, :version, :enabled)
|
||||
");
|
||||
$stmt->execute([
|
||||
':slug' => $slug,
|
||||
':name' => (string)($plugin['name'] ?? $slug),
|
||||
':version' => (string)($plugin['version'] ?? '0.0.0'),
|
||||
':enabled' => $plugin['is_enabled'] ? 1 : 0,
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
}
|
||||
}
|
||||
if ($db instanceof PDO && $plugin['is_enabled']) {
|
||||
$thisPages = $plugin['pages'] ?? [];
|
||||
if (is_array($thisPages) && $thisPages) {
|
||||
self::ensurePages($db, $thisPages);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self::$plugins = array_values($filesystem);
|
||||
}
|
||||
|
||||
private static function scanFilesystem(): array
|
||||
{
|
||||
if (self::$path === '' || !is_dir(self::$path)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$plugins = [];
|
||||
foreach (glob(self::$path . '/*/plugin.json') as $manifestPath) {
|
||||
$dir = dirname($manifestPath);
|
||||
$slug = basename($dir);
|
||||
$raw = file_get_contents($manifestPath);
|
||||
$decoded = json_decode($raw ?: '', true);
|
||||
if (!is_array($decoded)) {
|
||||
$decoded = [];
|
||||
}
|
||||
|
||||
$plugins[$slug] = [
|
||||
'slug' => $slug,
|
||||
'name' => (string)($decoded['name'] ?? $slug),
|
||||
'version' => (string)($decoded['version'] ?? '0.0.0'),
|
||||
'description' => (string)($decoded['description'] ?? ''),
|
||||
'author' => (string)($decoded['author'] ?? ''),
|
||||
'admin_nav' => is_array($decoded['admin_nav'] ?? null) ? $decoded['admin_nav'] : null,
|
||||
'pages' => is_array($decoded['pages'] ?? null) ? $decoded['pages'] : [],
|
||||
'entry' => (string)($decoded['entry'] ?? 'plugin.php'),
|
||||
'default_enabled' => (bool)($decoded['default_enabled'] ?? false),
|
||||
'path' => $dir,
|
||||
];
|
||||
}
|
||||
|
||||
return $plugins;
|
||||
}
|
||||
|
||||
private static function ensurePages(PDO $db, array $pages): void
|
||||
{
|
||||
foreach ($pages as $page) {
|
||||
if (!is_array($page)) {
|
||||
continue;
|
||||
}
|
||||
$slug = trim((string)($page['slug'] ?? ''));
|
||||
$title = trim((string)($page['title'] ?? ''));
|
||||
$content = (string)($page['content_html'] ?? '');
|
||||
if ($slug === '' || $title === '') {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
$stmt = $db->prepare("SELECT id FROM ac_pages WHERE slug = :slug LIMIT 1");
|
||||
$stmt->execute([':slug' => $slug]);
|
||||
if ($stmt->fetch()) {
|
||||
continue;
|
||||
}
|
||||
$insert = $db->prepare("
|
||||
INSERT INTO ac_pages (title, slug, content_html, is_published, is_home, is_blog_index)
|
||||
VALUES (:title, :slug, :content_html, 1, 0, 0)
|
||||
");
|
||||
$insert->execute([
|
||||
':title' => $title,
|
||||
':slug' => $slug,
|
||||
':content_html' => $content,
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static function removeNavLinks(PDO $db, array $pages): void
|
||||
{
|
||||
foreach ($pages as $page) {
|
||||
if (!is_array($page)) {
|
||||
continue;
|
||||
}
|
||||
$slug = trim((string)($page['slug'] ?? ''));
|
||||
if ($slug === '') {
|
||||
continue;
|
||||
}
|
||||
$url = '/' . ltrim($slug, '/');
|
||||
try {
|
||||
$stmt = $db->prepare("DELETE FROM ac_nav_links WHERE url = :url");
|
||||
$stmt->execute([':url' => $url]);
|
||||
} catch (Throwable $e) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
80
core/services/RateLimiter.php
Normal file
80
core/services/RateLimiter.php
Normal file
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Services;
|
||||
|
||||
use PDO;
|
||||
use Throwable;
|
||||
|
||||
class RateLimiter
|
||||
{
|
||||
private static bool $tableEnsured = false;
|
||||
|
||||
public static function tooMany(string $action, string $subjectKey, int $limit, int $windowSeconds): bool
|
||||
{
|
||||
if ($limit < 1 || $windowSeconds < 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$db = Database::get();
|
||||
if (!($db instanceof PDO)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
self::ensureTable($db);
|
||||
|
||||
try {
|
||||
$cutoff = date('Y-m-d H:i:s', time() - $windowSeconds);
|
||||
$countStmt = $db->prepare("
|
||||
SELECT COUNT(*) AS c
|
||||
FROM ac_rate_limits
|
||||
WHERE action_name = :action_name
|
||||
AND subject_key = :subject_key
|
||||
AND created_at >= :cutoff
|
||||
");
|
||||
$countStmt->execute([
|
||||
':action_name' => $action,
|
||||
':subject_key' => $subjectKey,
|
||||
':cutoff' => $cutoff,
|
||||
]);
|
||||
$count = (int)(($countStmt->fetch(PDO::FETCH_ASSOC)['c'] ?? 0));
|
||||
if ($count >= $limit) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$insertStmt = $db->prepare("
|
||||
INSERT INTO ac_rate_limits (action_name, subject_key, created_at)
|
||||
VALUES (:action_name, :subject_key, NOW())
|
||||
");
|
||||
$insertStmt->execute([
|
||||
':action_name' => $action,
|
||||
':subject_key' => $subjectKey,
|
||||
]);
|
||||
|
||||
$db->exec("DELETE FROM ac_rate_limits WHERE created_at < (NOW() - INTERVAL 2 DAY)");
|
||||
} catch (Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static function ensureTable(PDO $db): void
|
||||
{
|
||||
if (self::$tableEnsured) {
|
||||
return;
|
||||
}
|
||||
|
||||
$db->exec("
|
||||
CREATE TABLE IF NOT EXISTS ac_rate_limits (
|
||||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
action_name VARCHAR(80) NOT NULL,
|
||||
subject_key VARCHAR(191) NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
KEY idx_rate_limits_lookup (action_name, subject_key, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
");
|
||||
|
||||
self::$tableEnsured = true;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,5 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'version' => '1.5.0',
|
||||
'version' => '1.5.1',
|
||||
];
|
||||
|
||||
|
||||
@@ -1,31 +1,55 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Views;
|
||||
|
||||
class View
|
||||
{
|
||||
private string $basePath;
|
||||
|
||||
public function __construct(string $basePath = '')
|
||||
{
|
||||
$this->basePath = $basePath !== '' ? rtrim($basePath, '/') : __DIR__ . '/../../views';
|
||||
}
|
||||
|
||||
public function render(string $template, array $vars = []): string
|
||||
{
|
||||
$path = $this->basePath !== '' ? $this->basePath . '/' . ltrim($template, '/') : $template;
|
||||
if (!is_file($path)) {
|
||||
error_log('AC View missing: ' . $path);
|
||||
return '';
|
||||
}
|
||||
|
||||
if ($vars) {
|
||||
extract($vars, EXTR_SKIP);
|
||||
}
|
||||
|
||||
ob_start();
|
||||
require $path;
|
||||
return ob_get_clean() ?: '';
|
||||
}
|
||||
}
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Views;
|
||||
|
||||
use Core\Services\Csrf;
|
||||
|
||||
class View
|
||||
{
|
||||
private string $basePath;
|
||||
|
||||
public function __construct(string $basePath = '')
|
||||
{
|
||||
$this->basePath = $basePath !== '' ? rtrim($basePath, '/') : __DIR__ . '/../../views';
|
||||
}
|
||||
|
||||
public function render(string $template, array $vars = []): string
|
||||
{
|
||||
$path = $this->basePath !== '' ? $this->basePath . '/' . ltrim($template, '/') : $template;
|
||||
if (!is_file($path)) {
|
||||
error_log('AC View missing: ' . $path);
|
||||
return '';
|
||||
}
|
||||
|
||||
if ($vars) {
|
||||
extract($vars, EXTR_SKIP);
|
||||
}
|
||||
|
||||
ob_start();
|
||||
require $path;
|
||||
$html = ob_get_clean() ?: '';
|
||||
return $this->injectCsrfTokens($html);
|
||||
}
|
||||
|
||||
private function injectCsrfTokens(string $html): string
|
||||
{
|
||||
if ($html === '' || stripos($html, '<form') === false) {
|
||||
return $html;
|
||||
}
|
||||
|
||||
$tokenField = '<input type="hidden" name="csrf_token" value="' . htmlspecialchars(Csrf::token(), ENT_QUOTES, 'UTF-8') . '">';
|
||||
|
||||
return (string)preg_replace_callback(
|
||||
'~<form\b[^>]*>~i',
|
||||
static function (array $matches) use ($tokenField): string {
|
||||
$tag = $matches[0];
|
||||
if (!preg_match('~\bmethod\s*=\s*([\"\']?)post\1~i', $tag)) {
|
||||
return $tag;
|
||||
}
|
||||
return $tag . $tokenField;
|
||||
},
|
||||
$html
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user