Initial dev export (exclude uploads/runtime)
This commit is contained in:
22
core/bootstrap.php
Normal file
22
core/bootstrap.php
Normal 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
27
core/http/Response.php
Normal 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
185
core/http/Router.php
Normal 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
105
core/services/Audit.php
Normal 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
80
core/services/Auth.php
Normal 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
50
core/services/Csrf.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
50
core/services/Database.php
Normal file
50
core/services/Database.php
Normal 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
226
core/services/Mailer.php
Normal 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
25
core/services/Nav.php
Normal 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 ?: [];
|
||||
}
|
||||
}
|
||||
145
core/services/Permissions.php
Normal file
145
core/services/Permissions.php
Normal 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
258
core/services/Plugins.php
Normal 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) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
67
core/services/Settings.php
Normal file
67
core/services/Settings.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
54
core/services/Shortcodes.php
Normal file
54
core/services/Shortcodes.php
Normal 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
231
core/services/Updater.php
Normal 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
7
core/version.php
Normal file
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'version' => '1.5.0',
|
||||
];
|
||||
|
||||
31
core/views/View.php
Normal file
31
core/views/View.php
Normal 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() ?: '';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user