Files
AudioCore/core/services/ApiLayer.php
AudioCore Bot 9deabe1ec9 Release v1.5.1
2026-04-01 14:12:17 +00:00

561 lines
20 KiB
PHP

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