Initial dev export (exclude uploads/runtime)

This commit is contained in:
AudioCore Bot
2026-03-04 20:46:11 +00:00
commit b2afadd539
120 changed files with 20410 additions and 0 deletions

22
core/bootstrap.php Normal file
View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/http/Response.php';
require_once __DIR__ . '/http/Router.php';
require_once __DIR__ . '/views/View.php';
require_once __DIR__ . '/services/Database.php';
require_once __DIR__ . '/services/Auth.php';
require_once __DIR__ . '/services/Csrf.php';
require_once __DIR__ . '/services/Settings.php';
require_once __DIR__ . '/services/Audit.php';
require_once __DIR__ . '/services/Permissions.php';
require_once __DIR__ . '/services/Shortcodes.php';
require_once __DIR__ . '/services/Nav.php';
require_once __DIR__ . '/services/Mailer.php';
require_once __DIR__ . '/services/Plugins.php';
require_once __DIR__ . '/services/Updater.php';
Core\Services\Auth::init();
Core\Services\Settings::init(__DIR__ . '/../storage/settings.php');
Core\Services\Plugins::init(__DIR__ . '/../plugins');
Core\Services\Audit::ensureTable();

27
core/http/Response.php Normal file
View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Core\Http;
class Response
{
private string $body;
private int $status;
private array $headers;
public function __construct(string $body = '', int $status = 200, array $headers = [])
{
$this->body = $body;
$this->status = $status;
$this->headers = $headers;
}
public function send(): void
{
http_response_code($this->status);
foreach ($this->headers as $name => $value) {
header($name . ': ' . $value);
}
echo $this->body;
}
}

185
core/http/Router.php Normal file
View File

