view = new View(__DIR__ . '/views'); } public function adminIndex(): Response { if (!Auth::check()) { return new Response('', 302, ['Location' => '/admin/login']); } if (!Auth::hasRole(['admin'])) { return new Response('', 302, ['Location' => '/admin']); } $db = Database::get(); $clients = []; $createdKey = (string)($_SESSION['ac_api_created_key'] ?? ''); unset($_SESSION['ac_api_created_key']); if ($db instanceof PDO) { ApiLayer::ensureSchema($db); try { $stmt = $db->query(" SELECT id, name, api_key_prefix, webhook_url, is_active, last_used_at, last_used_ip, created_at FROM ac_api_clients ORDER BY created_at DESC, id DESC "); $clients = $stmt ? ($stmt->fetchAll(PDO::FETCH_ASSOC) ?: []) : []; } catch (Throwable $e) { $clients = []; } } return new Response($this->view->render('admin/index.php', [ 'title' => 'API', 'clients' => $clients, 'created_key' => $createdKey, 'status' => trim((string)($_GET['status'] ?? '')), 'message' => trim((string)($_GET['message'] ?? '')), 'base_url' => $this->baseUrl(), ])); } public function adminCreateClient(): Response { if ($guard = $this->guardAdmin()) { return $guard; } $name = trim((string)($_POST['name'] ?? '')); $webhookUrl = trim((string)($_POST['webhook_url'] ?? '')); if ($name === '') { return $this->adminRedirect('error', 'Client name is required.'); } $db = Database::get(); if (!($db instanceof PDO)) { return $this->adminRedirect('error', 'Database unavailable.'); } try { $plainKey = ApiLayer::issueClient($db, $name, $webhookUrl); $_SESSION['ac_api_created_key'] = $plainKey; return $this->adminRedirect('ok', 'API client created. Copy the key now.'); } catch (Throwable $e) { return $this->adminRedirect('error', 'Unable to create API client.'); } } public function adminToggleClient(): Response { if ($guard = $this->guardAdmin()) { return $guard; } $id = (int)($_POST['id'] ?? 0); if ($id <= 0) { return $this->adminRedirect('error', 'Invalid client.'); } $db = Database::get(); if (!($db instanceof PDO)) { return $this->adminRedirect('error', 'Database unavailable.'); } try { $stmt = $db->prepare(" UPDATE ac_api_clients SET is_active = CASE WHEN is_active = 1 THEN 0 ELSE 1 END WHERE id = :id "); $stmt->execute([':id' => $id]); return $this->adminRedirect('ok', 'API client updated.'); } catch (Throwable $e) { return $this->adminRedirect('error', 'Unable to update API client.'); } } public function adminDeleteClient(): Response { if ($guard = $this->guardAdmin()) { return $guard; } $id = (int)($_POST['id'] ?? 0); if ($id <= 0) { return $this->adminRedirect('error', 'Invalid client.'); } $db = Database::get(); if (!($db instanceof PDO)) { return $this->adminRedirect('error', 'Database unavailable.'); } try { $stmt = $db->prepare("DELETE FROM ac_api_clients WHERE id = :id LIMIT 1"); $stmt->execute([':id' => $id]); return $this->adminRedirect('ok', 'API client deleted.'); } catch (Throwable $e) { return $this->adminRedirect('error', 'Unable to delete API client.'); } } public function authVerify(): Response { $client = $this->requireClient(); if ($client instanceof Response) { return $client; } return $this->json([ 'ok' => true, 'client' => [ 'id' => (int)($client['id'] ?? 0), 'name' => (string)($client['name'] ?? ''), 'prefix' => (string)($client['api_key_prefix'] ?? ''), ], ]); } public function artistSales(): Response { $client = $this->requireClient(); if ($client instanceof Response) { return $client; } $artistId = (int)($_GET['artist_id'] ?? 0); if ($artistId <= 0) { return $this->json(['ok' => false, 'error' => 'artist_id is required.'], 422); } $db = Database::get(); if (!($db instanceof PDO)) { return $this->json(['ok' => false, 'error' => 'Database unavailable.'], 500); } ApiLayer::ensureSchema($db); try { $stmt = $db->prepare(" SELECT a.id, a.order_id, o.order_no, a.order_item_id, a.source_item_type, a.source_item_id, a.release_id, a.track_id, a.title_snapshot, a.qty, a.gross_amount, a.currency_snapshot, a.created_at, o.email AS customer_email, o.payment_provider, o.payment_ref FROM ac_store_order_item_allocations a JOIN ac_store_orders o ON o.id = a.order_id WHERE a.artist_id = :artist_id AND o.status = 'paid' ORDER BY a.id DESC LIMIT 500 "); $stmt->execute([':artist_id' => $artistId]); $rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; return $this->json([ 'ok' => true, 'artist_id' => $artistId, 'count' => count($rows), 'rows' => $rows, ]); } catch (Throwable $e) { return $this->json(['ok' => false, 'error' => 'Unable to load artist sales.'], 500); } } public function salesSince(): Response { $client = $this->requireClient(); if ($client instanceof Response) { return $client; } $artistId = (int)($_GET['artist_id'] ?? 0); $afterId = (int)($_GET['after_id'] ?? 0); $since = trim((string)($_GET['since'] ?? '')); if ($artistId <= 0) { return $this->json(['ok' => false, 'error' => 'artist_id is required.'], 422); } if ($afterId <= 0 && $since === '') { return $this->json(['ok' => false, 'error' => 'after_id or since is required.'], 422); } $db = Database::get(); if (!($db instanceof PDO)) { return $this->json(['ok' => false, 'error' => 'Database unavailable.'], 500); } ApiLayer::ensureSchema($db); try { $where = [ 'a.artist_id = :artist_id', "o.status = 'paid'", ]; $params = [':artist_id' => $artistId]; if ($afterId > 0) { $where[] = 'a.id > :after_id'; $params[':after_id'] = $afterId; } if ($since !== '') { $where[] = 'a.created_at >= :since'; $params[':since'] = $since; } $stmt = $db->prepare(" SELECT a.id, a.order_id, o.order_no, a.order_item_id, a.source_item_type, a.source_item_id, a.release_id, a.track_id, a.title_snapshot, a.qty, a.gross_amount, a.currency_snapshot, a.created_at FROM ac_store_order_item_allocations a JOIN ac_store_orders o ON o.id = a.order_id WHERE " . implode(' AND ', $where) . " ORDER BY a.id ASC LIMIT 500 "); $stmt->execute($params); $rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; return $this->json([ 'ok' => true, 'artist_id' => $artistId, 'count' => count($rows), 'rows' => $rows, ]); } catch (Throwable $e) { return $this->json(['ok' => false, 'error' => 'Unable to load incremental sales.'], 500); } } public function artistTracks(): Response { $client = $this->requireClient(); if ($client instanceof Response) { return $client; } $artistId = (int)($_GET['artist_id'] ?? 0); if ($artistId <= 0) { return $this->json(['ok' => false, 'error' => 'artist_id is required.'], 422); } $db = Database::get(); if (!($db instanceof PDO)) { return $this->json(['ok' => false, 'error' => 'Database unavailable.'], 500); } ApiLayer::ensureSchema($db); try { $stmt = $db->prepare(" SELECT t.id, t.release_id, r.title AS release_title, r.slug AS release_slug, r.catalog_no, r.release_date, t.track_no, t.title, t.mix_name, t.duration, t.bpm, t.key_signature, t.sample_url, sp.is_enabled AS store_enabled, sp.track_price, sp.currency FROM ac_release_tracks t JOIN ac_releases r ON r.id = t.release_id LEFT JOIN ac_store_track_products sp ON sp.release_track_id = t.id WHERE r.artist_id = :artist_id ORDER BY r.release_date DESC, r.id DESC, t.track_no ASC, t.id ASC "); $stmt->execute([':artist_id' => $artistId]); $rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; return $this->json([ 'ok' => true, 'artist_id' => $artistId, 'count' => count($rows), 'tracks' => $rows, ]); } catch (Throwable $e) { return $this->json(['ok' => false, 'error' => 'Unable to load artist tracks.'], 500); } } public function submitRelease(): Response { $client = $this->requireClient(); if ($client instanceof Response) { return $client; } $db = Database::get(); if (!($db instanceof PDO)) { return $this->json(['ok' => false, 'error' => 'Database unavailable.'], 500); } ApiLayer::ensureSchema($db); $data = $this->requestData(); $title = trim((string)($data['title'] ?? '')); if ($title === '') { return $this->json(['ok' => false, 'error' => 'title is required.'], 422); } $artistId = (int)($data['artist_id'] ?? 0); $artistName = trim((string)($data['artist_name'] ?? '')); if ($artistId > 0) { $artistName = $this->artistNameById($db, $artistId) ?: $artistName; } $slug = $this->slugify((string)($data['slug'] ?? $title)); try { $stmt = $db->prepare(" INSERT INTO ac_releases (title, slug, artist_id, artist_name, description, credits, catalog_no, release_date, cover_url, sample_url, is_published) VALUES (:title, :slug, :artist_id, :artist_name, :description, :credits, :catalog_no, :release_date, :cover_url, :sample_url, :is_published) "); $stmt->execute([ ':title' => $title, ':slug' => $slug, ':artist_id' => $artistId > 0 ? $artistId : null, ':artist_name' => $artistName !== '' ? $artistName : null, ':description' => $this->nullableText($data['description'] ?? null), ':credits' => $this->nullableText($data['credits'] ?? null), ':catalog_no' => $this->nullableText($data['catalog_no'] ?? null), ':release_date' => $this->nullableText($data['release_date'] ?? null), ':cover_url' => $this->nullableText($data['cover_url'] ?? null), ':sample_url' => $this->nullableText($data['sample_url'] ?? null), ':is_published' => !empty($data['is_published']) ? 1 : 0, ]); $releaseId = (int)$db->lastInsertId(); return $this->json([ 'ok' => true, 'release_id' => $releaseId, 'slug' => $slug, ], 201); } catch (Throwable $e) { return $this->json(['ok' => false, 'error' => 'Unable to create release.'], 500); } } public function submitTracks(): Response { $client = $this->requireClient(); if ($client instanceof Response) { return $client; } $db = Database::get(); if (!($db instanceof PDO)) { return $this->json(['ok' => false, 'error' => 'Database unavailable.'], 500); } ApiLayer::ensureSchema($db); $data = $this->requestData(); $releaseId = (int)($data['release_id'] ?? 0); $tracks = is_array($data['tracks'] ?? null) ? $data['tracks'] : []; if ($releaseId <= 0 || !$tracks) { return $this->json(['ok' => false, 'error' => 'release_id and tracks are required.'], 422); } $created = []; try { foreach ($tracks as $track) { if (!is_array($track)) { continue; } $trackId = (int)($track['id'] ?? 0); $title = trim((string)($track['title'] ?? '')); if ($title === '') { continue; } if ($trackId > 0) { $stmt = $db->prepare(" UPDATE ac_release_tracks SET track_no = :track_no, title = :title, mix_name = :mix_name, duration = :duration, bpm = :bpm, key_signature = :key_signature, sample_url = :sample_url WHERE id = :id AND release_id = :release_id "); $stmt->execute([ ':track_no' => isset($track['track_no']) ? (int)$track['track_no'] : null, ':title' => $title, ':mix_name' => $this->nullableText($track['mix_name'] ?? null), ':duration' => $this->nullableText($track['duration'] ?? null), ':bpm' => isset($track['bpm']) && $track['bpm'] !== '' ? (int)$track['bpm'] : null, ':key_signature' => $this->nullableText($track['key_signature'] ?? null), ':sample_url' => $this->nullableText($track['sample_url'] ?? null), ':id' => $trackId, ':release_id' => $releaseId, ]); $created[] = ['id' => $trackId, 'updated' => true]; } else { $stmt = $db->prepare(" INSERT INTO ac_release_tracks (release_id, track_no, title, mix_name, duration, bpm, key_signature, sample_url) VALUES (:release_id, :track_no, :title, :mix_name, :duration, :bpm, :key_signature, :sample_url) "); $stmt->execute([ ':release_id' => $releaseId, ':track_no' => isset($track['track_no']) ? (int)$track['track_no'] : null, ':title' => $title, ':mix_name' => $this->nullableText($track['mix_name'] ?? null), ':duration' => $this->nullableText($track['duration'] ?? null), ':bpm' => isset($track['bpm']) && $track['bpm'] !== '' ? (int)$track['bpm'] : null, ':key_signature' => $this->nullableText($track['key_signature'] ?? null), ':sample_url' => $this->nullableText($track['sample_url'] ?? null), ]); $created[] = ['id' => (int)$db->lastInsertId(), 'created' => true]; } } return $this->json([ 'ok' => true, 'release_id' => $releaseId, 'tracks' => $created, ], 201); } catch (Throwable $e) { return $this->json(['ok' => false, 'error' => 'Unable to submit tracks.'], 500); } } public function updateRelease(): Response { $client = $this->requireClient(); if ($client instanceof Response) { return $client; } $db = Database::get(); if (!($db instanceof PDO)) { return $this->json(['ok' => false, 'error' => 'Database unavailable.'], 500); } ApiLayer::ensureSchema($db); $data = $this->requestData(); $releaseId = (int)($data['release_id'] ?? 0); if ($releaseId <= 0) { return $this->json(['ok' => false, 'error' => 'release_id is required.'], 422); } $fields = []; $params = [':id' => $releaseId]; $map = [ 'title' => 'title', 'description' => 'description', 'credits' => 'credits', 'catalog_no' => 'catalog_no', 'release_date' => 'release_date', 'cover_url' => 'cover_url', 'sample_url' => 'sample_url', 'is_published' => 'is_published', ]; foreach ($map as $input => $column) { if (!array_key_exists($input, $data)) { continue; } $fields[] = "{$column} = :{$input}"; $params[":{$input}"] = $input === 'is_published' ? (!empty($data[$input]) ? 1 : 0) : $this->nullableText($data[$input]); } if (array_key_exists('slug', $data)) { $fields[] = "slug = :slug"; $params[':slug'] = $this->slugify((string)$data['slug']); } if (array_key_exists('artist_id', $data) || array_key_exists('artist_name', $data)) { $artistId = (int)($data['artist_id'] ?? 0); $artistName = trim((string)($data['artist_name'] ?? '')); if ($artistId > 0) { $artistName = $this->artistNameById($db, $artistId) ?: $artistName; } $fields[] = "artist_id = :artist_id"; $fields[] = "artist_name = :artist_name"; $params[':artist_id'] = $artistId > 0 ? $artistId : null; $params[':artist_name'] = $artistName !== '' ? $artistName : null; } if (!$fields) { return $this->json(['ok' => false, 'error' => 'No fields to update.'], 422); } try { $stmt = $db->prepare("UPDATE ac_releases SET " . implode(', ', $fields) . " WHERE id = :id"); $stmt->execute($params); return $this->json(['ok' => true, 'release_id' => $releaseId]); } catch (Throwable $e) { return $this->json(['ok' => false, 'error' => 'Unable to update release.'], 500); } } public function updateTrack(): Response { $client = $this->requireClient(); if ($client instanceof Response) { return $client; } $db = Database::get(); if (!($db instanceof PDO)) { return $this->json(['ok' => false, 'error' => 'Database unavailable.'], 500); } $data = $this->requestData(); $trackId = (int)($data['track_id'] ?? 0); if ($trackId <= 0) { return $this->json(['ok' => false, 'error' => 'track_id is required.'], 422); } $fields = []; $params = [':id' => $trackId]; $map = [ 'track_no' => 'track_no', 'title' => 'title', 'mix_name' => 'mix_name', 'duration' => 'duration', 'bpm' => 'bpm', 'key_signature' => 'key_signature', 'sample_url' => 'sample_url', ]; foreach ($map as $input => $column) { if (!array_key_exists($input, $data)) { continue; } $fields[] = "{$column} = :{$input}"; if ($input === 'bpm' || $input === 'track_no') { $params[":{$input}"] = $data[$input] !== '' ? (int)$data[$input] : null; } else { $params[":{$input}"] = $this->nullableText($data[$input]); } } if (!$fields) { return $this->json(['ok' => false, 'error' => 'No fields to update.'], 422); } try { $stmt = $db->prepare("UPDATE ac_release_tracks SET " . implode(', ', $fields) . " WHERE id = :id"); $stmt->execute($params); return $this->json(['ok' => true, 'track_id' => $trackId]); } catch (Throwable $e) { return $this->json(['ok' => false, 'error' => 'Unable to update track.'], 500); } } public function orderItemData(): Response { $client = $this->requireClient(); if ($client instanceof Response) { return $client; } $db = Database::get(); if (!($db instanceof PDO)) { return $this->json(['ok' => false, 'error' => 'Database unavailable.'], 500); } ApiLayer::ensureSchema($db); $artistId = (int)($_GET['artist_id'] ?? 0); $orderId = (int)($_GET['order_id'] ?? 0); $afterId = (int)($_GET['after_id'] ?? 0); $since = trim((string)($_GET['since'] ?? '')); try { $where = ['1=1']; $params = []; if ($artistId > 0) { $where[] = 'a.artist_id = :artist_id'; $params[':artist_id'] = $artistId; } if ($orderId > 0) { $where[] = 'a.order_id = :order_id'; $params[':order_id'] = $orderId; } if ($afterId > 0) { $where[] = 'a.id > :after_id'; $params[':after_id'] = $afterId; } if ($since !== '') { $where[] = 'a.created_at >= :since'; $params[':since'] = $since; } $stmt = $db->prepare(" SELECT a.id, a.order_id, o.order_no, o.status AS order_status, o.email AS customer_email, a.order_item_id, a.artist_id, a.release_id, a.track_id, a.source_item_type, a.source_item_id, a.title_snapshot, a.qty, a.gross_amount, a.currency_snapshot, a.created_at FROM ac_store_order_item_allocations a JOIN ac_store_orders o ON o.id = a.order_id WHERE " . implode(' AND ', $where) . " ORDER BY a.id DESC LIMIT 1000 "); $stmt->execute($params); $rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; return $this->json([ 'ok' => true, 'count' => count($rows), 'rows' => $rows, ]); } catch (Throwable $e) { return $this->json(['ok' => false, 'error' => 'Unable to load order item data.'], 500); } } private function requestData(): array { $contentType = strtolower(trim((string)($_SERVER['CONTENT_TYPE'] ?? ''))); if (str_contains($contentType, 'application/json')) { $raw = file_get_contents('php://input'); $decoded = json_decode(is_string($raw) ? $raw : '', true); return is_array($decoded) ? $decoded : []; } return is_array($_POST) ? $_POST : []; } private function requireClient(): array|Response { $db = Database::get(); if (!($db instanceof PDO)) { return $this->json(['ok' => false, 'error' => 'Database unavailable.'], 500); } $client = ApiLayer::verifyRequest($db); if (!$client) { return $this->json(['ok' => false, 'error' => 'Invalid API key.'], 401); } return $client; } private function json(array $data, int $status = 200): Response { $json = json_encode($data, JSON_UNESCAPED_SLASHES); return new Response( is_string($json) ? $json : '{"ok":false,"error":"json_encode_failed"}', $status, ['Content-Type' => 'application/json; charset=utf-8'] ); } private function slugify(string $value): string { $value = strtolower(trim($value)); $value = preg_replace('~[^a-z0-9]+~', '-', $value) ?? $value; $value = trim($value, '-'); return $value !== '' ? $value : 'release'; } private function nullableText(mixed $value): ?string { $text = trim((string)$value); return $text !== '' ? $text : null; } private function artistNameById(PDO $db, int $artistId): string { if ($artistId <= 0) { return ''; } try { $stmt = $db->prepare("SELECT name FROM ac_artists WHERE id = :id LIMIT 1"); $stmt->execute([':id' => $artistId]); return trim((string)($stmt->fetchColumn() ?: '')); } catch (Throwable $e) { return ''; } } private function adminRedirect(string $status, string $message): Response { return new Response('', 302, [ 'Location' => '/admin/api?status=' . rawurlencode($status) . '&message=' . rawurlencode($message), ]); } private function baseUrl(): string { $https = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') || ((string)($_SERVER['SERVER_PORT'] ?? '') === '443'); $scheme = $https ? 'https' : 'http'; $host = trim((string)($_SERVER['HTTP_HOST'] ?? '')); return $host !== '' ? ($scheme . '://' . $host) : ''; } private function guardAdmin(): ?Response { if (!Auth::check()) { return new Response('', 302, ['Location' => '/admin/login']); } if (!Auth::hasRole(['admin'])) { return new Response('', 302, ['Location' => '/admin']); } return null; } }