2026-03-04 20:46:11 +00:00
< ? php
declare ( strict_types = 1 );
namespace Modules\Admin ;
use Core\Http\Response ;
use Core\Services\Audit ;
use Core\Services\Auth ;
use Core\Services\Database ;
use Core\Services\Mailer ;
use Core\Services\Permissions ;
use Core\Services\Plugins ;
use Core\Services\Settings ;
use Core\Services\Shortcodes ;
use Core\Services\Updater ;
use Core\Views\View ;
use PDO ;
use Throwable ;
class AdminController
{
private View $view ;
public function __construct ()
{
$this -> view = new View ( __DIR__ . '/views' );
}
public function index () : Response
{
if ( ! $this -> dbReady ()) {
return $this -> installer ();
}
$this -> ensureCoreTables ();
if ( ! Auth :: check ()) {
return new Response ( '' , 302 , [ 'Location' => '/admin/login' ]);
}
return new Response ( $this -> view -> render ( 'dashboard.php' , [
'title' => 'Admin' ,
]));
}
public function installer () : Response
{
2026-04-01 14:12:17 +00:00
if ( $this -> appInstalled ()) {
return new Response ( '' , 302 , [ 'Location' => Auth :: check () ? '/admin' : '/admin/login' ]);
}
2026-03-04 20:46:11 +00:00
$installer = $_SESSION [ 'installer' ] ? ? [];
$step = ! empty ( $installer [ 'core_ready' ]) ? 2 : 1 ;
$values = is_array ( $installer [ 'values' ] ? ? null ) ? $installer [ 'values' ] : [];
$smtpResult = is_array ( $installer [ 'smtp_result' ] ? ? null ) ? $installer [ 'smtp_result' ] : [];
$checks = is_array ( $installer [ 'checks' ] ? ? null ) ? $installer [ 'checks' ] : [];
return new Response ( $this -> view -> render ( 'installer.php' , [
'title' => 'Installer' ,
'step' => $step ,
'error' => ( string )( $_GET [ 'error' ] ? ? '' ),
'success' => ( string )( $_GET [ 'success' ] ? ? '' ),
'values' => $values ,
'smtp_result' => $smtpResult ,
'checks' => $checks ,
]));
}
public function install () : Response
{
2026-04-01 14:12:17 +00:00
if ( $this -> appInstalled ()) {
return new Response ( '' , 302 , [ 'Location' => Auth :: check () ? '/admin' : '/admin/login' ]);
}
2026-03-04 20:46:11 +00:00
$action = trim (( string )( $_POST [ 'installer_action' ] ? ? 'setup_core' ));
if ( $action === 'setup_core' ) {
return $this -> installSetupCore ();
}
if ( $action === 'test_smtp' ) {
return $this -> installTestSmtp ();
}
if ( $action === 'finish_install' ) {
return $this -> installFinish ();
}
return new Response ( '' , 302 , [ 'Location' => '/admin/installer?error=' . rawurlencode ( 'Invalid installer action.' )]);
}
public function loginForm () : Response
{
if ( ! $this -> dbReady ()) {
return $this -> installer ();
}
$this -> ensureCoreTables ();
return new Response ( $this -> view -> render ( 'login.php' , [
'title' => 'Admin Login' ,
'error' => '' ,
]));
}
public function login () : Response
{
$this -> ensureCoreTables ();
$email = trim (( string )( $_POST [ 'email' ] ? ? '' ));
$password = ( string )( $_POST [ 'password' ] ? ? '' );
$db = Database :: get ();
if ( ! $db instanceof PDO ) {
return new Response ( $this -> view -> render ( 'login.php' , [
'title' => 'Admin Login' ,
'error' => 'Database unavailable.' ,
]));
}
try {
$stmt = $db -> prepare ( " SELECT id, name, password_hash, role FROM ac_admin_users WHERE email = :email LIMIT 1 " );
$stmt -> execute ([ ':email' => $email ]);
$row = $stmt -> fetch ();
if ( $row && password_verify ( $password , ( string ) $row [ 'password_hash' ])) {
Auth :: login (( int ) $row [ 'id' ], ( string )( $row [ 'role' ] ? ? 'admin' ), ( string )( $row [ 'name' ] ? ? 'Admin' ));
return new Response ( '' , 302 , [ 'Location' => '/admin' ]);
}
$stmt = $db -> prepare ( " SELECT id, name, password_hash FROM ac_admins WHERE email = :email LIMIT 1 " );
$stmt -> execute ([ ':email' => $email ]);
$row = $stmt -> fetch ();
if ( $row && password_verify ( $password , ( string ) $row [ 'password_hash' ])) {
Auth :: login (( int ) $row [ 'id' ], 'admin' , ( string )( $row [ 'name' ] ? ? 'Admin' ));
return new Response ( '' , 302 , [ 'Location' => '/admin' ]);
}
} catch ( Throwable $e ) {
return new Response ( $this -> view -> render ( 'login.php' , [
'title' => 'Admin Login' ,
'error' => 'Login failed due to missing database tables. Open /admin once to initialize tables, then retry.' ,
]));
}
return new Response ( $this -> view -> render ( 'login.php' , [
'title' => 'Admin Login' ,
'error' => 'Invalid login.' ,
]));
}
public function logout () : Response
{
Auth :: logout ();
return new Response ( '' , 302 , [ 'Location' => '/admin/login' ]);
}
public function accountsIndex () : Response
{
if ( $guard = $this -> guard ([ 'admin' ])) {
return $guard ;
}
$db = Database :: get ();
$users = [];
$error = '' ;
if ( $db instanceof PDO ) {
try {
$stmt = $db -> query ( " SELECT id, name, email, role, created_at FROM ac_admin_users ORDER BY created_at DESC " );
$users = $stmt ? $stmt -> fetchAll ( PDO :: FETCH_ASSOC ) : [];
} catch ( Throwable $e ) {
$error = 'Accounts table not available.' ;
}
} else {
$error = 'Database unavailable.' ;
}
return new Response ( $this -> view -> render ( 'accounts.php' , [
'title' => 'Accounts' ,
'users' => $users ,
'error' => $error ,
]));
}
public function accountsNew () : Response
{
if ( $guard = $this -> guard ([ 'admin' ])) {
return $guard ;
}
return new Response ( $this -> view -> render ( 'account_new.php' , [
'title' => 'New Account' ,
'error' => '' ,
]));
}
public function accountsSave () : Response
{
if ( $guard = $this -> guard ([ 'admin' ])) {
return $guard ;
}
$name = trim (( string )( $_POST [ 'name' ] ? ? '' ));
$email = trim (( string )( $_POST [ 'email' ] ? ? '' ));
$password = ( string )( $_POST [ 'password' ] ? ? '' );
$role = trim (( string )( $_POST [ 'role' ] ? ? 'editor' ));
if ( $name === '' || $email === '' || $password === '' ) {
return new Response ( $this -> view -> render ( 'account_new.php' , [
'title' => 'New Account' ,
'error' => 'Name, email, and password are required.' ,
]));
}
if ( ! in_array ( $role , [ 'admin' , 'manager' , 'editor' ], true )) {
$role = 'editor' ;
}
$db = Database :: get ();
if ( ! $db instanceof PDO ) {
return new Response ( '' , 302 , [ 'Location' => '/admin/accounts' ]);
}
try {
$hash = password_hash ( $password , PASSWORD_DEFAULT );
$stmt = $db -> prepare ( "
INSERT INTO ac_admin_users ( name , email , password_hash , role )
VALUES ( : name , : email , : hash , : role )
" );
$stmt -> execute ([
':name' => $name ,
':email' => $email ,
':hash' => $hash ,
':role' => $role ,
]);
} catch ( Throwable $e ) {
return new Response ( $this -> view -> render ( 'account_new.php' , [
'title' => 'New Account' ,
'error' => 'Unable to create account (email may exist).' ,
]));
}
return new Response ( '' , 302 , [ 'Location' => '/admin/accounts' ]);
}
public function accountsDelete () : Response
{
if ( $guard = $this -> guard ([ 'admin' ])) {
return $guard ;
}
$id = ( int )( $_POST [ 'id' ] ? ? 0 );
$db = Database :: get ();
if ( $db instanceof PDO && $id > 0 ) {
$stmt = $db -> prepare ( " DELETE FROM ac_admin_users WHERE id = :id " );
$stmt -> execute ([ ':id' => $id ]);
}
return new Response ( '' , 302 , [ 'Location' => '/admin/accounts' ]);
}
public function updatesForm () : Response
{
if ( $guard = $this -> guard ([ 'admin' ])) {
return $guard ;
}
$force = (( string )( $_GET [ 'force' ] ? ? '' ) === '1' );
$status = Updater :: getStatus ( $force );
return new Response ( $this -> view -> render ( 'updates.php' , [
'title' => 'Updates' ,
'status' => $status ,
'channel' => Settings :: get ( 'update_channel' , 'stable' ),
'message' => trim (( string )( $_GET [ 'message' ] ? ? '' )),
'message_type' => trim (( string )( $_GET [ 'message_type' ] ? ? '' )),
]));
}
public function updatesSave () : Response
{
if ( $guard = $this -> guard ([ 'admin' ])) {
return $guard ;
}
$action = trim (( string )( $_POST [ 'updates_action' ] ? ? '' ));
if ( $action === 'save_config' ) {
$channel = trim (( string )( $_POST [ 'update_channel' ] ? ? 'stable' ));
if ( ! in_array ( $channel , [ 'stable' , 'beta' ], true )) {
$channel = 'stable' ;
}
Settings :: set ( 'update_channel' , $channel );
Audit :: log ( 'updates.config.save' , [
'channel' => $channel ,
]);
return new Response ( '' , 302 , [ 'Location' => '/admin/updates?message=' . rawurlencode ( 'Update settings saved.' ) . '&message_type=ok' ]);
}
if ( $action === 'check_now' ) {
Updater :: getStatus ( true );
Audit :: log ( 'updates.check.now' );
return new Response ( '' , 302 , [ 'Location' => '/admin/updates?force=1&message=' . rawurlencode ( 'Update check complete.' ) . '&message_type=ok' ]);
}
return new Response ( '' , 302 , [ 'Location' => '/admin/updates?message=' . rawurlencode ( 'Unknown action.' ) . '&message_type=error' ]);
}
public function settingsForm () : Response
{
if ( $guard = $this -> guard ([ 'admin' ])) {
return $guard ;
}
$this -> ensureCoreTables ();
$this -> ensureSettingsAuxTables ();
$status = trim (( string )( $_GET [ 'status' ] ? ? '' ));
$statusMessage = trim (( string )( $_GET [ 'message' ] ? ? '' ));
$db = Database :: get ();
$redirects = [];
if ( $db instanceof PDO ) {
try {
$stmt = $db -> query ( "
SELECT id , source_path , target_url , status_code , is_active , updated_at
FROM ac_redirects
ORDER BY source_path ASC
" );
$redirects = $stmt ? $stmt -> fetchAll ( PDO :: FETCH_ASSOC ) : [];
} catch ( Throwable $e ) {
$redirects = [];
}
}
return new Response ( $this -> view -> render ( 'settings.php' , [
'title' => 'Settings' ,
'status' => $status ,
'status_message' => $statusMessage ,
2026-04-01 14:12:17 +00:00
'footer_text' => Settings :: get ( 'footer_text' , 'AudioCore V1.5.1' ),
2026-03-04 20:46:11 +00:00
'footer_links' => $this -> parseFooterLinks ( Settings :: get ( 'footer_links_json' , '[]' )),
2026-04-01 14:12:17 +00:00
'site_header_title' => Settings :: get ( 'site_header_title' , 'AudioCore V1.5.1' ),
2026-03-04 20:46:11 +00:00
'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' ),
'site_header_mark_mode' => Settings :: get ( 'site_header_mark_mode' , 'text' ),
'site_header_mark_text' => Settings :: get ( 'site_header_mark_text' , 'AC' ),
'site_header_mark_icon' => Settings :: get ( 'site_header_mark_icon' , 'fa-solid fa-music' ),
'site_header_mark_bg_start' => Settings :: get ( 'site_header_mark_bg_start' , '#22f2a5' ),
'site_header_mark_bg_end' => Settings :: get ( 'site_header_mark_bg_end' , '#10252e' ),
'site_header_logo_url' => Settings :: get ( 'site_header_logo_url' , '' ),
'fontawesome_url' => Settings :: get ( 'fontawesome_url' , '' ),
'fontawesome_pro_url' => Settings :: get ( 'fontawesome_pro_url' , '' ),
'site_maintenance_enabled' => Settings :: get ( 'site_maintenance_enabled' , '0' ),
'site_maintenance_title' => Settings :: get ( 'site_maintenance_title' , 'Coming Soon' ),
'site_maintenance_message' => Settings :: get ( 'site_maintenance_message' , 'We are currently updating the site. Please check back soon.' ),
'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' , '' ),
2026-04-01 14:12:17 +00:00
'site_maintenance_access_password_enabled' => Settings :: get ( 'site_maintenance_access_password_hash' , '' ) !== '' ? '1' : '0' ,
2026-03-04 20:46:11 +00:00
'smtp_host' => Settings :: get ( 'smtp_host' , '' ),
'smtp_port' => Settings :: get ( 'smtp_port' , '587' ),
'smtp_user' => Settings :: get ( 'smtp_user' , '' ),
'smtp_pass' => Settings :: get ( 'smtp_pass' , '' ),
'smtp_encryption' => Settings :: get ( 'smtp_encryption' , 'tls' ),
'smtp_from_email' => Settings :: get ( 'smtp_from_email' , '' ),
'smtp_from_name' => Settings :: get ( 'smtp_from_name' , '' ),
'mailchimp_api_key' => Settings :: get ( 'mailchimp_api_key' , '' ),
'mailchimp_list_id' => Settings :: get ( 'mailchimp_list_id' , '' ),
2026-04-01 14:12:17 +00:00
'seo_title_suffix' => Settings :: get ( 'seo_title_suffix' , Settings :: get ( 'site_title' , 'AudioCore V1.5.1' )),
2026-03-04 20:46:11 +00:00
'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' ),
'seo_og_image' => Settings :: get ( 'seo_og_image' , '' ),
2026-03-05 17:09:01 +00:00
'site_custom_css' => Settings :: get ( 'site_custom_css' , '' ),
2026-03-04 20:46:11 +00:00
'redirects' => $redirects ,
'permission_definitions' => Permissions :: definitions (),
'permission_matrix' => Permissions :: matrix (),
'audit_logs' => Audit :: latest ( 120 ),
]));
}
public function shortcodesIndex () : Response
{
if ( $guard = $this -> guard ([ 'admin' , 'manager' , 'editor' ])) {
return $guard ;
}
Plugins :: sync ();
$codes = [
[
'tag' => '[releases]' ,
'description' => 'Outputs the releases grid.' ,
'example' => '[releases limit="8"]' ,
'source' => 'Releases plugin' ,
'enabled' => Plugins :: isEnabled ( 'releases' ),
],
2026-03-05 17:09:01 +00:00
[
'tag' => '[latest-releases]' ,
'description' => 'Home-friendly alias of releases grid.' ,
'example' => '[latest-releases limit="8"]' ,
'source' => 'Releases plugin' ,
'enabled' => Plugins :: isEnabled ( 'releases' ),
],
[
'tag' => '[new-artists]' ,
'description' => 'Outputs the latest active artists grid.' ,
'example' => '[new-artists limit="6"]' ,
'source' => 'Artists plugin' ,
'enabled' => Plugins :: isEnabled ( 'artists' ),
],
2026-03-04 20:46:11 +00:00
[
'tag' => '[sale-chart]' ,
'description' => 'Outputs a best-sellers chart.' ,
'example' => '[sale-chart limit="10"]' ,
'source' => 'Store plugin' ,
'enabled' => Plugins :: isEnabled ( 'store' ),
],
2026-03-05 17:09:01 +00:00
[
'tag' => '[top-sellers]' ,
'description' => 'Alias for sale chart block.' ,
'example' => '[top-sellers type="tracks" window="weekly" limit="10"]' ,
'source' => 'Store plugin' ,
'enabled' => Plugins :: isEnabled ( 'store' ),
],
[
'tag' => '[hero]' ,
'description' => 'Home hero block with CTA buttons.' ,
'example' => '[hero title="Latest Drops" subtitle="Fresh releases weekly" cta_text="Browse Releases" cta_url="/releases"]' ,
'source' => 'Pages module' ,
'enabled' => true ,
],
2026-03-05 20:15:48 +00:00
[
'tag' => '[home-catalog]' ,
'description' => 'Complete homepage catalog block (hero + releases + chart + artists + newsletter).' ,
'example' => '[home-catalog release_limit="8" artist_limit="6" chart_limit="10"]' ,
'source' => 'Pages module' ,
'enabled' => true ,
],
2026-03-04 20:46:11 +00:00
[
'tag' => '[login-link]' ,
'description' => 'Renders an account login link.' ,
'example' => '[login-link label="Login"]' ,
'source' => 'Store plugin' ,
'enabled' => Plugins :: isEnabled ( 'store' ),
],
[
'tag' => '[account-link]' ,
'description' => 'Renders a my account link.' ,
'example' => '[account-link label="My Account"]' ,
'source' => 'Store plugin' ,
'enabled' => Plugins :: isEnabled ( 'store' ),
],
[
'tag' => '[cart-link]' ,
'description' => 'Renders a cart link with count/total.' ,
'example' => '[cart-link label="Cart" show_count="1" show_total="1"]' ,
'source' => 'Store plugin' ,
'enabled' => Plugins :: isEnabled ( 'store' ),
],
[
'tag' => '[checkout-link]' ,
'description' => 'Renders a checkout link.' ,
'example' => '[checkout-link label="Checkout"]' ,
'source' => 'Store plugin' ,
'enabled' => Plugins :: isEnabled ( 'store' ),
],
[
'tag' => '[newsletter-signup]' ,
'description' => 'Renders newsletter signup form.' ,
'example' => '[newsletter-signup title="Join the list" button="Subscribe"]' ,
'source' => 'Newsletter module' ,
'enabled' => true ,
],
[
'tag' => '[newsletter-unsubscribe]' ,
'description' => 'Renders unsubscribe link.' ,
'example' => '[newsletter-unsubscribe label="Unsubscribe"]' ,
'source' => 'Newsletter module' ,
'enabled' => true ,
],
[
'tag' => '[newsletter-unsubscribe-form]' ,
'description' => 'Renders unsubscribe by email form.' ,
'example' => '[newsletter-unsubscribe-form title="Leave list"]' ,
'source' => 'Newsletter module' ,
'enabled' => true ,
],
[
'tag' => '[support-link]' ,
'description' => 'Renders support/contact link.' ,
'example' => '[support-link label="Support"]' ,
'source' => 'Support plugin' ,
'enabled' => Plugins :: isEnabled ( 'support' ),
],
];
$storeChartKey = trim (( string ) Settings :: get ( 'store_sales_chart_cron_key' , '' ));
if ( $storeChartKey === '' && Plugins :: isEnabled ( 'store' )) {
try {
$storeChartKey = bin2hex ( random_bytes ( 24 ));
Settings :: set ( 'store_sales_chart_cron_key' , $storeChartKey );
} catch ( Throwable $e ) {
$storeChartKey = '' ;
}
}
$baseUrl = $this -> baseUrl ();
$storeCronUrl = ( $baseUrl !== '' && $storeChartKey !== '' )
? $baseUrl . '/store/sales-chart/rebuild?key=' . rawurlencode ( $storeChartKey )
: '' ;
$minutes = max ( 5 , min ( 1440 , ( int ) Settings :: get ( 'store_sales_chart_refresh_minutes' , '180' )));
$step = max ( 1 , ( int ) floor ( $minutes / 60 ));
$storeCronExpr = $minutes < 60 ? '*/' . $minutes . ' * * * *' : '0 */' . $step . ' * * *' ;
$storeCronCmd = $storeCronUrl !== '' ? $storeCronExpr . " /usr/bin/curl -fsS ' " . $storeCronUrl . " ' >/dev/null 2>&1 " : '' ;
return new Response ( $this -> view -> render ( 'shortcodes.php' , [
'title' => 'Shortcodes' ,
'codes' => $codes ,
'sale_chart_cron_url' => $storeCronUrl ,
'sale_chart_cron_cmd' => $storeCronCmd ,
'sale_chart_cron_enabled' => Plugins :: isEnabled ( 'store' ),
]));
}
public function shortcodesPreview () : Response
{
if ( $guard = $this -> guard ([ 'admin' , 'manager' , 'editor' ])) {
return $guard ;
}
$code = trim (( string )( $_GET [ 'code' ] ? ? '' ));
if ( $code === '' ) {
return new Response ( '<p>No shortcode supplied.</p>' , 400 );
}
$allowedTags = [
'releases' ,
2026-03-05 17:09:01 +00:00
'latest-releases' ,
'new-artists' ,
2026-03-04 20:46:11 +00:00
'sale-chart' ,
2026-03-05 17:09:01 +00:00
'top-sellers' ,
'hero' ,
2026-03-05 20:15:48 +00:00
'home-catalog' ,
2026-03-04 20:46:11 +00:00
'login-link' ,
'account-link' ,
'cart-link' ,
'checkout-link' ,
'newsletter-signup' ,
'newsletter-unsubscribe' ,
'newsletter-unsubscribe-form' ,
'support-link' ,
];
$tag = '' ;
if ( preg_match ( '/^\[\s*([a-zA-Z0-9_-]+)/' , $code , $m )) {
$tag = strtolower (( string ) $m [ 1 ]);
}
$isAllowed = in_array ( $tag , $allowedTags , true );
if ( ! $isAllowed ) {
return new Response ( '<p>Shortcode preview not allowed.</p>' , 403 );
}
$rendered = Shortcodes :: render ( $code , [ 'preview' => true ]);
$html = '<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">'
. '<title>Shortcode Preview</title>'
. '<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>'
. '<link href="https://fonts.googleapis.com/css2?family=Syne:wght@500;600;700&family=IBM+Plex+Mono:wght@400;600&display=swap" rel="stylesheet">'
. '<style>'
. 'body{margin:0;padding:20px;background:#14151a;color:#f5f7ff;font-family:Syne,sans-serif;}'
. '.preview-shell{max-width:1080px;margin:0 auto;border:1px solid rgba(255,255,255,.12);border-radius:16px;background:rgba(20,22,28,.72);padding:18px;}'
. '.preview-head{font-family:"IBM Plex Mono",monospace;font-size:11px;text-transform:uppercase;letter-spacing:.18em;color:rgba(255,255,255,.55);margin-bottom:14px;}'
. '.ac-shortcode-empty{border:1px solid rgba(255,255,255,0.12);background:rgba(255,255,255,0.02);border-radius:14px;padding:12px 14px;color:#9aa0b2;font-size:13px;}'
2026-03-05 17:36:16 +00:00
. '.ac-shortcode-release-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(190px,240px));justify-content:start;gap:12px;}'
2026-03-04 20:46:11 +00:00
. '.ac-shortcode-release-card{text-decoration:none;color:inherit;border:1px solid rgba(255,255,255,0.1);background:rgba(15,18,24,0.6);border-radius:14px;overflow:hidden;display:grid;min-height:100%;}'
. '.ac-shortcode-release-cover{aspect-ratio:1/1;background:rgba(255,255,255,0.03);display:grid;place-items:center;overflow:hidden;}'
. '.ac-shortcode-release-cover img{width:100%;height:100%;object-fit:cover;display:block;}'
. '.ac-shortcode-cover-fallback{color:#9aa0b2;font-family:"IBM Plex Mono",monospace;font-size:12px;letter-spacing:.2em;}'
. '.ac-shortcode-release-meta{padding:10px;display:grid;gap:4px;}'
. '.ac-shortcode-release-title{font-size:18px;line-height:1.2;font-weight:600;}'
. '.ac-shortcode-release-artist,.ac-shortcode-release-date{color:#9aa0b2;font-size:12px;}'
2026-03-05 17:36:16 +00:00
. '.ac-shortcode-artists-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(190px,240px));justify-content:start;gap:12px;}'
2026-03-05 17:09:01 +00:00
. '.ac-shortcode-artist-card{text-decoration:none;color:inherit;border:1px solid rgba(255,255,255,.1);background:rgba(15,18,24,.6);border-radius:14px;overflow:hidden;display:grid;}'
. '.ac-shortcode-artist-avatar{aspect-ratio:1/1;background:rgba(255,255,255,.03);display:grid;place-items:center;overflow:hidden;}'
. '.ac-shortcode-artist-avatar img{width:100%;height:100%;object-fit:cover;display:block;}'
. '.ac-shortcode-artist-meta{padding:10px;display:grid;gap:4px;}'
. '.ac-shortcode-artist-name{font-size:18px;line-height:1.2;font-weight:600;}'
. '.ac-shortcode-artist-country{color:#9aa0b2;font-size:12px;}'
2026-03-04 20:46:11 +00:00
. '.ac-shortcode-sale-list{list-style:none;margin:0;padding:0;display:grid;gap:8px;}'
. '.ac-shortcode-sale-item{border:1px solid rgba(255,255,255,0.1);background:rgba(15,18,24,0.6);border-radius:10px;padding:10px 12px;display:grid;grid-template-columns:auto 1fr auto;gap:10px;align-items:center;}'
. '.ac-shortcode-sale-rank{font-family:"IBM Plex Mono",monospace;font-size:11px;color:#9aa0b2;letter-spacing:.15em;}'
. '.ac-shortcode-sale-title{font-size:14px;line-height:1.3;}'
. '.ac-shortcode-sale-meta{font-size:12px;color:#9aa0b2;white-space:nowrap;}'
2026-03-05 17:09:01 +00:00
. '.ac-shortcode-hero{border:1px solid rgba(255,255,255,.14);border-radius:18px;padding:18px;background:linear-gradient(135deg,rgba(255,255,255,.05),rgba(255,255,255,.01));display:grid;gap:10px;}'
. '.ac-shortcode-hero-eyebrow{font-size:10px;letter-spacing:.24em;text-transform:uppercase;color:#9aa0b2;font-family:"IBM Plex Mono",monospace;}'
. '.ac-shortcode-hero-title{font-size:32px;line-height:1.05;font-weight:700;}'
. '.ac-shortcode-hero-subtitle{font-size:15px;color:#d1d7e7;max-width:72ch;}'
. '.ac-shortcode-hero-actions{display:flex;gap:8px;flex-wrap:wrap;}'
. '.ac-shortcode-hero-btn{display:inline-flex;align-items:center;justify-content:center;height:38px;padding:0 14px;border-radius:999px;border:1px solid rgba(255,255,255,.16);background:rgba(255,255,255,.05);color:#f5f7ff;text-decoration:none;font-size:11px;letter-spacing:.16em;text-transform:uppercase;font-family:"IBM Plex Mono",monospace;}'
. '.ac-shortcode-hero-btn.primary{border-color:rgba(57,244,179,.6);background:rgba(57,244,179,.16);color:#9ff8d8;}'
2026-03-04 20:46:11 +00:00
. '.ac-shortcode-link{display:inline-flex;align-items:center;gap:8px;padding:10px 14px;border-radius:12px;border:1px solid rgba(255,255,255,.15);background:rgba(15,18,24,.6);color:#f5f7ff;text-decoration:none;font-size:13px;letter-spacing:.08em;text-transform:uppercase;}'
. '.ac-shortcode-link:hover{border-color:rgba(57,244,179,.6);color:#9ff8d8;}'
. '.ac-shortcode-newsletter-form{display:grid;gap:10px;border:1px solid rgba(255,255,255,.15);border-radius:14px;background:rgba(15,18,24,.6);padding:14px;}'
. '.ac-shortcode-newsletter-title{font-size:13px;letter-spacing:.14em;text-transform:uppercase;color:#9aa0b2;font-family:"IBM Plex Mono",monospace;}'
. '.ac-shortcode-newsletter-row{display:grid;grid-template-columns:1fr auto;gap:8px;}'
. '.ac-shortcode-newsletter-input{height:40px;border:1px solid rgba(255,255,255,.16);border-radius:10px;background:rgba(8,10,16,.6);color:#f5f7ff;padding:0 12px;font-size:14px;}'
. '.ac-shortcode-newsletter-btn{height:40px;padding:0 14px;border:1px solid rgba(57,244,179,.6);border-radius:999px;background:rgba(57,244,179,.16);color:#9ff8d8;font-size:12px;letter-spacing:.14em;text-transform:uppercase;font-family:"IBM Plex Mono",monospace;cursor:pointer;}'
2026-03-05 20:15:48 +00:00
. '.ac-home-catalog{display:grid;gap:14px;}'
. '.ac-home-columns{display:grid;grid-template-columns:minmax(0,2.2fr) minmax(280px,1fr);gap:14px;align-items:start;}'
. '.ac-home-main,.ac-home-side{display:grid;gap:14px;align-content:start;}'
. '@media (max-width:1200px){.ac-home-columns{grid-template-columns:1fr;}}'
2026-03-04 20:46:11 +00:00
. '</style></head><body>'
. '<div class="preview-shell"><div class="preview-head">' . htmlspecialchars ( $code , ENT_QUOTES , 'UTF-8' ) . '</div>'
. $rendered
. '</div></body></html>' ;
return new Response ( $html );
}
public function saveSettings () : Response
{
if ( $guard = $this -> guard ([ 'admin' ])) {
return $guard ;
}
$this -> ensureCoreTables ();
$this -> ensureSettingsAuxTables ();
$action = trim (( string )( $_POST [ 'settings_action' ] ? ? '' ));
if ( $action === 'upload_logo' ) {
return $this -> uploadHeaderLogo ();
}
if ( $action === 'remove_logo' ) {
Settings :: set ( 'site_header_logo_url' , '' );
Audit :: log ( 'settings.logo.remove' );
return new Response ( '' , 302 , [ 'Location' => '/admin/settings?status=ok&message=' . rawurlencode ( 'Logo removed.' )]);
}
if ( $action === 'save_redirect' ) {
return $this -> saveRedirect ();
}
if ( $action === 'delete_redirect' ) {
return $this -> deleteRedirect ();
}
if ( $action === 'save_permissions' ) {
Permissions :: saveMatrix (( array )( $_POST [ 'permissions' ] ? ? []));
Audit :: log ( 'settings.permissions.save' );
return new Response ( '' , 302 , [ 'Location' => '/admin/settings?status=ok&message=' . rawurlencode ( 'Role permissions updated.' )]);
}
$footer = trim (( string )( $_POST [ 'footer_text' ] ? ? '' ));
$footerLinksJson = ( string )( $_POST [ 'footer_links_json' ] ? ? '[]' );
$siteHeaderTitle = trim (( string )( $_POST [ 'site_header_title' ] ? ? '' ));
$siteHeaderTagline = trim (( string )( $_POST [ 'site_header_tagline' ] ? ? '' ));
$siteHeaderBadgeText = trim (( string )( $_POST [ 'site_header_badge_text' ] ? ? '' ));
$siteHeaderBrandMode = trim (( string )( $_POST [ 'site_header_brand_mode' ] ? ? 'default' ));
$siteHeaderMarkMode = trim (( string )( $_POST [ 'site_header_mark_mode' ] ? ? 'text' ));
$siteHeaderMarkText = trim (( string )( $_POST [ 'site_header_mark_text' ] ? ? '' ));
$siteHeaderMarkIcon = trim (( string )( $_POST [ 'site_header_mark_icon' ] ? ? '' ));
$siteHeaderMarkBgStart = trim (( string )( $_POST [ 'site_header_mark_bg_start' ] ? ? '' ));
$siteHeaderMarkBgEnd = trim (( string )( $_POST [ 'site_header_mark_bg_end' ] ? ? '' ));
$siteHeaderLogoUrl = trim (( string )( $_POST [ 'site_header_logo_url' ] ? ? '' ));
$faUrl = trim (( string )( $_POST [ 'fontawesome_url' ] ? ? '' ));
$faProUrl = trim (( string )( $_POST [ 'fontawesome_pro_url' ] ? ? '' ));
$maintenanceEnabled = isset ( $_POST [ 'site_maintenance_enabled' ]) ? '1' : '0' ;
$maintenanceTitle = trim (( string )( $_POST [ 'site_maintenance_title' ] ? ? '' ));
$maintenanceMessage = trim (( string )( $_POST [ 'site_maintenance_message' ] ? ? '' ));
$maintenanceButtonLabel = trim (( string )( $_POST [ 'site_maintenance_button_label' ] ? ? '' ));
$maintenanceButtonUrl = trim (( string )( $_POST [ 'site_maintenance_button_url' ] ? ? '' ));
$maintenanceHtml = trim (( string )( $_POST [ 'site_maintenance_html' ] ? ? '' ));
2026-04-01 14:12:17 +00:00
$maintenanceAccessPassword = trim (( string )( $_POST [ 'site_maintenance_access_password' ] ? ? '' ));
$maintenanceAccessPasswordClear = isset ( $_POST [ 'site_maintenance_access_password_clear' ]);
2026-03-04 20:46:11 +00:00
$smtpHost = trim (( string )( $_POST [ 'smtp_host' ] ? ? '' ));
$smtpPort = trim (( string )( $_POST [ 'smtp_port' ] ? ? '' ));
$smtpUser = trim (( string )( $_POST [ 'smtp_user' ] ? ? '' ));
$smtpPass = trim (( string )( $_POST [ 'smtp_pass' ] ? ? '' ));
$smtpEncryption = trim (( string )( $_POST [ 'smtp_encryption' ] ? ? '' ));
$smtpFromEmail = trim (( string )( $_POST [ 'smtp_from_email' ] ? ? '' ));
$smtpFromName = trim (( string )( $_POST [ 'smtp_from_name' ] ? ? '' ));
$mailchimpKey = trim (( string )( $_POST [ 'mailchimp_api_key' ] ? ? '' ));
$mailchimpList = trim (( string )( $_POST [ 'mailchimp_list_id' ] ? ? '' ));
$seoTitleSuffix = trim (( string )( $_POST [ 'seo_title_suffix' ] ? ? '' ));
$seoMetaDescription = trim (( string )( $_POST [ 'seo_meta_description' ] ? ? '' ));
$seoRobotsIndex = isset ( $_POST [ 'seo_robots_index' ]) ? '1' : '0' ;
$seoRobotsFollow = isset ( $_POST [ 'seo_robots_follow' ]) ? '1' : '0' ;
$seoOgImage = trim (( string )( $_POST [ 'seo_og_image' ] ? ? '' ));
2026-03-05 17:09:01 +00:00
$siteCustomCss = trim (( string )( $_POST [ 'site_custom_css' ] ? ? '' ));
2026-03-04 20:46:11 +00:00
Settings :: set ( 'footer_text' , $footer );
$footerLinks = $this -> parseFooterLinks ( $footerLinksJson );
Settings :: set ( 'footer_links_json' , json_encode ( $footerLinks , JSON_UNESCAPED_SLASHES ));
Settings :: set ( 'site_header_title' , $siteHeaderTitle );
Settings :: set ( 'site_header_tagline' , $siteHeaderTagline );
Settings :: set ( 'site_header_badge_text' , $siteHeaderBadgeText );
Settings :: set ( 'site_header_brand_mode' , in_array ( $siteHeaderBrandMode , [ 'default' , 'logo_only' ], true ) ? $siteHeaderBrandMode : 'default' );
Settings :: set ( 'site_header_mark_mode' , in_array ( $siteHeaderMarkMode , [ 'text' , 'icon' , 'logo' ], true ) ? $siteHeaderMarkMode : 'text' );
Settings :: set ( 'site_header_mark_text' , $siteHeaderMarkText );
if ( $siteHeaderMarkIcon !== '' ) {
if ( preg_match ( '/class\\s*=\\s*"([^"]+)"/i' , $siteHeaderMarkIcon , $m )) {
$siteHeaderMarkIcon = trim (( string ) $m [ 1 ]);
}
$siteHeaderMarkIcon = trim ( strip_tags ( $siteHeaderMarkIcon ));
}
Settings :: set ( 'site_header_mark_icon' , $siteHeaderMarkIcon );
Settings :: set ( 'site_header_mark_bg_start' , $siteHeaderMarkBgStart );
Settings :: set ( 'site_header_mark_bg_end' , $siteHeaderMarkBgEnd );
Settings :: set ( 'site_header_logo_url' , $siteHeaderLogoUrl );
Settings :: set ( 'fontawesome_url' , $faUrl );
Settings :: set ( 'fontawesome_pro_url' , $faProUrl );
Settings :: set ( 'site_maintenance_enabled' , $maintenanceEnabled );
Settings :: set ( 'site_maintenance_title' , $maintenanceTitle );
Settings :: set ( 'site_maintenance_message' , $maintenanceMessage );
Settings :: set ( 'site_maintenance_button_label' , $maintenanceButtonLabel );
Settings :: set ( 'site_maintenance_button_url' , $maintenanceButtonUrl );
Settings :: set ( 'site_maintenance_html' , $maintenanceHtml );
2026-04-01 14:12:17 +00:00
if ( $maintenanceAccessPasswordClear ) {
Settings :: set ( 'site_maintenance_access_password_hash' , '' );
} elseif ( $maintenanceAccessPassword !== '' ) {
Settings :: set ( 'site_maintenance_access_password_hash' , password_hash ( $maintenanceAccessPassword , PASSWORD_DEFAULT ));
}
2026-03-04 20:46:11 +00:00
Settings :: set ( 'smtp_host' , $smtpHost );
Settings :: set ( 'smtp_port' , $smtpPort );
Settings :: set ( 'smtp_user' , $smtpUser );
Settings :: set ( 'smtp_pass' , $smtpPass );
Settings :: set ( 'smtp_encryption' , $smtpEncryption );
Settings :: set ( 'smtp_from_email' , $smtpFromEmail );
Settings :: set ( 'smtp_from_name' , $smtpFromName );
Settings :: set ( 'mailchimp_api_key' , $mailchimpKey );
Settings :: set ( 'mailchimp_list_id' , $mailchimpList );
Settings :: set ( 'seo_title_suffix' , $seoTitleSuffix );
Settings :: set ( 'seo_meta_description' , $seoMetaDescription );
Settings :: set ( 'seo_robots_index' , $seoRobotsIndex );
Settings :: set ( 'seo_robots_follow' , $seoRobotsFollow );
Settings :: set ( 'seo_og_image' , $seoOgImage );
2026-03-05 17:09:01 +00:00
Settings :: set ( 'site_custom_css' , $siteCustomCss );
2026-03-04 20:46:11 +00:00
Audit :: log ( 'settings.save' , [
'updated_keys' => [
'footer_text' , 'footer_links_json' , 'site_header_*' , 'fontawesome_*' ,
2026-03-05 17:09:01 +00:00
'site_maintenance_*' , 'smtp_*' , 'mailchimp_*' , 'seo_*' , 'site_custom_css' ,
2026-03-04 20:46:11 +00:00
],
]);
return new Response ( '' , 302 , [ 'Location' => '/admin/settings' ]);
}
private function installSetupCore () : Response
{
$dbHost = trim (( string )( $_POST [ 'db_host' ] ? ? 'localhost' ));
$dbName = trim (( string )( $_POST [ 'db_name' ] ? ? '' ));
$dbUser = trim (( string )( $_POST [ 'db_user' ] ? ? '' ));
$dbPass = ( string )( $_POST [ 'db_pass' ] ? ? '' );
$dbPort = ( int )( $_POST [ 'db_port' ] ? ? 3306 );
$adminName = trim (( string )( $_POST [ 'admin_name' ] ? ? 'Admin' ));
$adminEmail = strtolower ( trim (( string )( $_POST [ 'admin_email' ] ? ? '' )));
$adminPass = ( string )( $_POST [ 'admin_password' ] ? ? '' );
$defaults = $this -> installerDefaultValues ();
$values = [
'db_host' => $dbHost ,
'db_name' => $dbName ,
'db_user' => $dbUser ,
'db_port' => ( string ) $dbPort ,
'admin_name' => $adminName !== '' ? $adminName : 'Admin' ,
'admin_email' => $adminEmail ,
'site_title' => $defaults [ 'site_title' ],
'site_tagline' => $defaults [ 'site_tagline' ],
'seo_title_suffix' => $defaults [ 'seo_title_suffix' ],
'seo_meta_description' => $defaults [ 'seo_meta_description' ],
'smtp_host' => $defaults [ 'smtp_host' ],
'smtp_port' => $defaults [ 'smtp_port' ],
'smtp_user' => $defaults [ 'smtp_user' ],
'smtp_pass' => $defaults [ 'smtp_pass' ],
'smtp_encryption' => $defaults [ 'smtp_encryption' ],
'smtp_from_email' => $defaults [ 'smtp_from_email' ],
'smtp_from_name' => $defaults [ 'smtp_from_name' ],
'smtp_test_email' => $adminEmail ,
];
if ( $dbName === '' || $dbUser === '' || $adminEmail === '' || $adminPass === '' ) {
$_SESSION [ 'installer' ] = [
'core_ready' => false ,
'values' => $values ,
];
return new Response ( '' , 302 , [ 'Location' => '/admin/installer?error=' . rawurlencode ( 'Please fill all required fields.' )]);
}
if ( ! filter_var ( $adminEmail , FILTER_VALIDATE_EMAIL )) {
$_SESSION [ 'installer' ] = [
'core_ready' => false ,
'values' => $values ,
];
return new Response ( '' , 302 , [ 'Location' => '/admin/installer?error=' . rawurlencode ( 'Admin email is not valid.' )]);
}
if ( strlen ( $adminPass ) < 8 ) {
$_SESSION [ 'installer' ] = [
'core_ready' => false ,
'values' => $values ,
];
return new Response ( '' , 302 , [ 'Location' => '/admin/installer?error=' . rawurlencode ( 'Admin password must be at least 8 characters.' )]);
}
$config = " <?php \n return [ \n "
. " 'host' => ' " . addslashes ( $dbHost ) . " ', \n "
. " 'database' => ' " . addslashes ( $dbName ) . " ', \n "
. " 'user' => ' " . addslashes ( $dbUser ) . " ', \n "
. " 'pass' => ' " . addslashes ( $dbPass ) . " ', \n "
. " 'port' => " . ( int ) $dbPort . " , \n "
. " ]; \n " ;
2026-03-04 22:40:59 +00:00
$storageDir = __DIR__ . '/../../storage' ;
if ( ! is_dir ( $storageDir )) {
if ( !@ mkdir ( $storageDir , 0775 , true ) && ! is_dir ( $storageDir )) {
return new Response ( '' , 302 , [ 'Location' => '/admin/installer?error=' . rawurlencode ( 'Unable to create storage directory.' )]);
}
}
$settingsPath = $storageDir . '/settings.php' ;
if ( ! is_file ( $settingsPath )) {
2026-04-01 14:12:17 +00:00
$settingsSeed = " <?php \n return [ \n 'site_title' => 'AudioCore V1.5.1', \n ]; \n " ;
2026-03-04 22:40:59 +00:00
@ file_put_contents ( $settingsPath , $settingsSeed );
}
$configPath = $storageDir . '/db.php' ;
2026-03-04 20:46:11 +00:00
if ( file_put_contents ( $configPath , $config ) === false ) {
return new Response ( '' , 302 , [ 'Location' => '/admin/installer?error=' . rawurlencode ( 'Unable to write DB config file.' )]);
}
try {
$pdo = $this -> connectInstallerDb ( $dbHost , $dbName , $dbUser , $dbPass , $dbPort );
} catch ( Throwable $e ) {
$_SESSION [ 'installer' ] = [
'core_ready' => false ,
'values' => $values ,
];
return new Response ( '' , 302 , [ 'Location' => '/admin/installer?error=' . rawurlencode ( 'Database connection failed. Check credentials.' )]);
}
try {
$this -> createInstallerTables ( $pdo );
} catch ( Throwable $e ) {
$_SESSION [ 'installer' ] = [
'core_ready' => false ,
'values' => $values ,
];
return new Response ( '' , 302 , [ 'Location' => '/admin/installer?error=' . rawurlencode ( 'Unable to create core tables.' )]);
}
$hash = password_hash ( $adminPass , PASSWORD_DEFAULT );
$adminId = 0 ;
try {
$stmt = $pdo -> prepare ( " SELECT id FROM ac_admin_users WHERE email = :email LIMIT 1 " );
$stmt -> execute ([ ':email' => $adminEmail ]);
$existing = $stmt -> fetch ( PDO :: FETCH_ASSOC );
if ( $existing ) {
$adminId = ( int ) $existing [ 'id' ];
$update = $pdo -> prepare ( " UPDATE ac_admin_users SET name = :name, password_hash = :hash, role = 'admin' WHERE id = :id " );
$update -> execute ([
':name' => $adminName !== '' ? $adminName : 'Admin' ,
':hash' => $hash ,
':id' => $adminId ,
]);
} else {
$insert = $pdo -> prepare ( "
INSERT INTO ac_admin_users ( name , email , password_hash , role )
VALUES ( : name , : email , : hash , 'admin' )
" );
$insert -> execute ([
':name' => $adminName !== '' ? $adminName : 'Admin' ,
':email' => $adminEmail ,
':hash' => $hash ,
]);
$adminId = ( int ) $pdo -> lastInsertId ();
}
$stmt = $pdo -> prepare ( " SELECT id FROM ac_admins WHERE email = :email LIMIT 1 " );
$stmt -> execute ([ ':email' => $adminEmail ]);
$legacy = $stmt -> fetch ( PDO :: FETCH_ASSOC );
if ( $legacy ) {
$update = $pdo -> prepare ( " UPDATE ac_admins SET name = :name, password_hash = :hash WHERE id = :id " );
$update -> execute ([
':name' => $adminName !== '' ? $adminName : 'Admin' ,
':hash' => $hash ,
':id' => ( int ) $legacy [ 'id' ],
]);
} else {
$insert = $pdo -> prepare ( " INSERT INTO ac_admins (name, email, password_hash) VALUES (:name, :email, :hash) " );
$insert -> execute ([
':name' => $adminName !== '' ? $adminName : 'Admin' ,
':email' => $adminEmail ,
':hash' => $hash ,
]);
}
} catch ( Throwable $e ) {
return new Response ( '' , 302 , [ 'Location' => '/admin/installer?error=' . rawurlencode ( 'Unable to create admin account.' )]);
}
try {
$seed = $pdo -> prepare ( " REPLACE INTO ac_settings (setting_key, setting_value) VALUES (:k, :v) " );
$seed -> execute ([ ':k' => 'site_title' , ':v' => $defaults [ 'site_title' ]]);
$seed -> execute ([ ':k' => 'site_header_title' , ':v' => $defaults [ 'site_title' ]]);
$seed -> execute ([ ':k' => 'site_header_tagline' , ':v' => $defaults [ 'site_tagline' ]]);
$seed -> execute ([ ':k' => 'footer_text' , ':v' => $defaults [ 'site_title' ]]);
$seed -> execute ([ ':k' => 'seo_title_suffix' , ':v' => $defaults [ 'seo_title_suffix' ]]);
$seed -> execute ([ ':k' => 'seo_meta_description' , ':v' => $defaults [ 'seo_meta_description' ]]);
$seed -> execute ([ ':k' => 'seo_robots_index' , ':v' => '1' ]);
$seed -> execute ([ ':k' => 'seo_robots_follow' , ':v' => '1' ]);
$count = ( int ) $pdo -> query ( " SELECT COUNT(*) FROM ac_nav_links " ) -> fetchColumn ();
if ( $count === 0 ) {
$navInsert = $pdo -> prepare ( "
INSERT INTO ac_nav_links ( label , url , sort_order , is_active )
VALUES ( : label , : url , : sort_order , 1 )
" );
$navInsert -> execute ([ ':label' => 'Home' , ':url' => '/' , ':sort_order' => 1 ]);
$navInsert -> execute ([ ':label' => 'Artists' , ':url' => '/artists' , ':sort_order' => 2 ]);
$navInsert -> execute ([ ':label' => 'Releases' , ':url' => '/releases' , ':sort_order' => 3 ]);
$navInsert -> execute ([ ':label' => 'Store' , ':url' => '/store' , ':sort_order' => 4 ]);
$navInsert -> execute ([ ':label' => 'Contact' , ':url' => '/contact' , ':sort_order' => 5 ]);
}
} catch ( Throwable $e ) {
return new Response ( '' , 302 , [ 'Location' => '/admin/installer?error=' . rawurlencode ( 'Core setup completed but settings seed failed.' )]);
}
$_SESSION [ 'installer' ] = [
'core_ready' => true ,
'admin_id' => $adminId ,
'admin_name' => $adminName !== '' ? $adminName : 'Admin' ,
'db' => [
'host' => $dbHost ,
'name' => $dbName ,
'user' => $dbUser ,
'pass' => $dbPass ,
'port' => $dbPort ,
],
'values' => $values ,
'smtp_result' => [],
'checks' => [],
];
return new Response ( '' , 302 , [ 'Location' => '/admin/installer?success=' . rawurlencode ( 'Core setup complete. Configure SMTP and run a test email.' )]);
}
private function installTestSmtp () : Response
{
$installer = $_SESSION [ 'installer' ] ? ? [];
if ( empty ( $installer [ 'core_ready' ])) {
return new Response ( '' , 302 , [ 'Location' => '/admin/installer?error=' . rawurlencode ( 'Complete Step 1 first.' )]);
}
$values = $this -> installerSanitizedStepTwoValues (( array ) $_POST , ( array )( $installer [ 'values' ] ? ? []));
$testEmail = strtolower ( trim (( string )( $_POST [ 'smtp_test_email' ] ? ? '' )));
$values [ 'smtp_test_email' ] = $testEmail ;
if ( $testEmail === '' || ! filter_var ( $testEmail , FILTER_VALIDATE_EMAIL )) {
$installer [ 'values' ] = $values ;
$installer [ 'smtp_result' ] = [
'ok' => false ,
'message' => 'Enter a valid test recipient email.' ,
'debug' => '' ,
];
$_SESSION [ 'installer' ] = $installer ;
return new Response ( '' , 302 , [ 'Location' => '/admin/installer?error=' . rawurlencode ( 'SMTP test requires a valid recipient email.' )]);
}
$smtpSettings = [
'smtp_host' => $values [ 'smtp_host' ],
'smtp_port' => $values [ 'smtp_port' ],
'smtp_user' => $values [ 'smtp_user' ],
'smtp_pass' => $values [ 'smtp_pass' ],
'smtp_encryption' => $values [ 'smtp_encryption' ],
'smtp_from_email' => $values [ 'smtp_from_email' ],
'smtp_from_name' => $values [ 'smtp_from_name' ],
];
$subject = 'AudioCore installer SMTP test' ;
2026-04-01 14:12:17 +00:00
$html = '<h2>SMTP test successful</h2><p>Your AudioCore V1.5.1 installer SMTP settings are valid.</p>'
2026-03-04 20:46:11 +00:00
. '<p><strong>Generated:</strong> ' . gmdate ( 'Y-m-d H:i:s' ) . ' UTC</p>' ;
$mail = Mailer :: send ( $testEmail , $subject , $html , $smtpSettings );
$checks = $this -> installerHealthChecks (( array )( $installer [ 'db' ] ? ? []), $values );
$installer [ 'values' ] = $values ;
$installer [ 'smtp_result' ] = [
'ok' => ! empty ( $mail [ 'ok' ]),
'message' => ! empty ( $mail [ 'ok' ]) ? 'SMTP test email sent successfully.' : ( string )( $mail [ 'error' ] ? ? 'SMTP test failed.' ),
'debug' => ( string )( $mail [ 'debug' ] ? ? '' ),
'fingerprint' => hash ( 'sha256' , json_encode ( $smtpSettings )),
];
$installer [ 'checks' ] = $checks ;
$_SESSION [ 'installer' ] = $installer ;
if ( ! empty ( $mail [ 'ok' ])) {
return new Response ( '' , 302 , [ 'Location' => '/admin/installer?success=' . rawurlencode ( 'SMTP test passed. You can finish installation.' )]);
}
return new Response ( '' , 302 , [ 'Location' => '/admin/installer?error=' . rawurlencode ( 'SMTP test failed: ' . ( string )( $mail [ 'error' ] ? ? 'Unknown error' ))]);
}
private function installFinish () : Response
{
$installer = $_SESSION [ 'installer' ] ? ? [];
if ( empty ( $installer [ 'core_ready' ])) {
return new Response ( '' , 302 , [ 'Location' => '/admin/installer?error=' . rawurlencode ( 'Complete Step 1 first.' )]);
}
$values = $this -> installerSanitizedStepTwoValues (( array ) $_POST , ( array )( $installer [ 'values' ] ? ? []));
$values [ 'smtp_test_email' ] = strtolower ( trim (( string )( $_POST [ 'smtp_test_email' ] ? ? ( $values [ 'smtp_test_email' ] ? ? '' ))));
$smtpSettings = [
'smtp_host' => $values [ 'smtp_host' ],
'smtp_port' => $values [ 'smtp_port' ],
'smtp_user' => $values [ 'smtp_user' ],
'smtp_pass' => $values [ 'smtp_pass' ],
'smtp_encryption' => $values [ 'smtp_encryption' ],
'smtp_from_email' => $values [ 'smtp_from_email' ],
'smtp_from_name' => $values [ 'smtp_from_name' ],
];
$currentFingerprint = hash ( 'sha256' , json_encode ( $smtpSettings ));
$testedFingerprint = ( string )( $installer [ 'smtp_result' ][ 'fingerprint' ] ? ? '' );
$smtpPassed = ! empty ( $installer [ 'smtp_result' ][ 'ok' ]) && $testedFingerprint !== '' && hash_equals ( $testedFingerprint , $currentFingerprint );
if ( ! $smtpPassed ) {
$installer [ 'values' ] = $values ;
$_SESSION [ 'installer' ] = $installer ;
return new Response ( '' , 302 , [ 'Location' => '/admin/installer?error=' . rawurlencode ( 'Run SMTP test successfully before finishing. Re-test if SMTP values changed.' )]);
}
$dbConf = ( array )( $installer [ 'db' ] ? ? []);
try {
$pdo = $this -> connectInstallerDb (
( string )( $dbConf [ 'host' ] ? ? '' ),
( string )( $dbConf [ 'name' ] ? ? '' ),
( string )( $dbConf [ 'user' ] ? ? '' ),
( string )( $dbConf [ 'pass' ] ? ? '' ),
( int )( $dbConf [ 'port' ] ? ? 3306 )
);
} catch ( Throwable $e ) {
return new Response ( '' , 302 , [ 'Location' => '/admin/installer?error=' . rawurlencode ( 'Database connection failed while finalizing installation.' )]);
}
try {
$stmt = $pdo -> prepare ( " REPLACE INTO ac_settings (setting_key, setting_value) VALUES (:k, :v) " );
$pairs = [
'site_title' => $values [ 'site_title' ],
'site_header_title' => $values [ 'site_title' ],
'site_header_tagline' => $values [ 'site_tagline' ],
'seo_title_suffix' => $values [ 'seo_title_suffix' ],
'seo_meta_description' => $values [ 'seo_meta_description' ],
'seo_robots_index' => '1' ,
'seo_robots_follow' => '1' ,
'footer_text' => $values [ 'site_title' ],
'smtp_host' => $values [ 'smtp_host' ],
'smtp_port' => $values [ 'smtp_port' ],
'smtp_user' => $values [ 'smtp_user' ],
'smtp_pass' => $values [ 'smtp_pass' ],
'smtp_encryption' => $values [ 'smtp_encryption' ],
'smtp_from_email' => $values [ 'smtp_from_email' ],
'smtp_from_name' => $values [ 'smtp_from_name' ],
];
foreach ( $pairs as $key => $value ) {
$stmt -> execute ([ ':k' => $key , ':v' => ( string ) $value ]);
}
} catch ( Throwable $e ) {
return new Response ( '' , 302 , [ 'Location' => '/admin/installer?error=' . rawurlencode ( 'Failed to save site settings.' )]);
}
Settings :: reload ();
$adminId = ( int )( $installer [ 'admin_id' ] ? ? 0 );
$adminName = ( string )( $installer [ 'admin_name' ] ? ? 'Admin' );
if ( $adminId > 0 ) {
Auth :: login ( $adminId , 'admin' , $adminName !== '' ? $adminName : 'Admin' );
}
unset ( $_SESSION [ 'installer' ]);
return new Response ( '' , 302 , [ 'Location' => '/admin/settings?status=ok&message=' . rawurlencode ( 'Installation complete.' )]);
}
private function installerDefaultValues () : array
{
return [
2026-04-01 14:12:17 +00:00
'site_title' => 'AudioCore V1.5.1' ,
2026-03-04 20:46:11 +00:00
'site_tagline' => 'Core CMS for DJs & Producers' ,
2026-04-01 14:12:17 +00:00
'seo_title_suffix' => 'AudioCore V1.5.1' ,
2026-03-04 20:46:11 +00:00
'seo_meta_description' => 'Independent catalog platform for artists, releases, store, and support.' ,
'smtp_host' => '' ,
'smtp_port' => '587' ,
'smtp_user' => '' ,
'smtp_pass' => '' ,
'smtp_encryption' => 'tls' ,
'smtp_from_email' => '' ,
2026-04-01 14:12:17 +00:00
'smtp_from_name' => 'AudioCore V1.5.1' ,
2026-03-04 20:46:11 +00:00
];
}
private function installerSanitizedStepTwoValues ( array $post , array $existing ) : array
{
$defaults = array_merge ( $this -> installerDefaultValues (), $existing );
return [
'db_host' => ( string )( $existing [ 'db_host' ] ? ? '' ),
'db_name' => ( string )( $existing [ 'db_name' ] ? ? '' ),
'db_user' => ( string )( $existing [ 'db_user' ] ? ? '' ),
'db_port' => ( string )( $existing [ 'db_port' ] ? ? '3306' ),
'admin_name' => ( string )( $existing [ 'admin_name' ] ? ? 'Admin' ),
'admin_email' => ( string )( $existing [ 'admin_email' ] ? ? '' ),
'site_title' => trim (( string )( $post [ 'site_title' ] ? ? $defaults [ 'site_title' ])),
'site_tagline' => trim (( string )( $post [ 'site_tagline' ] ? ? $defaults [ 'site_tagline' ])),
'seo_title_suffix' => trim (( string )( $post [ 'seo_title_suffix' ] ? ? $defaults [ 'seo_title_suffix' ])),
'seo_meta_description' => trim (( string )( $post [ 'seo_meta_description' ] ? ? $defaults [ 'seo_meta_description' ])),
'smtp_host' => trim (( string )( $post [ 'smtp_host' ] ? ? $defaults [ 'smtp_host' ])),
'smtp_port' => trim (( string )( $post [ 'smtp_port' ] ? ? $defaults [ 'smtp_port' ])),
'smtp_user' => trim (( string )( $post [ 'smtp_user' ] ? ? $defaults [ 'smtp_user' ])),
'smtp_pass' => ( string )( $post [ 'smtp_pass' ] ? ? $defaults [ 'smtp_pass' ]),
'smtp_encryption' => trim (( string )( $post [ 'smtp_encryption' ] ? ? $defaults [ 'smtp_encryption' ])),
'smtp_from_email' => trim (( string )( $post [ 'smtp_from_email' ] ? ? $defaults [ 'smtp_from_email' ])),
'smtp_from_name' => trim (( string )( $post [ 'smtp_from_name' ] ? ? $defaults [ 'smtp_from_name' ])),
'smtp_test_email' => trim (( string )( $post [ 'smtp_test_email' ] ? ? ( $defaults [ 'smtp_test_email' ] ? ? '' ))),
];
}
private function installerHealthChecks ( array $dbConf , array $values ) : array
{
$checks = [];
try {
$pdo = $this -> connectInstallerDb (
( string )( $dbConf [ 'host' ] ? ? '' ),
( string )( $dbConf [ 'name' ] ? ? '' ),
( string )( $dbConf [ 'user' ] ? ? '' ),
( string )( $dbConf [ 'pass' ] ? ? '' ),
( int )( $dbConf [ 'port' ] ? ? 3306 )
);
$pdo -> query ( " SELECT 1 " );
$checks [] = [ 'label' => 'Database connection' , 'ok' => true , 'detail' => 'Connected successfully.' ];
$hasSettings = $pdo -> query ( " SHOW TABLES LIKE 'ac_settings' " ) -> fetchColumn () !== false ;
$checks [] = [ 'label' => 'Core tables' , 'ok' => $hasSettings , 'detail' => $hasSettings ? 'ac_settings found.' : 'ac_settings missing.' ];
} catch ( Throwable $e ) {
$checks [] = [ 'label' => 'Database connection' , 'ok' => false , 'detail' => 'Connection/query failed.' ];
}
$storagePath = __DIR__ . '/../../storage' ;
$checks [] = [
'label' => 'Storage directory writable' ,
'ok' => is_dir ( $storagePath ) && is_writable ( $storagePath ),
'detail' => $storagePath ,
];
$uploadsPath = __DIR__ . '/../../uploads' ;
$uploadsOk = is_dir ( $uploadsPath ) ? is_writable ( $uploadsPath ) : @ mkdir ( $uploadsPath , 0755 , true );
$checks [] = [
'label' => 'Uploads directory writable' ,
'ok' => ( bool ) $uploadsOk ,
'detail' => $uploadsPath ,
];
$checks [] = [
'label' => 'SMTP sender configured' ,
'ok' => $values [ 'smtp_from_email' ] !== '' || $values [ 'smtp_user' ] !== '' ,
'detail' => 'Use SMTP From Email or SMTP User.' ,
];
return $checks ;
}
private function connectInstallerDb ( string $host , string $dbName , string $dbUser , string $dbPass , int $dbPort ) : PDO
{
$dsn = " mysql:host= { $host } ;port= { $dbPort } ;dbname= { $dbName } ;charset=utf8mb4 " ;
return new PDO ( $dsn , $dbUser , $dbPass , [
PDO :: ATTR_ERRMODE => PDO :: ERRMODE_EXCEPTION ,
PDO :: ATTR_DEFAULT_FETCH_MODE => PDO :: FETCH_ASSOC ,
]);
}
private function createInstallerTables ( PDO $db ) : void
{
$db -> exec ( "
CREATE TABLE IF NOT EXISTS ac_admins (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY ,
name VARCHAR ( 120 ) NOT NULL ,
email VARCHAR ( 190 ) NOT NULL UNIQUE ,
password_hash VARCHAR ( 255 ) NOT NULL ,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 ;
" );
$db -> exec ( "
CREATE TABLE IF NOT EXISTS ac_settings (
setting_key VARCHAR ( 120 ) PRIMARY KEY ,
setting_value TEXT NOT NULL ,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 ;
" );
$db -> exec ( "
CREATE TABLE IF NOT EXISTS ac_pages (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY ,
title VARCHAR ( 200 ) NOT NULL ,
slug VARCHAR ( 200 ) NOT NULL UNIQUE ,
content_html MEDIUMTEXT NOT NULL ,
is_published TINYINT ( 1 ) NOT NULL DEFAULT 0 ,
is_home TINYINT ( 1 ) NOT NULL DEFAULT 0 ,
is_blog_index TINYINT ( 1 ) NOT NULL DEFAULT 0 ,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 ;
" );
$db -> exec ( "
CREATE TABLE IF NOT EXISTS ac_posts (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY ,
title VARCHAR ( 200 ) NOT NULL ,
slug VARCHAR ( 200 ) NOT NULL UNIQUE ,
excerpt TEXT NULL ,
featured_image_url VARCHAR ( 255 ) NULL ,
author_name VARCHAR ( 120 ) NULL ,
category VARCHAR ( 120 ) NULL ,
tags VARCHAR ( 255 ) NULL ,
content_html MEDIUMTEXT NOT NULL ,
is_published TINYINT ( 1 ) NOT NULL DEFAULT 0 ,
published_at DATETIME NULL ,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 ;
" );
$db -> exec ( "
CREATE TABLE IF NOT EXISTS ac_admin_users (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY ,
name VARCHAR ( 120 ) NOT NULL ,
email VARCHAR ( 190 ) NOT NULL UNIQUE ,
password_hash VARCHAR ( 255 ) NOT NULL ,
role VARCHAR ( 20 ) NOT NULL DEFAULT 'editor' ,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 ;
" );
$db -> exec ( "
CREATE TABLE IF NOT EXISTS ac_nav_links (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY ,
label VARCHAR ( 120 ) NOT NULL ,
url VARCHAR ( 255 ) NOT NULL ,
sort_order INT NOT NULL DEFAULT 0 ,
is_active TINYINT ( 1 ) NOT NULL DEFAULT 1 ,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 ;
" );
$db -> exec ( "
CREATE TABLE IF NOT EXISTS ac_newsletter_subscribers (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY ,
email VARCHAR ( 190 ) NOT NULL UNIQUE ,
name VARCHAR ( 120 ) NULL ,
status VARCHAR ( 20 ) NOT NULL DEFAULT 'subscribed' ,
source VARCHAR ( 50 ) NULL ,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ,
unsubscribed_at DATETIME NULL
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 ;
" );
$db -> exec ( "
CREATE TABLE IF NOT EXISTS ac_newsletter_campaigns (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY ,
title VARCHAR ( 200 ) NOT NULL ,
subject VARCHAR ( 200 ) NOT NULL ,
content_html MEDIUMTEXT NOT NULL ,
status VARCHAR ( 20 ) NOT NULL DEFAULT 'draft' ,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ,
sent_at DATETIME NULL ,
scheduled_at DATETIME NULL
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 ;
" );
$db -> exec ( "
CREATE TABLE IF NOT EXISTS ac_newsletter_sends (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY ,
campaign_id INT UNSIGNED NOT NULL ,
subscriber_id INT UNSIGNED NOT NULL ,
status VARCHAR ( 20 ) NOT NULL DEFAULT 'pending' ,
sent_at DATETIME NULL ,
error_text TEXT NULL ,
FOREIGN KEY ( campaign_id ) REFERENCES ac_newsletter_campaigns ( id ) ON DELETE CASCADE ,
FOREIGN KEY ( subscriber_id ) REFERENCES ac_newsletter_subscribers ( id ) ON DELETE CASCADE
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 ;
" );
$db -> exec ( "
CREATE TABLE IF NOT EXISTS ac_media (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY ,
file_name VARCHAR ( 255 ) NOT NULL ,
file_url VARCHAR ( 255 ) NOT NULL ,
file_type VARCHAR ( 120 ) NULL ,
file_size INT UNSIGNED NOT NULL DEFAULT 0 ,
folder_id INT UNSIGNED NULL ,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 ;
" );
$db -> exec ( "
CREATE TABLE IF NOT EXISTS ac_media_folders (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY ,
name VARCHAR ( 120 ) NOT NULL ,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 ;
" );
$db -> exec ( "
CREATE TABLE IF NOT EXISTS ac_plugins (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY ,
slug VARCHAR ( 120 ) NOT NULL UNIQUE ,
name VARCHAR ( 200 ) NOT NULL ,
version VARCHAR ( 50 ) NOT NULL DEFAULT '0.0.0' ,
is_enabled TINYINT ( 1 ) NOT NULL DEFAULT 0 ,
installed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 ;
" );
$db -> exec ( "
CREATE TABLE IF NOT EXISTS ac_redirects (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY ,
source_path VARCHAR ( 255 ) NOT NULL UNIQUE ,
target_url VARCHAR ( 1000 ) NOT NULL ,
status_code SMALLINT NOT NULL DEFAULT 301 ,
is_active TINYINT ( 1 ) NOT NULL DEFAULT 1 ,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 ;
" );
$db -> exec ( "
CREATE TABLE IF NOT EXISTS ac_audit_logs (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY ,
actor_id INT UNSIGNED NULL ,
actor_name VARCHAR ( 120 ) NULL ,
actor_role VARCHAR ( 40 ) NULL ,
action VARCHAR ( 120 ) NOT NULL ,
context_json MEDIUMTEXT NULL ,
ip_address VARCHAR ( 45 ) NULL ,
user_agent VARCHAR ( 255 ) NULL ,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 ;
" );
$db -> exec ( "
CREATE TABLE IF NOT EXISTS ac_update_checks (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY ,
checked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ,
channel VARCHAR ( 20 ) NOT NULL DEFAULT 'stable' ,
manifest_url VARCHAR ( 500 ) NOT NULL DEFAULT '' ,
current_version VARCHAR ( 50 ) NOT NULL DEFAULT '0.0.0' ,
latest_version VARCHAR ( 50 ) NOT NULL DEFAULT '0.0.0' ,
is_update_available TINYINT ( 1 ) NOT NULL DEFAULT 0 ,
ok TINYINT ( 1 ) NOT NULL DEFAULT 0 ,
error_text TEXT NULL ,
payload_json MEDIUMTEXT NULL
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 ;
" );
}
private function parseFooterLinks ( string $json ) : array
{
$decoded = json_decode ( $json , true );
if ( ! is_array ( $decoded )) {
return [];
}
$out = [];
foreach ( $decoded as $item ) {
if ( ! is_array ( $item )) {
continue ;
}
$label = trim (( string )( $item [ 'label' ] ? ? '' ));
$url = trim (( string )( $item [ 'url' ] ? ? '' ));
if ( $label === '' || $url === '' ) {
continue ;
}
$out [] = [
'label' => mb_substr ( $label , 0 , 80 ),
'url' => mb_substr ( $this -> normalizeUrl ( $url ), 0 , 255 ),
];
if ( count ( $out ) >= 20 ) {
break ;
}
}
return $out ;
}
private function uploadHeaderLogo () : Response
{
$file = $_FILES [ 'header_logo_file' ] ? ? null ;
if ( ! $file || ! isset ( $file [ 'tmp_name' ])) {
return new Response ( '' , 302 , [ 'Location' => '/admin/settings?status=error&message=' . rawurlencode ( 'No file selected.' )]);
}
if (( int ) $file [ 'error' ] !== UPLOAD_ERR_OK ) {
$map = [
UPLOAD_ERR_INI_SIZE => 'File exceeds upload limit.' ,
UPLOAD_ERR_FORM_SIZE => 'File exceeds form size limit.' ,
UPLOAD_ERR_PARTIAL => 'File was only partially uploaded.' ,
UPLOAD_ERR_NO_TMP_DIR => 'Missing temp upload directory.' ,
UPLOAD_ERR_CANT_WRITE => 'Failed to write file to disk.' ,
UPLOAD_ERR_EXTENSION => 'Upload stopped by server extension.' ,
UPLOAD_ERR_NO_FILE => 'No file uploaded.' ,
];
$msg = $map [( int ) $file [ 'error' ]] ? ? 'Upload failed.' ;
return new Response ( '' , 302 , [ 'Location' => '/admin/settings?status=error&message=' . rawurlencode ( $msg )]);
}
$tmp = ( string ) $file [ 'tmp_name' ];
if ( $tmp === '' || ! is_uploaded_file ( $tmp )) {
return new Response ( '' , 302 , [ 'Location' => '/admin/settings?status=error&message=' . rawurlencode ( 'Upload validation failed.' )]);
}
$info = @ getimagesize ( $tmp );
if ( $info === false ) {
return new Response ( '' , 302 , [ 'Location' => '/admin/settings?status=error&message=' . rawurlencode ( 'Logo must be an image file.' )]);
}
$ext = strtolower ( pathinfo (( string ) $file [ 'name' ], PATHINFO_EXTENSION ));
if ( $ext === '' ) {
$ext = image_type_to_extension (( int )( $info [ 2 ] ? ? IMAGETYPE_PNG ), false ) ? : 'png' ;
}
$allowed = [ 'png' , 'jpg' , 'jpeg' , 'webp' , 'gif' , 'svg' ];
if ( ! in_array ( $ext , $allowed , true )) {
return new Response ( '' , 302 , [ 'Location' => '/admin/settings?status=error&message=' . rawurlencode ( 'Allowed types: PNG, JPG, WEBP, GIF, SVG.' )]);
}
$uploadDir = __DIR__ . '/../../uploads/media' ;
if ( ! is_dir ( $uploadDir ) && ! mkdir ( $uploadDir , 0755 , true )) {
return new Response ( '' , 302 , [ 'Location' => '/admin/settings?status=error&message=' . rawurlencode ( 'Upload directory could not be created.' )]);
}
if ( ! is_writable ( $uploadDir )) {
return new Response ( '' , 302 , [ 'Location' => '/admin/settings?status=error&message=' . rawurlencode ( 'Upload directory is not writable.' )]);
}
$base = preg_replace ( '~[^a-z0-9]+~' , '-' , strtolower (( string ) $file [ 'name' ])) ? ? 'logo' ;
$base = trim ( $base , '-' );
$filename = ( $base !== '' ? $base : 'logo' ) . '-' . date ( 'YmdHis' ) . '.' . $ext ;
$dest = $uploadDir . '/' . $filename ;
if ( ! move_uploaded_file ( $tmp , $dest )) {
return new Response ( '' , 302 , [ 'Location' => '/admin/settings?status=error&message=' . rawurlencode ( 'Upload failed while moving file.' )]);
}
$url = '/uploads/media/' . $filename ;
Settings :: set ( 'site_header_logo_url' , $url );
Audit :: log ( 'settings.logo.upload' , [ 'logo_url' => $url ]);
$db = Database :: get ();
if ( $db instanceof PDO ) {
try {
$stmt = $db -> prepare ( "
INSERT INTO ac_media ( file_name , file_url , file_type , file_size , folder_id )
VALUES ( : name , : url , : type , : size , NULL )
" );
$stmt -> execute ([
':name' => ( string )( $file [ 'name' ] ? ? $filename ),
':url' => $url ,
':type' => ( string )( $file [ 'type' ] ? ? '' ),
':size' => ( int )( $file [ 'size' ] ? ? 0 ),
]);
} catch ( Throwable $e ) {
}
}
return new Response ( '' , 302 , [ 'Location' => '/admin/settings?status=ok&message=' . rawurlencode ( 'Logo uploaded and applied.' )]);
}
private function saveRedirect () : Response
{
$db = Database :: get ();
if ( ! ( $db instanceof PDO )) {
return new Response ( '' , 302 , [ 'Location' => '/admin/settings?status=error&message=' . rawurlencode ( 'Database unavailable.' )]);
}
$source = trim (( string )( $_POST [ 'redirect_source_path' ] ? ? '' ));
$target = trim (( string )( $_POST [ 'redirect_target_url' ] ? ? '' ));
$statusCode = ( int )( $_POST [ 'redirect_status_code' ] ? ? 301 );
$isActive = isset ( $_POST [ 'redirect_is_active' ]) ? 1 : 0 ;
if ( $source === '' || $target === '' ) {
return new Response ( '' , 302 , [ 'Location' => '/admin/settings?status=error&message=' . rawurlencode ( 'Redirect source and target are required.' )]);
}
if ( $source [ 0 ] !== '/' ) {
$source = '/' . ltrim ( $source , '/' );
}
if ( ! in_array ( $statusCode , [ 301 , 302 , 307 , 308 ], true )) {
$statusCode = 301 ;
}
try {
$stmt = $db -> prepare ( "
INSERT INTO ac_redirects ( source_path , target_url , status_code , is_active )
VALUES ( : source_path , : target_url , : status_code , : is_active )
ON DUPLICATE KEY UPDATE
target_url = VALUES ( target_url ),
status_code = VALUES ( status_code ),
is_active = VALUES ( is_active ),
updated_at = NOW ()
" );
$stmt -> execute ([
':source_path' => $source ,
':target_url' => $target ,
':status_code' => $statusCode ,
':is_active' => $isActive ,
]);
Audit :: log ( 'settings.redirect.save' , [
'source_path' => $source ,
'target_url' => $target ,
'status_code' => $statusCode ,
'is_active' => $isActive ,
]);
return new Response ( '' , 302 , [ 'Location' => '/admin/settings?status=ok&message=' . rawurlencode ( 'Redirect saved.' )]);
} catch ( Throwable $e ) {
return new Response ( '' , 302 , [ 'Location' => '/admin/settings?status=error&message=' . rawurlencode ( 'Failed to save redirect.' )]);
}
}
private function deleteRedirect () : Response
{
$id = ( int )( $_POST [ 'redirect_id' ] ? ? 0 );
if ( $id <= 0 ) {
return new Response ( '' , 302 , [ 'Location' => '/admin/settings?status=error&message=' . rawurlencode ( 'Invalid redirect id.' )]);
}
$db = Database :: get ();
if ( ! ( $db instanceof PDO )) {
return new Response ( '' , 302 , [ 'Location' => '/admin/settings?status=error&message=' . rawurlencode ( 'Database unavailable.' )]);
}
try {
$stmt = $db -> prepare ( " DELETE FROM ac_redirects WHERE id = :id LIMIT 1 " );
$stmt -> execute ([ ':id' => $id ]);
Audit :: log ( 'settings.redirect.delete' , [ 'id' => $id ]);
return new Response ( '' , 302 , [ 'Location' => '/admin/settings?status=ok&message=' . rawurlencode ( 'Redirect deleted.' )]);
} catch ( Throwable $e ) {
return new Response ( '' , 302 , [ 'Location' => '/admin/settings?status=error&message=' . rawurlencode ( 'Failed to delete redirect.' )]);
}
}
private function ensureSettingsAuxTables () : void
{
$db = Database :: get ();
if ( ! ( $db instanceof PDO )) {
return ;
}
try {
$db -> exec ( "
CREATE TABLE IF NOT EXISTS ac_redirects (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY ,
source_path VARCHAR ( 255 ) NOT NULL UNIQUE ,
target_url VARCHAR ( 1000 ) NOT NULL ,
status_code SMALLINT NOT NULL DEFAULT 301 ,
is_active TINYINT ( 1 ) NOT NULL DEFAULT 1 ,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 ;
" );
} catch ( Throwable $e ) {
}
}
private function ensureCoreTables () : void
{
$db = Database :: get ();
if ( ! $db instanceof PDO ) {
return ;
}
try {
$db -> exec ( "
CREATE TABLE IF NOT EXISTS ac_settings (
setting_key VARCHAR ( 120 ) PRIMARY KEY ,
setting_value TEXT NOT NULL ,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 ;
" );
$db -> exec ( "
CREATE TABLE IF NOT EXISTS ac_admin_users (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY ,
name VARCHAR ( 120 ) NOT NULL ,
email VARCHAR ( 190 ) NOT NULL UNIQUE ,
password_hash VARCHAR ( 255 ) NOT NULL ,
role VARCHAR ( 20 ) NOT NULL DEFAULT 'editor' ,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 ;
" );
$db -> exec ( "
CREATE TABLE IF NOT EXISTS ac_admins (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY ,
name VARCHAR ( 120 ) NOT NULL ,
email VARCHAR ( 190 ) NOT NULL UNIQUE ,
password_hash VARCHAR ( 255 ) NOT NULL ,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 ;
" );
$db -> exec ( "
CREATE TABLE IF NOT EXISTS ac_update_checks (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY ,
checked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ,
channel VARCHAR ( 20 ) NOT NULL DEFAULT 'stable' ,
manifest_url VARCHAR ( 500 ) NOT NULL DEFAULT '' ,
current_version VARCHAR ( 50 ) NOT NULL DEFAULT '0.0.0' ,
latest_version VARCHAR ( 50 ) NOT NULL DEFAULT '0.0.0' ,
is_update_available TINYINT ( 1 ) NOT NULL DEFAULT 0 ,
ok TINYINT ( 1 ) NOT NULL DEFAULT 0 ,
error_text TEXT NULL ,
payload_json MEDIUMTEXT NULL
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 ;
" );
} catch ( Throwable $e ) {
return ;
}
}
public function navigationForm () : Response
{
if ( $guard = $this -> guard ([ 'admin' , 'manager' ])) {
return $guard ;
}
$db = Database :: get ();
$links = [];
$pages = [];
$error = '' ;
if ( $db instanceof PDO ) {
try {
$stmt = $db -> query ( " SELECT id, label, url, sort_order, is_active FROM ac_nav_links ORDER BY sort_order ASC, id ASC " );
$links = $stmt ? $stmt -> fetchAll ( PDO :: FETCH_ASSOC ) : [];
$pagesStmt = $db -> query ( " SELECT title, slug FROM ac_pages ORDER BY title ASC " );
$pages = $pagesStmt ? $pagesStmt -> fetchAll ( PDO :: FETCH_ASSOC ) : [];
} catch ( Throwable $e ) {
$error = 'Navigation table not available.' ;
}
} else {
$error = 'Database unavailable.' ;
}
$saved = isset ( $_GET [ 'saved' ]) ? '1' : '0' ;
return new Response ( $this -> view -> render ( 'navigation.php' , [
'title' => 'Navigation' ,
'links' => $links ,
'pages' => $pages ,
'error' => $error ,
'saved' => $saved ,
]));
}
public function saveNavigation () : Response
{
if ( $guard = $this -> guard ([ 'admin' , 'manager' ])) {
return $guard ;
}
$db = Database :: get ();
if ( ! $db instanceof PDO ) {
return new Response ( '' , 302 , [ 'Location' => '/admin/navigation?error=1' ]);
}
$items = $_POST [ 'items' ] ? ? [];
$newItems = $_POST [ 'new' ] ? ? [];
$deleteIds = array_map ( 'intval' , $_POST [ 'delete_ids' ] ? ? []);
try {
$db -> beginTransaction ();
if ( $deleteIds ) {
$placeholders = implode ( ',' , array_fill ( 0 , count ( $deleteIds ), '?' ));
$stmt = $db -> prepare ( " DELETE FROM ac_nav_links WHERE id IN ( { $placeholders } ) " );
$stmt -> execute ( $deleteIds );
}
$update = $db -> prepare ( "
UPDATE ac_nav_links
SET label = : label , url = : url , sort_order = : sort_order , is_active = : is_active
WHERE id = : id
" );
foreach ( $items as $id => $data ) {
$id = ( int ) $id ;
if ( $id <= 0 || in_array ( $id , $deleteIds , true )) {
continue ;
}
$label = trim (( string )( $data [ 'label' ] ? ? '' ));
$url = trim (( string )( $data [ 'url' ] ? ? '' ));
if ( $label === '' || $url === '' ) {
continue ;
}
$url = $this -> normalizeUrl ( $url );
$sortOrder = ( int )( $data [ 'sort_order' ] ? ? 0 );
$isActive = isset ( $data [ 'is_active' ]) ? 1 : 0 ;
$update -> execute ([
':label' => $label ,
':url' => $url ,
':sort_order' => $sortOrder ,
':is_active' => $isActive ,
':id' => $id ,
]);
}
$insert = $db -> prepare ( "
INSERT INTO ac_nav_links ( label , url , sort_order , is_active )
VALUES ( : label , : url , : sort_order , : is_active )
" );
foreach ( $newItems as $data ) {
$label = trim (( string )( $data [ 'label' ] ? ? '' ));
$url = trim (( string )( $data [ 'url' ] ? ? '' ));
if ( $label === '' || $url === '' ) {
continue ;
}
$url = $this -> normalizeUrl ( $url );
$sortOrder = ( int )( $data [ 'sort_order' ] ? ? 0 );
$isActive = isset ( $data [ 'is_active' ]) ? 1 : 0 ;
$insert -> execute ([
':label' => $label ,
':url' => $url ,
':sort_order' => $sortOrder ,
':is_active' => $isActive ,
]);
}
$db -> commit ();
} catch ( Throwable $e ) {
if ( $db -> inTransaction ()) {
$db -> rollBack ();
}
return new Response ( '' , 302 , [ 'Location' => '/admin/navigation?error=1' ]);
}
return new Response ( '' , 302 , [ 'Location' => '/admin/navigation?saved=1' ]);
}
private function dbReady () : bool
{
return Database :: get () instanceof PDO ;
}
2026-04-01 14:12:17 +00:00
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 ;
}
}
2026-03-04 20:46:11 +00:00
private function normalizeUrl ( string $url ) : string
{
if ( preg_match ( '~^(https?://|/|#|mailto:)~i' , $url )) {
return $url ;
}
return '/' . ltrim ( $url , '/' );
}
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' ] ? ? '' ));
if ( $host === '' ) {
return '' ;
}
return $scheme . '://' . $host ;
}
private function guard ( array $roles ) : ? Response
{
$this -> ensureCoreTables ();
if ( ! Auth :: check ()) {
return new Response ( '' , 302 , [ 'Location' => '/admin/login' ]);
}
if ( ! Auth :: hasRole ( $roles )) {
return new Response ( '' , 302 , [ 'Location' => '/admin' ]);
}
return null ;
}
}