Release v1.5.1
This commit is contained in:
@@ -42,6 +42,9 @@ class AdminController
|
||||
|
||||
public function installer(): Response
|
||||
{
|
||||
if ($this->appInstalled()) {
|
||||
return new Response('', 302, ['Location' => Auth::check() ? '/admin' : '/admin/login']);
|
||||
}
|
||||
$installer = $_SESSION['installer'] ?? [];
|
||||
$step = !empty($installer['core_ready']) ? 2 : 1;
|
||||
$values = is_array($installer['values'] ?? null) ? $installer['values'] : [];
|
||||
@@ -61,6 +64,9 @@ class AdminController
|
||||
|
||||
public function install(): Response
|
||||
{
|
||||
if ($this->appInstalled()) {
|
||||
return new Response('', 302, ['Location' => Auth::check() ? '/admin' : '/admin/login']);
|
||||
}
|
||||
$action = trim((string)($_POST['installer_action'] ?? 'setup_core'));
|
||||
if ($action === 'setup_core') {
|
||||
return $this->installSetupCore();
|
||||
@@ -307,9 +313,9 @@ class AdminController
|
||||
'title' => 'Settings',
|
||||
'status' => $status,
|
||||
'status_message' => $statusMessage,
|
||||
'footer_text' => Settings::get('footer_text', 'AudioCore V1.5'),
|
||||
'footer_text' => Settings::get('footer_text', 'AudioCore V1.5.1'),
|
||||
'footer_links' => $this->parseFooterLinks(Settings::get('footer_links_json', '[]')),
|
||||
'site_header_title' => Settings::get('site_header_title', 'AudioCore V1.5'),
|
||||
'site_header_title' => Settings::get('site_header_title', 'AudioCore V1.5.1'),
|
||||
'site_header_tagline' => Settings::get('site_header_tagline', 'Core CMS for DJs & Producers'),
|
||||
'site_header_badge_text' => Settings::get('site_header_badge_text', 'Independent catalog'),
|
||||
'site_header_brand_mode' => Settings::get('site_header_brand_mode', 'default'),
|
||||
@@ -327,6 +333,7 @@ class AdminController
|
||||
'site_maintenance_button_label' => Settings::get('site_maintenance_button_label', ''),
|
||||
'site_maintenance_button_url' => Settings::get('site_maintenance_button_url', ''),
|
||||
'site_maintenance_html' => Settings::get('site_maintenance_html', ''),
|
||||
'site_maintenance_access_password_enabled' => Settings::get('site_maintenance_access_password_hash', '') !== '' ? '1' : '0',
|
||||
'smtp_host' => Settings::get('smtp_host', ''),
|
||||
'smtp_port' => Settings::get('smtp_port', '587'),
|
||||
'smtp_user' => Settings::get('smtp_user', ''),
|
||||
@@ -336,7 +343,7 @@ class AdminController
|
||||
'smtp_from_name' => Settings::get('smtp_from_name', ''),
|
||||
'mailchimp_api_key' => Settings::get('mailchimp_api_key', ''),
|
||||
'mailchimp_list_id' => Settings::get('mailchimp_list_id', ''),
|
||||
'seo_title_suffix' => Settings::get('seo_title_suffix', Settings::get('site_title', 'AudioCore V1.5')),
|
||||
'seo_title_suffix' => Settings::get('seo_title_suffix', Settings::get('site_title', 'AudioCore V1.5.1')),
|
||||
'seo_meta_description' => Settings::get('seo_meta_description', ''),
|
||||
'seo_robots_index' => Settings::get('seo_robots_index', '1'),
|
||||
'seo_robots_follow' => Settings::get('seo_robots_follow', '1'),
|
||||
@@ -632,6 +639,8 @@ class AdminController
|
||||
$maintenanceButtonLabel = trim((string)($_POST['site_maintenance_button_label'] ?? ''));
|
||||
$maintenanceButtonUrl = trim((string)($_POST['site_maintenance_button_url'] ?? ''));
|
||||
$maintenanceHtml = trim((string)($_POST['site_maintenance_html'] ?? ''));
|
||||
$maintenanceAccessPassword = trim((string)($_POST['site_maintenance_access_password'] ?? ''));
|
||||
$maintenanceAccessPasswordClear = isset($_POST['site_maintenance_access_password_clear']);
|
||||
$smtpHost = trim((string)($_POST['smtp_host'] ?? ''));
|
||||
$smtpPort = trim((string)($_POST['smtp_port'] ?? ''));
|
||||
$smtpUser = trim((string)($_POST['smtp_user'] ?? ''));
|
||||
@@ -675,6 +684,11 @@ class AdminController
|
||||
Settings::set('site_maintenance_button_label', $maintenanceButtonLabel);
|
||||
Settings::set('site_maintenance_button_url', $maintenanceButtonUrl);
|
||||
Settings::set('site_maintenance_html', $maintenanceHtml);
|
||||
if ($maintenanceAccessPasswordClear) {
|
||||
Settings::set('site_maintenance_access_password_hash', '');
|
||||
} elseif ($maintenanceAccessPassword !== '') {
|
||||
Settings::set('site_maintenance_access_password_hash', password_hash($maintenanceAccessPassword, PASSWORD_DEFAULT));
|
||||
}
|
||||
Settings::set('smtp_host', $smtpHost);
|
||||
Settings::set('smtp_port', $smtpPort);
|
||||
Settings::set('smtp_user', $smtpUser);
|
||||
@@ -769,7 +783,7 @@ class AdminController
|
||||
}
|
||||
$settingsPath = $storageDir . '/settings.php';
|
||||
if (!is_file($settingsPath)) {
|
||||
$settingsSeed = "<?php\nreturn [\n 'site_title' => 'AudioCore V1.5',\n];\n";
|
||||
$settingsSeed = "<?php\nreturn [\n 'site_title' => 'AudioCore V1.5.1',\n];\n";
|
||||
@file_put_contents($settingsPath, $settingsSeed);
|
||||
}
|
||||
$configPath = $storageDir . '/db.php';
|
||||
@@ -925,7 +939,7 @@ class AdminController
|
||||
];
|
||||
|
||||
$subject = 'AudioCore installer SMTP test';
|
||||
$html = '<h2>SMTP test successful</h2><p>Your AudioCore V1.5 installer SMTP settings are valid.</p>'
|
||||
$html = '<h2>SMTP test successful</h2><p>Your AudioCore V1.5.1 installer SMTP settings are valid.</p>'
|
||||
. '<p><strong>Generated:</strong> ' . gmdate('Y-m-d H:i:s') . ' UTC</p>';
|
||||
$mail = Mailer::send($testEmail, $subject, $html, $smtpSettings);
|
||||
|
||||
@@ -1028,9 +1042,9 @@ class AdminController
|
||||
private function installerDefaultValues(): array
|
||||
{
|
||||
return [
|
||||
'site_title' => 'AudioCore V1.5',
|
||||
'site_title' => 'AudioCore V1.5.1',
|
||||
'site_tagline' => 'Core CMS for DJs & Producers',
|
||||
'seo_title_suffix' => 'AudioCore V1.5',
|
||||
'seo_title_suffix' => 'AudioCore V1.5.1',
|
||||
'seo_meta_description' => 'Independent catalog platform for artists, releases, store, and support.',
|
||||
'smtp_host' => '',
|
||||
'smtp_port' => '587',
|
||||
@@ -1038,7 +1052,7 @@ class AdminController
|
||||
'smtp_pass' => '',
|
||||
'smtp_encryption' => 'tls',
|
||||
'smtp_from_email' => '',
|
||||
'smtp_from_name' => 'AudioCore V1.5',
|
||||
'smtp_from_name' => 'AudioCore V1.5.1',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1664,6 +1678,24 @@ class AdminController
|
||||
return Database::get() instanceof PDO;
|
||||
}
|
||||
|
||||
private function appInstalled(): bool
|
||||
{
|
||||
$db = Database::get();
|
||||
if (!$db instanceof PDO) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->ensureCoreTables();
|
||||
|
||||
try {
|
||||
$adminUsers = (int)$db->query("SELECT COUNT(*) FROM ac_admin_users")->fetchColumn();
|
||||
$legacyAdmins = (int)$db->query("SELECT COUNT(*) FROM ac_admins")->fetchColumn();
|
||||
return ($adminUsers + $legacyAdmins) > 0;
|
||||
} catch (Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private function normalizeUrl(string $url): string
|
||||
{
|
||||
if (preg_match('~^(https?://|/|#|mailto:)~i', $url)) {
|
||||
|
||||
@@ -136,7 +136,7 @@ ob_start();
|
||||
|
||||
<section class="card ac-installer">
|
||||
<div class="badge">Setup</div>
|
||||
<h1 class="ac-installer-title">AudioCore V1.5 Installer</h1>
|
||||
<h1 class="ac-installer-title">AudioCore V1.5.1 Installer</h1>
|
||||
<p class="ac-installer-intro">Deploy a fresh instance with validated SMTP and baseline health checks.</p>
|
||||
|
||||
<div class="ac-installer-steps">
|
||||
@@ -201,7 +201,7 @@ ob_start();
|
||||
<div class="ac-installer-grid">
|
||||
<div>
|
||||
<label>Site Title *</label>
|
||||
<input class="input" name="site_title" value="<?= htmlspecialchars($val('site_title', 'AudioCore V1.5'), ENT_QUOTES, 'UTF-8') ?>">
|
||||
<input class="input" name="site_title" value="<?= htmlspecialchars($val('site_title', 'AudioCore V1.5.1'), ENT_QUOTES, 'UTF-8') ?>">
|
||||
</div>
|
||||
<div>
|
||||
<label>Site Tagline</label>
|
||||
@@ -209,7 +209,7 @@ ob_start();
|
||||
</div>
|
||||
<div>
|
||||
<label>SEO Title Suffix</label>
|
||||
<input class="input" name="seo_title_suffix" value="<?= htmlspecialchars($val('seo_title_suffix', 'AudioCore V1.5'), ENT_QUOTES, 'UTF-8') ?>">
|
||||
<input class="input" name="seo_title_suffix" value="<?= htmlspecialchars($val('seo_title_suffix', 'AudioCore V1.5.1'), ENT_QUOTES, 'UTF-8') ?>">
|
||||
</div>
|
||||
<div style="grid-column:1/-1;">
|
||||
<label>SEO Meta Description</label>
|
||||
@@ -244,7 +244,7 @@ ob_start();
|
||||
</div>
|
||||
<div>
|
||||
<label>SMTP From Name</label>
|
||||
<input class="input" name="smtp_from_name" value="<?= htmlspecialchars($val('smtp_from_name', 'AudioCore V1.5'), ENT_QUOTES, 'UTF-8') ?>">
|
||||
<input class="input" name="smtp_from_name" value="<?= htmlspecialchars($val('smtp_from_name', 'AudioCore V1.5.1'), ENT_QUOTES, 'UTF-8') ?>">
|
||||
</div>
|
||||
<div>
|
||||
<label>Test Recipient Email *</label>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -32,7 +32,7 @@ ob_start();
|
||||
<div class="badge" style="opacity:0.7;">Branding</div>
|
||||
<div style="margin-top:12px; display:grid; gap:12px;">
|
||||
<label class="label">Header Title</label>
|
||||
<input class="input" name="site_header_title" value="<?= htmlspecialchars($site_header_title ?? '', ENT_QUOTES, 'UTF-8') ?>" placeholder="AudioCore V1.5">
|
||||
<input class="input" name="site_header_title" value="<?= htmlspecialchars($site_header_title ?? '', ENT_QUOTES, 'UTF-8') ?>" placeholder="AudioCore V1.5.1">
|
||||
|
||||
<label class="label">Header Tagline</label>
|
||||
<input class="input" name="site_header_tagline" value="<?= htmlspecialchars($site_header_tagline ?? '', ENT_QUOTES, 'UTF-8') ?>" placeholder="Core CMS for DJs & Producers">
|
||||
@@ -120,7 +120,7 @@ ob_start();
|
||||
<div class="badge" style="opacity:0.7;">Footer</div>
|
||||
<div style="margin-top:12px; display:grid; gap:12px;">
|
||||
<label class="label">Footer Text</label>
|
||||
<input class="input" name="footer_text" value="<?= htmlspecialchars($footer_text ?? '', ENT_QUOTES, 'UTF-8') ?>" placeholder="AudioCore V1.5">
|
||||
<input class="input" name="footer_text" value="<?= htmlspecialchars($footer_text ?? '', ENT_QUOTES, 'UTF-8') ?>" placeholder="AudioCore V1.5.1">
|
||||
<div style="font-size:12px; color:var(--muted);">Shown in the site footer.</div>
|
||||
|
||||
<div class="label">Footer Links</div>
|
||||
@@ -158,6 +158,17 @@ ob_start();
|
||||
<input class="input" name="site_maintenance_button_url" value="<?= htmlspecialchars($site_maintenance_button_url ?? '', ENT_QUOTES, 'UTF-8') ?>" placeholder="/admin/login">
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:grid; gap:10px; padding:14px; border:1px solid rgba(255,255,255,.08); border-radius:16px; background:rgba(255,255,255,.02);">
|
||||
<div class="badge" style="opacity:.7;">Visitor Access Password</div>
|
||||
<div style="font-size:12px; color:var(--muted);">
|
||||
Set a password to let non-admin users unlock the site during maintenance mode. Leave blank to keep the current password.
|
||||
</div>
|
||||
<input class="input" type="password" name="site_maintenance_access_password" placeholder="<?= (($site_maintenance_access_password_enabled ?? '0') === '1') ? 'Password already set - enter a new one to replace it' : 'Set an access password' ?>">
|
||||
<label style="display:inline-flex; align-items:center; gap:8px; font-size:13px;">
|
||||
<input type="checkbox" name="site_maintenance_access_password_clear" value="1">
|
||||
Clear the current access password
|
||||
</label>
|
||||
</div>
|
||||
<label class="label">Custom HTML (optional, overrides title/message layout)</label>
|
||||
<textarea class="input" name="site_maintenance_html" rows="6" style="resize:vertical; font-family:'IBM Plex Mono', monospace;"><?= htmlspecialchars($site_maintenance_html ?? '', ENT_QUOTES, 'UTF-8') ?></textarea>
|
||||
</div>
|
||||
@@ -218,7 +229,7 @@ ob_start();
|
||||
<div class="badge" style="opacity:0.7;">Global SEO</div>
|
||||
<div style="margin-top:12px; display:grid; gap:12px;">
|
||||
<label class="label">Title Suffix</label>
|
||||
<input class="input" name="seo_title_suffix" value="<?= htmlspecialchars($seo_title_suffix ?? '', ENT_QUOTES, 'UTF-8') ?>" placeholder="AudioCore V1.5">
|
||||
<input class="input" name="seo_title_suffix" value="<?= htmlspecialchars($seo_title_suffix ?? '', ENT_QUOTES, 'UTF-8') ?>" placeholder="AudioCore V1.5.1">
|
||||
<label class="label">Default Meta Description</label>
|
||||
<textarea class="input" name="seo_meta_description" rows="3" style="resize:vertical;"><?= htmlspecialchars($seo_meta_description ?? '', ENT_QUOTES, 'UTF-8') ?></textarea>
|
||||
<label class="label">Open Graph Image URL</label>
|
||||
|
||||
760
modules/api/ApiController.php
Normal file
760
modules/api/ApiController.php
Normal file
@@ -0,0 +1,760 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\Api;
|
||||
|
||||
use Core\Http\Response;
|
||||
use Core\Services\ApiLayer;
|
||||
use Core\Services\Auth;
|
||||
use Core\Services\Database;
|
||||
use Core\Views\View;
|
||||
use PDO;
|
||||
use Throwable;
|
||||
|
||||
class ApiController
|
||||
{
|
||||
private View $view;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->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;
|
||||
}
|
||||
}
|
||||
26
modules/api/module.php
Normal file
26
modules/api/module.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use Core\Http\Router;
|
||||
use Modules\Api\ApiController;
|
||||
|
||||
require_once __DIR__ . '/ApiController.php';
|
||||
|
||||
return function (Router $router): void {
|
||||
$controller = new ApiController();
|
||||
|
||||
$router->get('/admin/api', [$controller, 'adminIndex']);
|
||||
$router->post('/admin/api/clients/create', [$controller, 'adminCreateClient']);
|
||||
$router->post('/admin/api/clients/toggle', [$controller, 'adminToggleClient']);
|
||||
$router->post('/admin/api/clients/delete', [$controller, 'adminDeleteClient']);
|
||||
|
||||
$router->get('/api/v1/auth/verify', [$controller, 'authVerify']);
|
||||
$router->get('/api/v1/sales', [$controller, 'artistSales']);
|
||||
$router->get('/api/v1/sales/since', [$controller, 'salesSince']);
|
||||
$router->get('/api/v1/tracks', [$controller, 'artistTracks']);
|
||||
$router->get('/api/v1/order-items', [$controller, 'orderItemData']);
|
||||
$router->post('/api/v1/releases', [$controller, 'submitRelease']);
|
||||
$router->post('/api/v1/tracks', [$controller, 'submitTracks']);
|
||||
$router->post('/api/v1/releases/update', [$controller, 'updateRelease']);
|
||||
$router->post('/api/v1/tracks/update', [$controller, 'updateTrack']);
|
||||
};
|
||||
189
modules/api/views/admin/index.php
Normal file
189
modules/api/views/admin/index.php
Normal file
@@ -0,0 +1,189 @@
|
||||
<?php
|
||||
$pageTitle = 'API';
|
||||
$clients = is_array($clients ?? null) ? $clients : [];
|
||||
$createdKey = (string)($created_key ?? '');
|
||||
$status = trim((string)($status ?? ''));
|
||||
$message = trim((string)($message ?? ''));
|
||||
$baseUrl = rtrim((string)($base_url ?? ''), '/');
|
||||
|
||||
$endpointRows = [
|
||||
[
|
||||
'title' => 'Verify key',
|
||||
'method' => 'GET',
|
||||
'path' => $baseUrl . '/api/v1/auth/verify',
|
||||
'note' => 'Quick auth check for AMS bootstrapping.',
|
||||
],
|
||||
[
|
||||
'title' => 'Artist sales',
|
||||
'method' => 'GET',
|
||||
'path' => $baseUrl . '/api/v1/sales?artist_id=123',
|
||||
'note' => 'Detailed paid sales rows for one artist.',
|
||||
],
|
||||
[
|
||||
'title' => 'Sales since sync',
|
||||
'method' => 'GET',
|
||||
'path' => $baseUrl . '/api/v1/sales/since?artist_id=123&after_id=500',
|
||||
'note' => 'Incremental sync using after_id or timestamp.',
|
||||
],
|
||||
[
|
||||
'title' => 'Artist tracks',
|
||||
'method' => 'GET',
|
||||
'path' => $baseUrl . '/api/v1/tracks?artist_id=123',
|
||||
'note' => 'Track list tied to one artist account.',
|
||||
],
|
||||
[
|
||||
'title' => 'Order item detail',
|
||||
'method' => 'GET',
|
||||
'path' => $baseUrl . '/api/v1/order-items?artist_id=123',
|
||||
'note' => 'Granular order transparency for AMS reporting.',
|
||||
],
|
||||
[
|
||||
'title' => 'Submit release / tracks',
|
||||
'method' => 'POST',
|
||||
'path' => $baseUrl . '/api/v1/releases + /api/v1/tracks',
|
||||
'note' => 'AMS pushes approved releases and tracks into AudioCore.',
|
||||
],
|
||||
];
|
||||
|
||||
ob_start();
|
||||
?>
|
||||
<section class="admin-card">
|
||||
<div class="badge">Integration</div>
|
||||
<div style="margin-top:14px; max-width:860px;">
|
||||
<h1 style="margin:0; font-size:30px;">API</h1>
|
||||
<p style="margin:8px 0 0; color:var(--muted);">AMS integration endpoints, API keys, and sales sync access live here. Keep this page operational rather than decorative: create clients, issue keys, and hand exact endpoints to the AMS.</p>
|
||||
</div>
|
||||
|
||||
<?php if ($message !== ''): ?>
|
||||
<div class="admin-card" style="margin-top:16px; padding:14px; border-color:<?= $status === 'ok' ? 'rgba(34,242,165,.25)' : 'rgba(255,120,120,.22)' ?>; color:<?= $status === 'ok' ? '#9ff8d8' : '#ffb0b0' ?>;">
|
||||
<?= htmlspecialchars($message, ENT_QUOTES, 'UTF-8') ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($createdKey !== ''): ?>
|
||||
<div class="admin-card" style="margin-top:16px; padding:16px;">
|
||||
<div style="display:flex; align-items:flex-start; justify-content:space-between; gap:16px; flex-wrap:wrap;">
|
||||
<div>
|
||||
<div class="label">New API Key</div>
|
||||
<div style="margin-top:8px; font-size:13px; color:#ffdfad;">Copy this now. It is only shown once.</div>
|
||||
</div>
|
||||
<button type="button" class="btn outline small" onclick="navigator.clipboard.writeText('<?= htmlspecialchars($createdKey, ENT_QUOTES, 'UTF-8') ?>')">Copy key</button>
|
||||
</div>
|
||||
<code style="display:block; margin-top:12px; padding:14px 16px; border-radius:14px; border:1px solid rgba(255,255,255,0.12); background:rgba(255,255,255,0.03); font-family:'IBM Plex Mono', monospace; font-size:13px; overflow:auto; white-space:nowrap;"><?= htmlspecialchars($createdKey, ENT_QUOTES, 'UTF-8') ?></code>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div style="display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:12px; margin-top:18px;">
|
||||
<article class="admin-card" style="padding:16px;">
|
||||
<div class="label">Clients</div>
|
||||
<div style="margin-top:10px; font-size:28px; font-weight:700;"><?= count($clients) ?></div>
|
||||
<div style="margin-top:6px; color:var(--muted); font-size:13px;">Configured external systems</div>
|
||||
</article>
|
||||
<article class="admin-card" style="padding:16px;">
|
||||
<div class="label">Auth</div>
|
||||
<div style="margin-top:10px; font-size:28px; font-weight:700;">Bearer</div>
|
||||
<div style="margin-top:6px; color:var(--muted); font-size:13px;">Also accepts <code>X-API-Key</code></div>
|
||||
</article>
|
||||
<article class="admin-card" style="padding:16px;">
|
||||
<div class="label">Webhook</div>
|
||||
<div style="margin-top:10px; font-size:28px; font-weight:700;">sale.paid</div>
|
||||
<div style="margin-top:6px; color:var(--muted); font-size:13px;">Outbound sale notifications for AMS sync</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<article class="admin-card" style="padding:18px; margin-top:16px;">
|
||||
<div style="display:flex; align-items:flex-start; justify-content:space-between; gap:16px; flex-wrap:wrap;">
|
||||
<div>
|
||||
<div class="label">Create API Client</div>
|
||||
<div style="margin-top:8px; color:var(--muted); font-size:13px; max-width:680px;">Use one client per AMS install or per integration target. That keeps revocation clean and usage attribution obvious.</div>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" action="/admin/api/clients/create" style="display:grid; gap:14px; margin-top:16px;">
|
||||
<div style="display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1fr) auto; gap:12px; align-items:end;">
|
||||
<label style="display:grid; gap:6px;">
|
||||
<span class="label">Client Name</span>
|
||||
<input class="input" type="text" name="name" placeholder="AudioCore AMS">
|
||||
</label>
|
||||
<label style="display:grid; gap:6px;">
|
||||
<span class="label">Webhook URL (optional)</span>
|
||||
<input class="input" type="url" name="webhook_url" placeholder="https://ams.example.com/webhooks/audiocore">
|
||||
</label>
|
||||
<button class="btn" type="submit">Create Key</button>
|
||||
</div>
|
||||
</form>
|
||||
</article>
|
||||
|
||||
<article class="admin-card" style="padding:18px; margin-top:16px;">
|
||||
<div style="display:flex; align-items:flex-start; justify-content:space-between; gap:16px; flex-wrap:wrap;">
|
||||
<div>
|
||||
<div class="label">Endpoint Reference</div>
|
||||
<div style="margin-top:8px; color:var(--muted); font-size:13px;">Keep this as an operator reference. The layout is stacked because this panel needs readability more than compression.</div>
|
||||
</div>
|
||||
<div style="color:var(--muted); font-size:12px;">Use <strong>Authorization: Bearer <api-key></strong> or <strong>X-API-Key</strong>.</div>
|
||||
</div>
|
||||
<div style="display:grid; gap:10px; margin-top:16px;">
|
||||
<?php foreach ($endpointRows as $row): ?>
|
||||
<div class="admin-card" style="padding:14px 16px; display:grid; grid-template-columns:140px 96px minmax(0,1fr); gap:14px; align-items:start;">
|
||||
<div>
|
||||
<div style="font-weight:700;"><?= htmlspecialchars($row['title'], ENT_QUOTES, 'UTF-8') ?></div>
|
||||
<div style="margin-top:5px; color:var(--muted); font-size:12px; line-height:1.45;"><?= htmlspecialchars($row['note'], ENT_QUOTES, 'UTF-8') ?></div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="pill" style="padding:6px 10px; font-size:10px; letter-spacing:0.14em; border-color:rgba(115,255,198,0.25); color:#9ff8d8;"><?= htmlspecialchars($row['method'], ENT_QUOTES, 'UTF-8') ?></span>
|
||||
</div>
|
||||
<code style="display:block; padding:10px 12px; border-radius:10px; background:rgba(255,255,255,0.03); border:1px solid rgba(255,255,255,0.1); font-size:12px; overflow:auto; white-space:nowrap;"><?= htmlspecialchars($row['path'], ENT_QUOTES, 'UTF-8') ?></code>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="admin-card" style="padding:18px; margin-top:16px;">
|
||||
<div style="display:flex; align-items:flex-start; justify-content:space-between; gap:16px; flex-wrap:wrap;">
|
||||
<div>
|
||||
<div class="label">Active Clients</div>
|
||||
<div style="margin-top:8px; color:var(--muted); font-size:13px;">Disable a client to cut access immediately. Delete only when you do not need audit visibility anymore.</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php if (!$clients): ?>
|
||||
<div style="margin-top:14px; color:var(--muted); font-size:13px;">No API clients created yet.</div>
|
||||
<?php else: ?>
|
||||
<div style="display:grid; gap:10px; margin-top:14px;">
|
||||
<?php foreach ($clients as $client): ?>
|
||||
<div class="admin-card" style="padding:16px; display:grid; grid-template-columns:minmax(0,1fr) auto; gap:16px; align-items:center;">
|
||||
<div>
|
||||
<div style="display:flex; gap:10px; align-items:center; flex-wrap:wrap;">
|
||||
<strong><?= htmlspecialchars((string)($client['name'] ?? ''), ENT_QUOTES, 'UTF-8') ?></strong>
|
||||
<span class="pill" style="padding:5px 10px; font-size:10px; letter-spacing:0.14em; border-color:<?= (int)($client['is_active'] ?? 0) === 1 ? 'rgba(115,255,198,0.35)' : 'rgba(255,255,255,0.16)' ?>; color:<?= (int)($client['is_active'] ?? 0) === 1 ? '#9ff8d8' : '#a7adba' ?>;">
|
||||
<?= (int)($client['is_active'] ?? 0) === 1 ? 'Active' : 'Disabled' ?>
|
||||
</span>
|
||||
</div>
|
||||
<div style="display:grid; grid-template-columns:repeat(3,minmax(0,auto)); gap:18px; margin-top:10px; color:var(--muted); font-size:12px;">
|
||||
<div><span class="label" style="display:block; margin-bottom:4px;">Key prefix</span><?= htmlspecialchars((string)($client['api_key_prefix'] ?? ''), ENT_QUOTES, 'UTF-8') ?>...</div>
|
||||
<div><span class="label" style="display:block; margin-bottom:4px;">Last used</span><?= htmlspecialchars((string)($client['last_used_at'] ?? 'Never'), ENT_QUOTES, 'UTF-8') ?></div>
|
||||
<div><span class="label" style="display:block; margin-bottom:4px;">Last IP</span><?= htmlspecialchars((string)($client['last_used_ip'] ?? '—'), ENT_QUOTES, 'UTF-8') ?></div>
|
||||
</div>
|
||||
<?php if (!empty($client['webhook_url'])): ?>
|
||||
<div style="margin-top:8px; color:var(--muted); font-size:12px;">
|
||||
Webhook: <?= htmlspecialchars((string)$client['webhook_url'], ENT_QUOTES, 'UTF-8') ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div style="display:flex; gap:8px; align-items:center; justify-content:flex-end;">
|
||||
<form method="post" action="/admin/api/clients/toggle">
|
||||
<input type="hidden" name="id" value="<?= (int)($client['id'] ?? 0) ?>">
|
||||
<button type="submit" class="btn outline small"><?= (int)($client['is_active'] ?? 0) === 1 ? 'Disable' : 'Enable' ?></button>
|
||||
</form>
|
||||
<form method="post" action="/admin/api/clients/delete" onsubmit="return confirm('Delete this API client?');">
|
||||
<input type="hidden" name="id" value="<?= (int)($client['id'] ?? 0) ?>">
|
||||
<button type="submit" class="btn outline small" style="border-color:rgba(255,120,120,.35); color:#ffb0b0;">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</article>
|
||||
</section>
|
||||
<?php
|
||||
$content = ob_get_clean();
|
||||
require __DIR__ . '/../../../admin/views/layout.php';
|
||||
0
modules/artists/views/admin/.gitkeep
Normal file
0
modules/artists/views/admin/.gitkeep
Normal file
@@ -1,61 +1,171 @@
|
||||
<?php
|
||||
$pageTitle = $title ?? 'News';
|
||||
$posts = $posts ?? [];
|
||||
$page = $page ?? null;
|
||||
$posts = is_array($posts ?? null) ? $posts : [];
|
||||
$page = is_array($page ?? null) ? $page : null;
|
||||
$pageContentHtml = trim((string)($page['content_html'] ?? ''));
|
||||
|
||||
$formatDate = static function (?string $value): string {
|
||||
if ($value === null || trim($value) === '') {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
return (new DateTime($value))->format('d M Y');
|
||||
} catch (Throwable $e) {
|
||||
return $value;
|
||||
}
|
||||
};
|
||||
|
||||
$renderTags = static function (string $tags): array {
|
||||
return array_values(array_filter(array_map('trim', explode(',', $tags))));
|
||||
};
|
||||
|
||||
ob_start();
|
||||
?>
|
||||
<section class="card">
|
||||
<div class="badge">News</div>
|
||||
<?php if ($page && !empty($page['content_html'])): ?>
|
||||
<div style="margin-top:12px; color:var(--muted); line-height:1.7;">
|
||||
<?= (string)$page['content_html'] ?>
|
||||
<section class="card news-list-shell-minimal">
|
||||
<?php if ($pageContentHtml !== ''): ?>
|
||||
<div class="blog-page-content-box">
|
||||
<?= $pageContentHtml ?>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<h1 style="margin-top:12px; font-size:30px;">Latest Updates</h1>
|
||||
<p style="color:var(--muted); margin-top:8px;">News, updates, and announcements.</p>
|
||||
<?php endif; ?>
|
||||
|
||||
<div style="margin-top:18px; display:grid; gap:12px;">
|
||||
<?php if (!$posts): ?>
|
||||
<div style="color:var(--muted);">No posts yet.</div>
|
||||
<?php else: ?>
|
||||
<?php if ($posts): ?>
|
||||
<div class="news-list-grid-minimal">
|
||||
<?php foreach ($posts as $post): ?>
|
||||
<article style="padding:14px 16px; border-radius:16px; border:1px solid rgba(255,255,255,0.12); background: rgba(0,0,0,0.25);">
|
||||
<div style="font-size:11px; text-transform:uppercase; letter-spacing:0.28em; color:var(--muted);">Post</div>
|
||||
<h2 style="margin:8px 0 6px; font-size:22px;"><?= htmlspecialchars((string)($post['title'] ?? ''), ENT_QUOTES, 'UTF-8') ?></h2>
|
||||
<a class="news-card-minimal" href="/news/<?= htmlspecialchars((string)$post['slug'], ENT_QUOTES, 'UTF-8') ?>">
|
||||
<?php if (!empty($post['featured_image_url'])): ?>
|
||||
<img src="<?= htmlspecialchars((string)$post['featured_image_url'], ENT_QUOTES, 'UTF-8') ?>" alt="" style="width:100%; border-radius:12px; margin:10px 0;">
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($post['published_at'])): ?>
|
||||
<div style="font-size:12px; color:var(--muted); margin-bottom:8px;"><?= htmlspecialchars((string)$post['published_at'], ENT_QUOTES, 'UTF-8') ?></div>
|
||||
<?php endif; ?>
|
||||
<div style="font-size:12px; color:var(--muted); margin-bottom:8px;">
|
||||
<?php if (!empty($post['author_name'])): ?>
|
||||
<?= htmlspecialchars((string)$post['author_name'], ENT_QUOTES, 'UTF-8') ?>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($post['category'])): ?>
|
||||
<?php if (!empty($post['author_name'])): ?> · <?php endif; ?>
|
||||
<?= htmlspecialchars((string)$post['category'], ENT_QUOTES, 'UTF-8') ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<p style="color:var(--muted); line-height:1.6;">
|
||||
<?= htmlspecialchars((string)($post['excerpt'] ?? ''), ENT_QUOTES, 'UTF-8') ?>
|
||||
</p>
|
||||
<?php if (!empty($post['tags'])): ?>
|
||||
<div style="margin-top:8px; display:flex; flex-wrap:wrap; gap:6px;">
|
||||
<?php foreach (array_filter(array_map('trim', explode(',', (string)$post['tags'] ?? ''))) as $tag): ?>
|
||||
<span style="font-size:11px; color:#c8ccd8; border:1px solid rgba(255,255,255,0.15); padding:4px 8px; border-radius:999px;">
|
||||
<?= htmlspecialchars((string)$tag, ENT_QUOTES, 'UTF-8') ?>
|
||||
</span>
|
||||
<?php endforeach; ?>
|
||||
<div class="news-card-media-minimal">
|
||||
<img src="<?= htmlspecialchars((string)$post['featured_image_url'], ENT_QUOTES, 'UTF-8') ?>" alt="">
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<a href="/news/<?= htmlspecialchars((string)($post['slug'] ?? ''), ENT_QUOTES, 'UTF-8') ?>" style="display:inline-flex; margin-top:10px; font-size:12px; text-transform:uppercase; letter-spacing:0.2em; color:#9ad4ff;">Read more</a>
|
||||
</article>
|
||||
<div class="news-card-copy-minimal">
|
||||
<div class="news-card-meta-minimal">
|
||||
<?php $published = $formatDate((string)($post['published_at'] ?? '')); ?>
|
||||
<?php if ($published !== ''): ?><span><?= htmlspecialchars($published, ENT_QUOTES, 'UTF-8') ?></span><?php endif; ?>
|
||||
<?php if (!empty($post['author_name'])): ?><span><?= htmlspecialchars((string)$post['author_name'], ENT_QUOTES, 'UTF-8') ?></span><?php endif; ?>
|
||||
</div>
|
||||
<h2><?= htmlspecialchars((string)$post['title'], ENT_QUOTES, 'UTF-8') ?></h2>
|
||||
<?php if (!empty($post['excerpt'])): ?>
|
||||
<p><?= htmlspecialchars((string)$post['excerpt'], ENT_QUOTES, 'UTF-8') ?></p>
|
||||
<?php endif; ?>
|
||||
<?php $tags = $renderTags((string)($post['tags'] ?? '')); ?>
|
||||
<?php if ($tags): ?>
|
||||
<div class="news-card-tags-minimal">
|
||||
<?php foreach (array_slice($tags, 0, 3) as $tag): ?>
|
||||
<span><?= htmlspecialchars($tag, ENT_QUOTES, 'UTF-8') ?></span>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php elseif ($pageContentHtml === ''): ?>
|
||||
<div class="news-empty-minimal">No published posts yet.</div>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.news-list-shell-minimal { display: grid; gap: 18px; }
|
||||
.blog-page-content-box {
|
||||
border-radius: 22px;
|
||||
border: 1px solid rgba(255,255,255,.1);
|
||||
background: rgba(12,15,21,.84);
|
||||
padding: 28px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.blog-page-content-box img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
.news-list-grid-minimal {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.news-card-minimal {
|
||||
display: grid;
|
||||
grid-template-columns: 180px minmax(0, 1fr);
|
||||
gap: 0;
|
||||
min-height: 180px;
|
||||
border-radius: 22px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255,255,255,.1);
|
||||
background: rgba(12,15,21,.84);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: transform .18s ease, border-color .18s ease, box-shadow .18s ease;
|
||||
}
|
||||
.news-card-minimal:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: rgba(34,242,165,.34);
|
||||
box-shadow: 0 18px 32px rgba(0,0,0,.2);
|
||||
}
|
||||
.news-card-media-minimal { background: rgba(255,255,255,.03); }
|
||||
.news-card-media-minimal img {
|
||||
width: 100%; height: 100%; object-fit: cover; display: block;
|
||||
}
|
||||
.news-card-copy-minimal {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 12px;
|
||||
padding: 20px;
|
||||
}
|
||||
.news-card-meta-minimal {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
color: rgba(255,255,255,.58);
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 10px;
|
||||
letter-spacing: .16em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.news-card-copy-minimal h2 {
|
||||
margin: 0;
|
||||
font-size: 34px;
|
||||
line-height: .96;
|
||||
}
|
||||
.news-card-copy-minimal p {
|
||||
margin: 0;
|
||||
color: #c4cede;
|
||||
line-height: 1.65;
|
||||
font-size: 15px;
|
||||
}
|
||||
.news-card-tags-minimal {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.news-card-tags-minimal span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255,255,255,.12);
|
||||
background: rgba(255,255,255,.04);
|
||||
color: #dce5f6;
|
||||
font-size: 11px;
|
||||
}
|
||||
.news-empty-minimal {
|
||||
border-radius: 18px;
|
||||
border: 1px dashed rgba(255,255,255,.16);
|
||||
padding: 24px;
|
||||
color: #afb8ca;
|
||||
background: rgba(255,255,255,.02);
|
||||
}
|
||||
@media (max-width: 980px) {
|
||||
.news-list-grid-minimal { grid-template-columns: 1fr; }
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.blog-page-content-box { padding: 20px; }
|
||||
.news-card-minimal {
|
||||
grid-template-columns: 1fr;
|
||||
min-height: 0;
|
||||
}
|
||||
.news-card-media-minimal { min-height: 220px; }
|
||||
.news-card-copy-minimal h2 { font-size: 28px; }
|
||||
}
|
||||
</style>
|
||||
<?php
|
||||
$content = ob_get_clean();
|
||||
require __DIR__ . '/../../../views/site/layout.php';
|
||||
require __DIR__ . '/../../../../views/site/layout.php';
|
||||
@@ -1,47 +1,257 @@
|
||||
<?php
|
||||
$pageTitle = $title ?? 'Post';
|
||||
$contentHtml = $content_html ?? '';
|
||||
$publishedAt = $published_at ?? '';
|
||||
$featuredImage = $featured_image_url ?? '';
|
||||
$authorName = $author_name ?? '';
|
||||
$category = $category ?? '';
|
||||
$tags = $tags ?? '';
|
||||
ob_start();
|
||||
?>
|
||||
<section class="card">
|
||||
<div class="badge">News</div>
|
||||
<h1 style="margin-top:12px; font-size:30px;"><?= htmlspecialchars($pageTitle, ENT_QUOTES, 'UTF-8') ?></h1>
|
||||
<?php if ($publishedAt !== '' || $authorName !== '' || $category !== ''): ?>
|
||||
<div style="font-size:12px; color:var(--muted); margin-top:6px;">
|
||||
<?php if ($publishedAt !== ''): ?>
|
||||
<?= htmlspecialchars($publishedAt, ENT_QUOTES, 'UTF-8') ?>
|
||||
<?php endif; ?>
|
||||
<?php if ($authorName !== ''): ?>
|
||||
<?php if ($publishedAt !== ''): ?> · <?php endif; ?>
|
||||
<?= htmlspecialchars($authorName, ENT_QUOTES, 'UTF-8') ?>
|
||||
<?php endif; ?>
|
||||
<?php if ($category !== ''): ?>
|
||||
<?php if ($publishedAt !== '' || $authorName !== ''): ?> · <?php endif; ?>
|
||||
<?= htmlspecialchars($category, ENT_QUOTES, 'UTF-8') ?>
|
||||
<?php endif; ?>
|
||||
<?php
|
||||
$pageTitle = $title ?? 'Post';
|
||||
$contentHtml = (string)($content_html ?? '');
|
||||
$publishedAt = (string)($published_at ?? '');
|
||||
$authorName = (string)($author_name ?? '');
|
||||
$category = (string)($category ?? '');
|
||||
$tags = (string)($tags ?? '');
|
||||
$tagList = array_filter(array_map('trim', explode(',', $tags)));
|
||||
$publishedDisplay = '';
|
||||
if ($publishedAt !== '') {
|
||||
try {
|
||||
$publishedDisplay = (new DateTime($publishedAt))->format('d M Y');
|
||||
} catch (Throwable $e) {
|
||||
$publishedDisplay = $publishedAt;
|
||||
}
|
||||
}
|
||||
ob_start();
|
||||
?>
|
||||
<section class="card article-shell-fluid">
|
||||
<div class="article-fluid-grid">
|
||||
<aside class="article-fluid-meta">
|
||||
<div class="badge">News</div>
|
||||
<h1 class="article-fluid-title"><?= htmlspecialchars($pageTitle, ENT_QUOTES, 'UTF-8') ?></h1>
|
||||
<div class="article-fluid-meta-list">
|
||||
<?php if ($publishedDisplay !== ''): ?>
|
||||
<div class="article-fluid-meta-item">
|
||||
<span>Date</span>
|
||||
<strong><?= htmlspecialchars($publishedDisplay, ENT_QUOTES, 'UTF-8') ?></strong>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if ($authorName !== ''): ?>
|
||||
<div class="article-fluid-meta-item">
|
||||
<span>Author</span>
|
||||
<strong><?= htmlspecialchars($authorName, ENT_QUOTES, 'UTF-8') ?></strong>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if ($category !== ''): ?>
|
||||
<div class="article-fluid-meta-item">
|
||||
<span>Category</span>
|
||||
<strong><?= htmlspecialchars($category, ENT_QUOTES, 'UTF-8') ?></strong>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php if ($tagList): ?>
|
||||
<div class="article-fluid-tags">
|
||||
<?php foreach ($tagList as $tag): ?>
|
||||
<span><?= htmlspecialchars((string)$tag, ENT_QUOTES, 'UTF-8') ?></span>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<a href="/news" class="article-fluid-back">Back to news</a>
|
||||
</aside>
|
||||
|
||||
<div class="article-fluid-content-shell">
|
||||
<div class="article-fluid-post-box">
|
||||
<?= $contentHtml ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if ($featuredImage !== ''): ?>
|
||||
<img src="<?= htmlspecialchars($featuredImage, ENT_QUOTES, 'UTF-8') ?>" alt="" style="width:100%; border-radius:16px; margin-top:16px;">
|
||||
<?php endif; ?>
|
||||
<div style="margin-top:14px; color:var(--muted); line-height:1.8;">
|
||||
<?= $contentHtml ?>
|
||||
</div>
|
||||
<?php if ($tags !== ''): ?>
|
||||
<div style="margin-top:16px; display:flex; flex-wrap:wrap; gap:6px;">
|
||||
<?php foreach (array_filter(array_map('trim', explode(',', (string)$tags))) as $tag): ?>
|
||||
<span style="font-size:11px; color:#c8ccd8; border:1px solid rgba(255,255,255,0.15); padding:4px 8px; border-radius:999px;">
|
||||
<?= htmlspecialchars((string)$tag, ENT_QUOTES, 'UTF-8') ?>
|
||||
</span>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
<?php
|
||||
$content = ob_get_clean();
|
||||
require __DIR__ . '/../../../views/site/layout.php';
|
||||
|
||||
<style>
|
||||
.article-shell-fluid {
|
||||
padding: 0;
|
||||
}
|
||||
.article-fluid-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 280px minmax(0, 1fr);
|
||||
gap: 0;
|
||||
align-items: start;
|
||||
min-height: 100%;
|
||||
}
|
||||
.article-fluid-meta {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
align-content: start;
|
||||
min-height: 100%;
|
||||
padding: 32px 24px;
|
||||
}
|
||||
.article-fluid-title {
|
||||
margin: 0;
|
||||
color: #f5f7ff;
|
||||
font-size: clamp(34px, 3vw, 56px);
|
||||
line-height: .94;
|
||||
word-break: break-word;
|
||||
}
|
||||
.article-fluid-meta-list {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
.article-fluid-meta-item span {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
color: rgba(255,255,255,.56);
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 10px;
|
||||
letter-spacing: .18em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.article-fluid-meta-item strong {
|
||||
color: #e8eefc;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.article-fluid-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.article-fluid-tags span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 7px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255,255,255,.12);
|
||||
background: rgba(255,255,255,.04);
|
||||
color: #dce5f6;
|
||||
font-size: 11px;
|
||||
}
|
||||
.article-fluid-back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 42px;
|
||||
padding: 0 14px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255,255,255,.14);
|
||||
background: rgba(255,255,255,.04);
|
||||
color: #eef4ff;
|
||||
text-decoration: none;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 11px;
|
||||
letter-spacing: .16em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.article-fluid-back:hover {
|
||||
border-color: rgba(34,242,165,.36);
|
||||
color: #9ff8d8;
|
||||
}
|
||||
.article-fluid-content-shell {
|
||||
min-width: 0;
|
||||
min-height: 100%;
|
||||
margin: 0;
|
||||
padding: 40px 44px;
|
||||
border-radius: 0 28px 28px 0;
|
||||
background: #f4f1ec;
|
||||
box-shadow: none;
|
||||
}
|
||||
.article-fluid-post-box {
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
color: #26272d;
|
||||
}
|
||||
.article-fluid-post-box,
|
||||
.article-fluid-post-box p,
|
||||
.article-fluid-post-box li,
|
||||
.article-fluid-post-box blockquote,
|
||||
.article-fluid-post-box figcaption,
|
||||
.article-fluid-post-box td,
|
||||
.article-fluid-post-box th {
|
||||
color: #3a3d45;
|
||||
}
|
||||
.article-fluid-post-box h1,
|
||||
.article-fluid-post-box h2,
|
||||
.article-fluid-post-box h3,
|
||||
.article-fluid-post-box h4,
|
||||
.article-fluid-post-box h5,
|
||||
.article-fluid-post-box h6,
|
||||
.article-fluid-post-box strong {
|
||||
color: #202228;
|
||||
}
|
||||
.article-fluid-post-box,
|
||||
.article-fluid-post-box > *,
|
||||
.article-fluid-post-box > * > *,
|
||||
.article-fluid-post-box > * > * > * {
|
||||
max-width: none !important;
|
||||
}
|
||||
.article-fluid-post-box > * {
|
||||
width: 100% !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
.article-fluid-post-box > div,
|
||||
.article-fluid-post-box > section,
|
||||
.article-fluid-post-box > article,
|
||||
.article-fluid-post-box > main,
|
||||
.article-fluid-post-box > figure {
|
||||
width: 100% !important;
|
||||
max-width: none !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
.article-fluid-post-box * {
|
||||
margin-left: 0 !important;
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
.article-fluid-post-box [style*="max-width"],
|
||||
.article-fluid-post-box [style*="width"],
|
||||
.article-fluid-post-box [style*="margin: auto"],
|
||||
.article-fluid-post-box [style*="margin-left:auto"],
|
||||
.article-fluid-post-box [style*="margin-right:auto"] {
|
||||
max-width: none !important;
|
||||
width: 100% !important;
|
||||
margin-left: 0 !important;
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
.article-fluid-post-box > div,
|
||||
.article-fluid-post-box > section,
|
||||
.article-fluid-post-box > article,
|
||||
.article-fluid-post-box > main,
|
||||
.article-fluid-post-box > figure,
|
||||
.article-fluid-post-box > div > div:first-child {
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
border: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
.article-fluid-post-box img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
.article-fluid-post-box iframe,
|
||||
.article-fluid-post-box video,
|
||||
.article-fluid-post-box table,
|
||||
.article-fluid-post-box pre {
|
||||
max-width: 100%;
|
||||
}
|
||||
@media (max-width: 980px) {
|
||||
.article-fluid-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
.article-fluid-meta {
|
||||
padding: 24px 24px 8px;
|
||||
}
|
||||
.article-fluid-content-shell {
|
||||
margin: 0;
|
||||
border-radius: 0 0 28px 28px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.article-shell-fluid {
|
||||
padding: 0;
|
||||
}
|
||||
.article-fluid-content-shell {
|
||||
padding: 28px 24px;
|
||||
}
|
||||
.article-fluid-post-box {
|
||||
padding: 0;
|
||||
}
|
||||
.article-fluid-title {
|
||||
font-size: 42px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<?php
|
||||
$content = ob_get_clean();
|
||||
require __DIR__ . '/../../../../views/site/layout.php';
|
||||
|
||||
@@ -7,6 +7,7 @@ use Core\Http\Response;
|
||||
use Core\Services\Auth;
|
||||
use Core\Services\Database;
|
||||
use Core\Services\Mailer;
|
||||
use Core\Services\RateLimiter;
|
||||
use Core\Services\Settings;
|
||||
use Core\Views\View;
|
||||
use PDO;
|
||||
@@ -28,6 +29,13 @@ class NewsletterController
|
||||
if ($email === '') {
|
||||
return new Response('Missing email', 400);
|
||||
}
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
return new Response('Invalid email', 400);
|
||||
}
|
||||
$limitKey = sha1(strtolower($email) . '|' . $this->clientIp());
|
||||
if (RateLimiter::tooMany('newsletter_subscribe', $limitKey, 8, 600)) {
|
||||
return new Response('Too many requests. Please wait 10 minutes and try again.', 429);
|
||||
}
|
||||
|
||||
$db = Database::get();
|
||||
if ($db instanceof PDO) {
|
||||
@@ -61,6 +69,16 @@ class NewsletterController
|
||||
{
|
||||
$email = trim((string)($_POST['email'] ?? ''));
|
||||
$status = 'Email is required.';
|
||||
if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
$limitKey = sha1(strtolower($email) . '|' . $this->clientIp());
|
||||
if (RateLimiter::tooMany('newsletter_unsubscribe', $limitKey, 8, 600)) {
|
||||
return new Response($this->view->render('site/unsubscribe.php', [
|
||||
'title' => 'Unsubscribe',
|
||||
'email' => $email,
|
||||
'status' => 'Too many unsubscribe attempts. Please wait 10 minutes.',
|
||||
]), 429);
|
||||
}
|
||||
}
|
||||
|
||||
$db = Database::get();
|
||||
if ($db instanceof PDO && $email !== '') {
|
||||
@@ -76,6 +94,24 @@ class NewsletterController
|
||||
]));
|
||||
}
|
||||
|
||||
private function clientIp(): string
|
||||
{
|
||||
foreach (['HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR', 'REMOTE_ADDR'] as $key) {
|
||||
$value = trim((string)($_SERVER[$key] ?? ''));
|
||||
if ($value === '') {
|
||||
continue;
|
||||
}
|
||||
if ($key === 'HTTP_X_FORWARDED_FOR') {
|
||||
$parts = array_map('trim', explode(',', $value));
|
||||
$value = (string)($parts[0] ?? '');
|
||||
}
|
||||
if ($value !== '') {
|
||||
return substr($value, 0, 64);
|
||||
}
|
||||
}
|
||||
return '0.0.0.0';
|
||||
}
|
||||
|
||||
public function adminIndex(): Response
|
||||
{
|
||||
if ($guard = $this->guard(['admin', 'manager'])) {
|
||||
|
||||
Reference in New Issue
Block a user