Release v1.5.1

This commit is contained in:
AudioCore Bot
2026-04-01 14:12:17 +00:00
parent dc53051358
commit 9deabe1ec9
50 changed files with 10775 additions and 5637 deletions

View File

@@ -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 . '">';
}
}

View File

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

View File

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

View File

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

View File

@@ -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) {
}
}
}
}

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

View File

@@ -2,6 +2,5 @@
declare(strict_types=1);
return [
'version' => '1.5.0',
'version' => '1.5.1',
];

View File

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