Release v1.5.1
This commit is contained in:
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user