@@ -0,0 +1,185 @@
<?php
declare(strict_types=1);
namespace Core\Http;
use Core\Services\Auth;
use Core\Services\Csrf;
use Core\Services\Database;
use Core\Services\Permissions;
use Core\Services\Shortcodes;
use Core\Views\View;
use PDO;
use Throwable;
class Router
{
private array $routes = [];
public function get(string $path, callable $handler): void
{
$this->routes['GET'][$path] = $handler;
}
public function post(string $path, callable $handler): void
{
$this->routes['POST'][$path] = $handler;
}
public function registerModules(string $modulesPath): void
{
foreach (glob($modulesPath . '/*/module.php') as $moduleFile) {
$module = require $moduleFile;
if (is_callable($module)) {
$module($this);
}
}
}
public function dispatch(string $uri, string $method): Response
{
$path = parse_url($uri, PHP_URL_PATH) ?: '/';
if ($path !== '/' && str_ends_with($path, '/')) {
$path = rtrim($path, '/');
}
$method = strtoupper($method);
if ($method === 'POST' && $this->requiresCsrf($path) && !Csrf::verifyRequest()) {
return new Response('Invalid request token. Please refresh and try again.', 419);
}
if ($method === 'GET') {
$redirect = $this->matchRedirect($path);
if ($redirect !== null) {
return $redirect;
}
}
if (str_starts_with($path, '/admin')) {
$permission = Permissions::routePermission($path);
if ($permission !== null && Auth::check() && !Auth::can($permission)) {
return new Response('', 302, ['Location' => '/admin?denied=1']);
}
}
if (isset($this->routes[$method][$path])) {
$handler = $this->routes[$method][$path];
return $handler();
}
if ($method === 'GET') {
if (str_starts_with($path, '/news/')) {
$postSlug = trim(substr($path, strlen('/news/')));
if ($postSlug !== '' && strpos($postSlug, '/') === false) {
$db = Database::get();
if ($db instanceof PDO) {
try {
$stmt = $db->prepare("
SELECT title, content_html, published_at, featured_image_url, author_name, category, tags
FROM ac_posts
WHERE slug = :slug AND is_published = 1
LIMIT 1
");
$stmt->execute([':slug' => $postSlug]);
$post = $stmt->fetch(PDO::FETCH_ASSOC);
if ($post) {
$view = new View(__DIR__ . '/../../modules/blog/views');
return new Response($view->render('site/show.php', [
'title' => (string)$post['title'],
'content_html' => (string)$post['content_html'],
'published_at' => (string)($post['published_at'] ?? ''),
'featured_image_url' => (string)($post['featured_image_url'] ?? ''),
'author_name' => (string)($post['author_name'] ?? ''),
'category' => (string)($post['category'] ?? ''),
'tags' => (string)($post['tags'] ?? ''),
]));
}
} catch (Throwable $e) {
}
}
}
}
$slug = ltrim($path, '/');
if ($slug !== '' && strpos($slug, '/') === false) {
$db = Database::get();
if ($db instanceof PDO) {
try {
$stmt = $db->prepare("SELECT title, content_html FROM ac_pages WHERE slug = :slug AND is_published = 1 LIMIT 1");
$stmt->execute([':slug' => $slug]);
$page = $stmt->fetch(PDO::FETCH_ASSOC);
if ($page) {
$view = new View(__DIR__ . '/../../modules/pages/views');
return new Response($view->render('site/show.php', [
'title' => (string)$page['title'],
'content_html' => Shortcodes::render((string)$page['content_html'], [
'page_slug' => (string)$slug,
]),
]));
}
} catch (Throwable $e) {
}
}
}
}
$view = new View();
return new Response($view->render('site/404.php', [
'title' => 'Not Found',
'message' => 'Page not found.',
]), 404);
}
private function requiresCsrf(string $path): bool
{
// All browser-initiated POST routes require CSRF protection.
return true;
}
private function matchRedirect(string $path): ?Response
{
$db = Database::get();
if (!($db instanceof PDO)) {
return null;
}
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;
");
$stmt = $db->prepare("
SELECT target_url, status_code
FROM ac_redirects
WHERE source_path = :path AND is_active = 1
LIMIT 1
");
$stmt->execute([':path' => $path]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$row) {
return null;
}
$code = (int)($row['status_code'] ?? 301);
if (!in_array($code, [301, 302, 307, 308], true)) {
$code = 301;
}
$target = trim((string)($row['target_url'] ?? ''));
if ($target === '' || $target === $path) {
return null;
}
return new Response('', $code, ['Location' => $target]);
} catch (Throwable $e) {
return null;
}
}
}

105
core/services/Audit.php Normal file
View File

@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace Core\Services;
use PDO;
use Throwable;
class Audit
{
public static function ensureTable(): void
{
$db = Database::get();
if (!($db instanceof PDO)) {
return;
}
try {
$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;
");
} catch (Throwable $e) {
return;
}
}
public static function log(string $action, array $context = []): void
{
$db = Database::get();
if (!($db instanceof PDO)) {
return;
}
self::ensureTable();
try {
$stmt = $db->prepare("
INSERT INTO ac_audit_logs
(actor_id, actor_name, actor_role, action, context_json, ip_address, user_agent)
VALUES
(:actor_id, :actor_name, :actor_role, :action, :context_json, :ip_address, :user_agent)
");
$stmt->execute([
':actor_id' => Auth::id() > 0 ? Auth::id() : null,
':actor_name' => Auth::name() !== '' ? Auth::name() : null,
':actor_role' => Auth::role() !== '' ? Auth::role() : null,
':action' => $action,
':context_json' => $context ? json_encode($context, JSON_UNESCAPED_SLASHES) : null,
':ip_address' => self::ip(),
':user_agent' => mb_substr((string)($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255),
]);
} catch (Throwable $e) {
return;
}
}
public static function latest(int $limit = 100): array
{
$db = Database::get();
if (!($db instanceof PDO)) {
return [];
}
self::ensureTable();
$limit = max(1, min(500, $limit));
try {
$stmt = $db->prepare("
SELECT id, actor_name, actor_role, action, context_json, ip_address, created_at
FROM ac_audit_logs
ORDER BY id DESC
LIMIT :limit
");
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
} catch (Throwable $e) {
return [];
}
}
private static function ip(): ?string
{
$candidates = [
(string)($_SERVER['HTTP_CF_CONNECTING_IP'] ?? ''),
(string)($_SERVER['HTTP_X_FORWARDED_FOR'] ?? ''),
(string)($_SERVER['REMOTE_ADDR'] ?? ''),
];
foreach ($candidates as $candidate) {
if ($candidate === '') {
continue;
}
$first = trim(explode(',', $candidate)[0] ?? '');
if ($first !== '' && filter_var($first, FILTER_VALIDATE_IP)) {
return $first;
}
}
return null;
}
}

80
core/services/Auth.php Normal file
View File

@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace Core\Services;
class Auth
{
private const SESSION_KEY = 'admin_id';
private const SESSION_ROLE_KEY = 'admin_role';
private const SESSION_NAME_KEY = 'admin_name';
public static function init(): void
{
if (session_status() !== PHP_SESSION_ACTIVE) {
$secure = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';
session_start([
'cookie_httponly' => true,
'cookie_secure' => $secure,
'cookie_samesite' => 'Lax',
'use_strict_mode' => 1,
]);
}
}
public static function check(): bool
{
self::init();
return isset($_SESSION[self::SESSION_KEY]);
}
public static function id(): int
{
self::init();
return (int)($_SESSION[self::SESSION_KEY] ?? 0);
}
public static function login(int $adminId, string $role = 'admin', string $name = ''): void
{
self::init();
session_regenerate_id(true);
$_SESSION[self::SESSION_KEY] = $adminId;
$_SESSION[self::SESSION_ROLE_KEY] = $role;
if ($name !== '') {
$_SESSION[self::SESSION_NAME_KEY] = $name;
}
}
public static function logout(): void
{
self::init();
unset($_SESSION[self::SESSION_KEY]);
unset($_SESSION[self::SESSION_ROLE_KEY]);
unset($_SESSION[self::SESSION_NAME_KEY]);
}
public static function role(): string
{
self::init();
return (string)($_SESSION[self::SESSION_ROLE_KEY] ?? 'admin');
}
public static function name(): string
{
self::init();
return (string)($_SESSION[self::SESSION_NAME_KEY] ?? 'Admin');
}
public static function hasRole(array $roles): bool
{
return in_array(self::role(), $roles, true);
}
public static function can(string $permission): bool
{
if (!self::check()) {
return false;
}
return Permissions::can(self::role(), $permission);
}
}

50
core/services/Csrf.php Normal file
View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace Core\Services;
class Csrf
{
private const SESSION_KEY = '_csrf_token';
public static function token(): string
{
if (session_status() !== PHP_SESSION_ACTIVE) {
@session_start();
}
$token = (string)($_SESSION[self::SESSION_KEY] ?? '');
if ($token === '') {
$token = bin2hex(random_bytes(32));
$_SESSION[self::SESSION_KEY] = $token;
}
return $token;
}
public static function verifyRequest(): bool
{
if (session_status() !== PHP_SESSION_ACTIVE) {
@session_start();
}
$sessionToken = (string)($_SESSION[self::SESSION_KEY] ?? '');
if ($sessionToken === '') {
// Legacy compatibility: allow request when no token has been seeded yet.
return true;
}
$provided = '';
if (isset($_POST['csrf_token'])) {
$provided = (string)$_POST['csrf_token'];
} elseif (isset($_SERVER['HTTP_X_CSRF_TOKEN'])) {
$provided = (string)$_SERVER['HTTP_X_CSRF_TOKEN'];
}
if ($provided === '') {
// Legacy compatibility: don't hard-fail older forms without token.
return true;
}
return hash_equals($sessionToken, $provided);
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace Core\Services;
use PDO;
use PDOException;
class Database
{
private static ?PDO $pdo = null;
public static function get(): ?PDO
{
if (self::$pdo instanceof PDO) {
return self::$pdo;
}
$configPath = __DIR__ . '/../../storage/db.php';
if (!is_file($configPath)) {
return null;
}
$config = require $configPath;
if (!is_array($config)) {
return null;
}
$host = (string)($config['host'] ?? 'localhost');
$db = (string)($config['database'] ?? '');
$user = (string)($config['user'] ?? '');
$pass = (string)($config['pass'] ?? '');
$port = (int)($config['port'] ?? 3306);
if ($db == '' || $user == '') {
return null;
}
$dsn = "mysql:host={$host};dbname={$db};port={$port};charset=utf8mb4";
try {
self::$pdo = new PDO($dsn, $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
} catch (PDOException $e) {
return null;
}
return self::$pdo;
}
}

226
core/services/Mailer.php Normal file
View File

@@ -0,0 +1,226 @@
<?php
declare(strict_types=1);
namespace Core\Services;
class Mailer
{
public static function send(string $to, string $subject, string $html, array $settings): array
{
$host = (string)($settings['smtp_host'] ?? '');
$port = (int)($settings['smtp_port'] ?? 587);
$user = (string)($settings['smtp_user'] ?? '');
$pass = (string)($settings['smtp_pass'] ?? '');
$encryption = strtolower((string)($settings['smtp_encryption'] ?? 'tls'));
$fromEmail = (string)($settings['smtp_from_email'] ?? '');
$fromName = (string)($settings['smtp_from_name'] ?? '');
if ($fromEmail === '') {
$fromEmail = $user !== '' ? $user : 'no-reply@localhost';
}
$fromHeader = $fromName !== '' ? "{$fromName} <{$fromEmail}>" : $fromEmail;
if ($host === '') {
$headers = [
'MIME-Version: 1.0',
'Content-type: text/html; charset=utf-8',
"From: {$fromHeader}",
];
$ok = mail($to, $subject, $html, implode("\r\n", $headers));
return ['ok' => $ok, 'error' => $ok ? '' : 'mail() failed', 'debug' => 'transport=mail()'];
}
$remote = $encryption === 'ssl' ? "ssl://{$host}:{$port}" : "{$host}:{$port}";
$socket = stream_socket_client($remote, $errno, $errstr, 10);
if (!$socket) {
return ['ok' => false, 'error' => "SMTP connect failed: {$errstr}", 'debug' => "connect={$remote} errno={$errno} err={$errstr}"];
}
$debug = [];
$read = function () use ($socket): string {
$data = '';
while (!feof($socket)) {
$line = fgets($socket, 515);
if ($line === false) {
break;
}
$data .= $line;
if (isset($line[3]) && $line[3] === ' ') {
break;
}
}
return $data;
};
$send = function (string $command) use ($socket, $read): string {
fwrite($socket, $command . "\r\n");
return $read();
};
$resp = $read();
$debug[] = 'S: ' . trim($resp);
if (!self::isOkResponse($resp)) {
fclose($socket);
return ['ok' => false, 'error' => 'SMTP greeting failed', 'debug' => implode("\n", $debug)];
}
$resp = $send("EHLO localhost");
$debug[] = 'C: EHLO localhost';
$debug[] = 'S: ' . trim($resp);
if (!self::isOkResponse($resp)) {
fclose($socket);
return ['ok' => false, 'error' => 'SMTP EHLO failed', 'debug' => implode("\n", $debug)];
}
$authCaps = self::parseAuthCapabilities($resp);
if ($encryption === 'tls') {
$resp = $send("STARTTLS");
$debug[] = 'C: STARTTLS';
$debug[] = 'S: ' . trim($resp);
if (substr(trim($resp), 0, 3) !== '220') {
fclose($socket);
return ['ok' => false, 'error' => 'SMTP STARTTLS failed', 'debug' => implode("\n", $debug)];
}
if (!stream_socket_enable_crypto($socket, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) {
fclose($socket);
return ['ok' => false, 'error' => 'SMTP TLS negotiation failed', 'debug' => implode("\n", $debug)];
}
$resp = $send("EHLO localhost");
$debug[] = 'C: EHLO localhost';
$debug[] = 'S: ' . trim($resp);
if (!self::isOkResponse($resp)) {
fclose($socket);
return ['ok' => false, 'error' => 'SMTP EHLO after TLS failed', 'debug' => implode("\n", $debug)];
}
$authCaps = self::parseAuthCapabilities($resp);
}
if ($user !== '' && $pass !== '') {
$authOk = false;
$authErrors = [];
// Prefer advertised AUTH mechanisms when available.
if (in_array('PLAIN', $authCaps, true)) {
$authResp = $send("AUTH PLAIN " . base64_encode("\0{$user}\0{$pass}"));
$debug[] = 'C: AUTH PLAIN [credentials]';
$debug[] = 'S: ' . trim($authResp);
if (substr(trim($authResp), 0, 3) === '235') {
$authOk = true;
} else {
$authErrors[] = 'PLAIN rejected';
}
}
if (!$authOk && (in_array('LOGIN', $authCaps, true) || !$authCaps)) {
$resp = $send("AUTH LOGIN");
$debug[] = 'C: AUTH LOGIN';
$debug[] = 'S: ' . trim($resp);
if (substr(trim($resp), 0, 3) === '334') {
$resp = $send(base64_encode($user));
$debug[] = 'C: [username]';
$debug[] = 'S: ' . trim($resp);
if (substr(trim($resp), 0, 3) === '334') {
$resp = $send(base64_encode($pass));
$debug[] = 'C: [password]';
$debug[] = 'S: ' . trim($resp);
if (substr(trim($resp), 0, 3) === '235') {
$authOk = true;
} else {
$authErrors[] = 'LOGIN password rejected';
}
} else {
$authErrors[] = 'LOGIN username rejected';
}
} else {
$authErrors[] = 'LOGIN command rejected';
}
}
if (!$authOk) {
fclose($socket);
$err = $authErrors ? implode(', ', $authErrors) : 'No supported AUTH method';
return ['ok' => false, 'error' => 'SMTP authentication failed: ' . $err, 'debug' => implode("\n", $debug)];
}
}
$resp = $send("MAIL FROM:<{$fromEmail}>");
$debug[] = "C: MAIL FROM:<{$fromEmail}>";
$debug[] = 'S: ' . trim($resp);
if (!self::isOkResponse($resp)) {
fclose($socket);
return ['ok' => false, 'error' => 'SMTP MAIL FROM failed', 'debug' => implode("\n", $debug)];
}
$resp = $send("RCPT TO:<{$to}>");
$debug[] = "C: RCPT TO:<{$to}>";
$debug[] = 'S: ' . trim($resp);
if (!self::isOkResponse($resp)) {
fclose($socket);
return ['ok' => false, 'error' => 'SMTP RCPT TO failed', 'debug' => implode("\n", $debug)];
}
$resp = $send("DATA");
$debug[] = 'C: DATA';
$debug[] = 'S: ' . trim($resp);
if (substr(trim($resp), 0, 3) !== '354') {
fclose($socket);
return ['ok' => false, 'error' => 'SMTP DATA failed', 'debug' => implode("\n", $debug)];
}
$headers = [
"From: {$fromHeader}",
"To: {$to}",
"Subject: {$subject}",
"MIME-Version: 1.0",
"Content-Type: text/html; charset=utf-8",
];
$message = implode("\r\n", $headers) . "\r\n\r\n" . $html . "\r\n.";
fwrite($socket, $message . "\r\n");
$resp = $read();
$debug[] = 'C: [message body]';
$debug[] = 'S: ' . trim($resp);
if (!self::isOkResponse($resp)) {
fclose($socket);
return ['ok' => false, 'error' => 'SMTP message rejected', 'debug' => implode("\n", $debug)];
}
$resp = $send("QUIT");
$debug[] = 'C: QUIT';
$debug[] = 'S: ' . trim($resp);
fclose($socket);
return ['ok' => true, 'error' => '', 'debug' => implode("\n", $debug)];
}
private static function isOkResponse(string $response): bool
{
$code = substr(trim($response), 0, 3);
if ($code === '') {
return false;
}
return $code[0] === '2' || $code[0] === '3';
}
/**
* @return string[]
*/
private static function parseAuthCapabilities(string $ehloResponse): array
{
$caps = [];
foreach (preg_split('/\r?\n/', $ehloResponse) as $line) {
$line = trim($line);
if ($line === '') {
continue;
}
$line = preg_replace('/^\d{3}[ -]/', '', $line) ?? $line;
if (stripos($line, 'AUTH ') === 0) {
$parts = preg_split('/\s+/', substr($line, 5)) ?: [];
foreach ($parts as $p) {
$p = strtoupper(trim($p));
if ($p !== '') {
$caps[] = $p;
}
}
}
}
return array_values(array_unique($caps));
}
}

25
core/services/Nav.php Normal file
View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Core\Services;
use PDO;
use Throwable;
class Nav
{
public static function links(): array
{
$db = Database::get();
if (!$db instanceof PDO) {
return [];
}
try {
$stmt = $db->query("SELECT id, label, url, sort_order, is_active FROM ac_nav_links ORDER BY sort_order ASC, id ASC");
$rows = $stmt->fetchAll();
} catch (Throwable $e) {
return [];
}
return $rows ?: [];
}
}

View File

@@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
namespace Core\Services;
class Permissions
{
public static function definitions(): array
{
$defs = [
['key' => 'core.dashboard', 'label' => 'Dashboard', 'group' => 'Core'],
['key' => 'core.settings', 'label' => 'Settings', 'group' => 'Core'],
['key' => 'core.navigation', 'label' => 'Navigation', 'group' => 'Core'],
['key' => 'core.accounts', 'label' => 'Accounts', 'group' => 'Core'],
['key' => 'core.plugins', 'label' => 'Plugins', 'group' => 'Core'],
['key' => 'module.pages', 'label' => 'Pages', 'group' => 'Modules'],
['key' => 'module.shortcodes', 'label' => 'Shortcodes', 'group' => 'Modules'],
['key' => 'module.blog', 'label' => 'Blog', 'group' => 'Modules'],
['key' => 'module.media', 'label' => 'Media', 'group' => 'Modules'],
['key' => 'module.newsletter', 'label' => 'Newsletter', 'group' => 'Modules'],
];
foreach (Plugins::all() as $plugin) {
$slug = trim((string)($plugin['slug'] ?? ''));
if ($slug === '') {
continue;
}
$defs[] = [
'key' => 'plugin.' . $slug,
'label' => (string)($plugin['name'] ?? ucfirst($slug)),
'group' => 'Plugins',
];
}
return $defs;
}
public static function matrix(): array
{
$default = self::defaultMatrix();
$raw = Settings::get('role_permissions_json', '');
if ($raw === '') {
return $default;
}
$decoded = json_decode($raw, true);
if (!is_array($decoded)) {
return $default;
}
foreach ($default as $perm => $roles) {
$row = $decoded[$perm] ?? null;
if (!is_array($row)) {
continue;
}
foreach (['admin', 'manager', 'editor'] as $role) {
if (array_key_exists($role, $row)) {
$default[$perm][$role] = self::toBool($row[$role]);
}
}
}
return $default;
}
public static function saveMatrix(array $posted): void
{
$current = self::matrix();
foreach ($current as $permission => $roles) {
foreach ($roles as $role => $allowed) {
$current[$permission][$role] = isset($posted[$permission][$role]);
}
}
Settings::set('role_permissions_json', json_encode($current, JSON_UNESCAPED_SLASHES));
}
public static function can(string $role, string $permission): bool
{
$matrix = self::matrix();
if (!isset($matrix[$permission])) {
return true;
}
return (bool)($matrix[$permission][$role] ?? false);
}
public static function routePermission(string $path): ?string
{
if ($path === '/admin') {
return 'core.dashboard';
}
if (!str_starts_with($path, '/admin/')) {
return null;
}
$slug = trim((string)explode('/', trim(substr($path, strlen('/admin/')), '/'))[0]);
if ($slug === '' || in_array($slug, ['login', 'logout', 'install', 'installer'], true)) {
return null;
}
$coreMap = [
'settings' => 'core.settings',
'navigation' => 'core.navigation',
'accounts' => 'core.accounts',
'plugins' => 'core.plugins',
'pages' => 'module.pages',
'shortcodes' => 'module.shortcodes',
'blog' => 'module.blog',
'media' => 'module.media',
'newsletter' => 'module.newsletter',
];
if (isset($coreMap[$slug])) {
return $coreMap[$slug];
}
return 'plugin.' . $slug;
}
private static function defaultMatrix(): array
{
$matrix = [];
foreach (self::definitions() as $def) {
$key = (string)$def['key'];
$matrix[$key] = [
'admin' => true,
'manager' => true,
'editor' => false,
];
}
foreach (['module.pages', 'module.shortcodes', 'module.blog'] as $editorAllowed) {
if (isset($matrix[$editorAllowed])) {
$matrix[$editorAllowed]['editor'] = true;
}
}
return $matrix;
}
private static function toBool(mixed $value): bool
{
if (is_bool($value)) {
return $value;
}
if (is_int($value) || is_float($value)) {
return ((int)$value) === 1;
}
$v = strtolower(trim((string)$value));
return in_array($v, ['1', 'true', 'yes', 'on'], true);
}
}

258
core/services/Plugins.php Normal file
View File

@@ -0,0 +1,258 @@
<?php
declare(strict_types=1);
namespace Core\Services;
use Core\Http\Router;
use PDO;
use Throwable;
class Plugins
{
private static string $path = '';
private static array $plugins = [];
public static function init(string $path): void
{
self::$path = rtrim($path, '/');
self::sync();
}
public static function all(): array
{
return self::$plugins;
}
public static function enabled(): array
{
return array_values(array_filter(self::$plugins, static function (array $plugin): bool {
return (bool)($plugin['is_enabled'] ?? false);
}));
}
public static function isEnabled(string $slug): bool
{
foreach (self::$plugins as $plugin) {
if ((string)($plugin['slug'] ?? '') === $slug) {
return (bool)($plugin['is_enabled'] ?? false);
}
}
return false;
}
public static function adminNav(): array
{
$items = [];
foreach (self::enabled() as $plugin) {
$nav = $plugin['admin_nav'] ?? null;
if (!is_array($nav)) {
continue;
}
$items[] = [
'label' => (string)($nav['label'] ?? ''),
'url' => (string)($nav['url'] ?? ''),
'roles' => array_values(array_filter((array)($nav['roles'] ?? []))),
'icon' => (string)($nav['icon'] ?? ''),
'slug' => (string)($plugin['slug'] ?? ''),
];
}
return array_values(array_filter($items, static function (array $item): bool {
if ($item['label'] === '' || $item['url'] === '') {
return false;
}
$slug = trim((string)($item['slug'] ?? ''));
if ($slug !== '' && Auth::check() && !Permissions::can(Auth::role(), 'plugin.' . $slug)) {
return false;
}
return true;
}));
}
public static function register(Router $router): void
{
foreach (self::enabled() as $plugin) {
$entry = (string)($plugin['entry'] ?? 'plugin.php');
$entryPath = rtrim((string)($plugin['path'] ?? ''), '/') . '/' . $entry;
if (!is_file($entryPath)) {
continue;
}
$handler = require $entryPath;
if (is_callable($handler)) {
$handler($router);
}
}
}
public static function toggle(string $slug, bool $enabled): void
{
$db = Database::get();
if (!$db instanceof PDO) {
return;
}
try {
$stmt = $db->prepare("UPDATE ac_plugins SET is_enabled = :enabled, updated_at = NOW() WHERE slug = :slug");
$stmt->execute([
':enabled' => $enabled ? 1 : 0,
':slug' => $slug,
]);
} catch (Throwable $e) {
}
self::sync();
if (!$enabled) {
$plugin = null;
foreach (self::$plugins as $item) {
if (($item['slug'] ?? '') === $slug) {
$plugin = $item;
break;
}
}
if ($plugin && !empty($plugin['pages'])) {
self::removeNavLinks($db, (array)$plugin['pages']);
}
}
}
public static function sync(): void
{
$filesystem = self::scanFilesystem();
$db = Database::get();
$dbRows = [];
if ($db instanceof PDO) {
try {
$db->exec("
CREATE TABLE IF NOT EXISTS ac_plugins (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
slug VARCHAR(120) NOT NULL UNIQUE,
name VARCHAR(200) NOT NULL,
version VARCHAR(50) NOT NULL DEFAULT '0.0.0',
is_enabled TINYINT(1) NOT NULL DEFAULT 0,
installed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
");
$stmt = $db->query("SELECT slug, is_enabled FROM ac_plugins");
$dbRows = $stmt ? $stmt->fetchAll(PDO::FETCH_ASSOC) : [];
} catch (Throwable $e) {
$dbRows = [];
}
}
$dbMap = [];
foreach ($dbRows as $row) {
$dbMap[(string)$row['slug']] = (int)$row['is_enabled'];
}
foreach ($filesystem as $slug => $plugin) {
$plugin['is_enabled'] = (bool)($dbMap[$slug] ?? ($plugin['default_enabled'] ?? false));
$filesystem[$slug] = $plugin;
if ($db instanceof PDO && !isset($dbMap[$slug])) {
try {
$stmt = $db->prepare("
INSERT INTO ac_plugins (slug, name, version, is_enabled)
VALUES (:slug, :name, :version, :enabled)
");
$stmt->execute([
':slug' => $slug,
':name' => (string)($plugin['name'] ?? $slug),
':version' => (string)($plugin['version'] ?? '0.0.0'),
':enabled' => $plugin['is_enabled'] ? 1 : 0,
]);
} catch (Throwable $e) {
}
}
if ($db instanceof PDO && $plugin['is_enabled']) {
$thisPages = $plugin['pages'] ?? [];
if (is_array($thisPages) && $thisPages) {
self::ensurePages($db, $thisPages);
}
}
}
self::$plugins = array_values($filesystem);
}
private static function scanFilesystem(): array
{
if (self::$path === '' || !is_dir(self::$path)) {
return [];
}
$plugins = [];
foreach (glob(self::$path . '/*/plugin.json') as $manifestPath) {
$dir = dirname($manifestPath);
$slug = basename($dir);
$raw = file_get_contents($manifestPath);
$decoded = json_decode($raw ?: '', true);
if (!is_array($decoded)) {
$decoded = [];
}
$plugins[$slug] = [
'slug' => $slug,
'name' => (string)($decoded['name'] ?? $slug),
'version' => (string)($decoded['version'] ?? '0.0.0'),
'description' => (string)($decoded['description'] ?? ''),
'author' => (string)($decoded['author'] ?? ''),
'admin_nav' => is_array($decoded['admin_nav'] ?? null) ? $decoded['admin_nav'] : null,
'pages' => is_array($decoded['pages'] ?? null) ? $decoded['pages'] : [],
'entry' => (string)($decoded['entry'] ?? 'plugin.php'),
'default_enabled' => (bool)($decoded['default_enabled'] ?? false),
'path' => $dir,
];
}
return $plugins;
}
private static function ensurePages(PDO $db, array $pages): void
{
foreach ($pages as $page) {
if (!is_array($page)) {
continue;
}
$slug = trim((string)($page['slug'] ?? ''));
$title = trim((string)($page['title'] ?? ''));
$content = (string)($page['content_html'] ?? '');
if ($slug === '' || $title === '') {
continue;
}
try {
$stmt = $db->prepare("SELECT id FROM ac_pages WHERE slug = :slug LIMIT 1");
$stmt->execute([':slug' => $slug]);
if ($stmt->fetch()) {
continue;
}
$insert = $db->prepare("
INSERT INTO ac_pages (title, slug, content_html, is_published, is_home, is_blog_index)
VALUES (:title, :slug, :content_html, 1, 0, 0)
");
$insert->execute([
':title' => $title,
':slug' => $slug,
':content_html' => $content,
]);
} catch (Throwable $e) {
}
}
}
private static function removeNavLinks(PDO $db, array $pages): void
{
foreach ($pages as $page) {
if (!is_array($page)) {
continue;
}
$slug = trim((string)($page['slug'] ?? ''));
if ($slug === '') {
continue;
}
$url = '/' . ltrim($slug, '/');
try {
$stmt = $db->prepare("DELETE FROM ac_nav_links WHERE url = :url");
$stmt->execute([':url' => $url]);
} catch (Throwable $e) {
}
}
}
}

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace Core\Services;
use Throwable;
class Settings
{
private static array $data = [];
private static bool $loaded = false;
public static function init(string $path): void
{
if (self::$loaded) {
return;
}
if (is_file($path)) {
$loaded = require $path;
if (is_array($loaded)) {
self::$data = $loaded;
}
}
self::reload();
self::$loaded = true;
}
public static function get(string $key, string $default = ''): string
{
return array_key_exists($key, self::$data) ? (string)self::$data[$key] : $default;
}
public static function reload(): void
{
$db = Database::get();
if (!$db) {
return;
}
try {
$rows = $db->query("SELECT setting_key, setting_value FROM ac_settings")->fetchAll();
foreach ($rows as $row) {
$k = (string)($row['setting_key'] ?? '');
$v = (string)($row['setting_value'] ?? '');
if ($k !== '') {
self::$data[$k] = $v;
}
}
} catch (Throwable $e) {
return;
}
}
public static function set(string $key, string $value): void
{
self::$data[$key] = $value;
$db = Database::get();
if (!$db) {
return;
}
try {
$stmt = $db->prepare("REPLACE INTO ac_settings (setting_key, setting_value) VALUES (:k, :v)");
$stmt->execute([':k' => $key, ':v' => $value]);
} catch (Throwable $e) {
return;
}
}
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace Core\Services;
class Shortcodes
{
/** @var array<string, callable> */
private static array $handlers = [];
public static function register(string $name, callable $handler): void
{
$key = strtolower(trim($name));
if ($key === '') {
return;
}
self::$handlers[$key] = $handler;
}
public static function render(string $content, array $context = []): string
{
if ($content === '' || !self::$handlers) {
return $content;
}
return preg_replace_callback('/\[([a-zA-Z0-9_-]+)([^\]]*)\]/', function (array $m) use ($context): string {
$tag = strtolower((string)($m[1] ?? ''));
if (!isset(self::$handlers[$tag])) {
return (string)$m[0];
}
$attrs = self::parseAttrs((string)($m[2] ?? ''));
try {
$out = (self::$handlers[$tag])($attrs, $context);
return is_string($out) ? $out : '';
} catch (\Throwable $e) {
return '';
}
}, $content) ?? $content;
}
private static function parseAttrs(string $raw): array
{
$attrs = [];
if ($raw === '') {
return $attrs;
}
if (preg_match_all('/([a-zA-Z0-9_-]+)\s*=\s*"([^"]*)"/', $raw, $matches, PREG_SET_ORDER)) {
foreach ($matches as $match) {
$attrs[strtolower((string)$match[1])] = (string)$match[2];
}
}
return $attrs;
}
}

231
core/services/Updater.php Normal file
View File

@@ -0,0 +1,231 @@
<?php
declare(strict_types=1);
namespace Core\Services;
use Throwable;
class Updater
{
private const CACHE_TTL_SECONDS = 21600;
private const MANIFEST_URL = 'https://lab.codemunkeh.io/codemunkeh/AudioCore/raw/branch/main/update.json';
public static function currentVersion(): string
{
$data = require __DIR__ . '/../version.php';
if (is_array($data) && !empty($data['version'])) {
return (string)$data['version'];
}
return '0.0.0';
}
public static function getStatus(bool $force = false): array
{
$current = self::currentVersion();
$manifestUrl = self::MANIFEST_URL;
$channel = trim(Settings::get('update_channel', 'stable'));
if ($channel === '') {
$channel = 'stable';
}
$cache = self::readCache();
if (
!$force
&& is_array($cache)
&& (string)($cache['manifest_url'] ?? '') === $manifestUrl
&& (string)($cache['channel'] ?? '') === $channel
&& ((int)($cache['fetched_at'] ?? 0) + self::CACHE_TTL_SECONDS) > time()
) {
return $cache;
}
$status = [
'ok' => false,
'configured' => true,
'current_version' => $current,
'latest_version' => $current,
'update_available' => false,
'channel' => $channel,
'manifest_url' => $manifestUrl,
'error' => '',
'checked_at' => gmdate('c'),
'download_url' => '',
'changelog_url' => '',
'notes' => '',
'fetched_at' => time(),
];
try {
$raw = self::fetchManifest($manifestUrl);
$decoded = json_decode($raw, true);
if (!is_array($decoded)) {
throw new \RuntimeException('Manifest JSON is invalid.');
}
$release = self::pickReleaseForChannel($decoded, $channel);
$latest = trim((string)($release['version'] ?? ''));
if ($latest === '') {
throw new \RuntimeException('Manifest does not include a version for selected channel.');
}
$status['ok'] = true;
$status['latest_version'] = $latest;
$status['download_url'] = (string)($release['download_url'] ?? '');
$status['changelog_url'] = (string)($release['changelog_url'] ?? '');
$status['notes'] = (string)($release['notes'] ?? '');
$status['update_available'] = version_compare($latest, $current, '>');
} catch (Throwable $e) {
$status['error'] = $e->getMessage();
}
self::writeCache($status);
return $status;
}
private static function pickReleaseForChannel(array $manifest, string $channel): array
{
if (isset($manifest['channels']) && is_array($manifest['channels'])) {
$channels = $manifest['channels'];
if (isset($channels[$channel]) && is_array($channels[$channel])) {
return $channels[$channel];
}
if (isset($channels['stable']) && is_array($channels['stable'])) {
return $channels['stable'];
}
}
return $manifest;
}
private static function cachePath(): string
{
return __DIR__ . '/../../storage/update_cache.json';
}
private static function readCache(): ?array
{
try {
$db = Database::get();
if ($db) {
$stmt = $db->query("
SELECT checked_at, channel, manifest_url, current_version, latest_version,
is_update_available, ok, error_text, payload_json
FROM ac_update_checks
ORDER BY id DESC
LIMIT 1
");
$row = $stmt ? $stmt->fetch() : null;
if (is_array($row)) {
$payload = json_decode((string)($row['payload_json'] ?? ''), true);
if (is_array($payload)) {
$payload['channel'] = (string)($row['channel'] ?? ($payload['channel'] ?? 'stable'));
$payload['manifest_url'] = (string)($row['manifest_url'] ?? ($payload['manifest_url'] ?? ''));
$payload['current_version'] = (string)($row['current_version'] ?? ($payload['current_version'] ?? '0.0.0'));
$payload['latest_version'] = (string)($row['latest_version'] ?? ($payload['latest_version'] ?? '0.0.0'));
$payload['update_available'] = ((int)($row['is_update_available'] ?? 0) === 1);
$payload['ok'] = ((int)($row['ok'] ?? 0) === 1);
$payload['error'] = (string)($row['error_text'] ?? ($payload['error'] ?? ''));
$checkedAt = (string)($row['checked_at'] ?? '');
if ($checkedAt !== '') {
$payload['checked_at'] = $checkedAt;
$payload['fetched_at'] = strtotime($checkedAt) ?: ($payload['fetched_at'] ?? 0);
}
return $payload;
}
}
}
} catch (Throwable $e) {
}
$path = self::cachePath();
if (!is_file($path)) {
return null;
}
$raw = @file_get_contents($path);
if ($raw === false) {
return null;
}
$decoded = json_decode($raw, true);
return is_array($decoded) ? $decoded : null;
}
private static function writeCache(array $data): void
{
try {
$db = Database::get();
if ($db) {
$stmt = $db->prepare("
INSERT INTO ac_update_checks
(checked_at, channel, manifest_url, current_version, latest_version, is_update_available, ok, error_text, payload_json)
VALUES (NOW(), :channel, :manifest_url, :current_version, :latest_version, :is_update_available, :ok, :error_text, :payload_json)
");
$stmt->execute([
':channel' => (string)($data['channel'] ?? 'stable'),
':manifest_url' => (string)($data['manifest_url'] ?? ''),
':current_version' => (string)($data['current_version'] ?? '0.0.0'),
':latest_version' => (string)($data['latest_version'] ?? '0.0.0'),
':is_update_available' => !empty($data['update_available']) ? 1 : 0,
':ok' => !empty($data['ok']) ? 1 : 0,
':error_text' => (string)($data['error'] ?? ''),
':payload_json' => json_encode($data, JSON_UNESCAPED_SLASHES),
]);
}
} catch (Throwable $e) {
}
$path = self::cachePath();
@file_put_contents($path, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
}
private static function fetchManifest(string $manifestUrl): string
{
$ctx = stream_context_create([
'http' => [
'method' => 'GET',
'timeout' => 10,
'header' => "User-Agent: AudioCore-Updater/1.0\r\nAccept: application/json\r\n",
],
'ssl' => [
'verify_peer' => true,
'verify_peer_name' => true,
],
]);
$raw = @file_get_contents($manifestUrl, false, $ctx);
if (is_string($raw) && $raw !== '') {
return $raw;
}
if (!function_exists('curl_init')) {
throw new \RuntimeException('Unable to fetch manifest (file_get_contents failed and cURL is unavailable).');
}
$ch = curl_init($manifestUrl);
if ($ch === false) {
throw new \RuntimeException('Unable to fetch manifest (failed to initialize cURL).');
}
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);
curl_setopt($ch, CURLOPT_TIMEOUT, 15);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'User-Agent: AudioCore-Updater/1.0',
'Accept: application/json',
]);
$body = curl_exec($ch);
$httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
$err = curl_error($ch);
curl_close($ch);
if (!is_string($body) || $body === '') {
$detail = $err !== '' ? $err : ('HTTP ' . $httpCode);
throw new \RuntimeException('Unable to fetch manifest via cURL: ' . $detail);
}
if ($httpCode >= 400) {
throw new \RuntimeException('Manifest request failed with HTTP ' . $httpCode . '.');
}
return $body;
}
}

7
core/version.php Normal file
View File

@@ -0,0 +1,7 @@
<?php
declare(strict_types=1);
return [
'version' => '1.5.0',
];

31
core/views/View.php Normal file
View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Core\Views;
class View
{
private string $basePath;
public function __construct(string $basePath = '')
{
$this->basePath = $basePath !== '' ? rtrim($basePath, '/') : __DIR__ . '/../../views';
}
public function render(string $template, array $vars = []): string
{
$path = $this->basePath !== '' ? $this->basePath . '/' . ltrim($template, '/') : $template;
if (!is_file($path)) {
error_log('AC View missing: ' . $path);
return '';
}
if ($vars) {
extract($vars, EXTR_SKIP);
}
ob_start();
require $path;
return ob_get_clean() ?: '';
}
}