start
This commit is contained in:
50
src/App/App.php
Normal file
50
src/App/App.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
final class App
|
||||
{
|
||||
private static ?self $instance = null;
|
||||
|
||||
private Request $request;
|
||||
private SessionManager $session;
|
||||
private Assets $assets;
|
||||
private I18n $i18n;
|
||||
private Flash $flash;
|
||||
private ?\PDO $pdo;
|
||||
|
||||
private function __construct(private Config $config)
|
||||
{
|
||||
$this->request = new Request();
|
||||
$this->session = new SessionManager($config);
|
||||
$this->assets = new Assets($config);
|
||||
$this->i18n = new I18n($config, 'en');
|
||||
$this->flash = new Flash($this->session);
|
||||
$this->pdo = Database::createPdo($config);
|
||||
}
|
||||
|
||||
public static function init(Config $config): self
|
||||
{
|
||||
if (self::$instance === null) {
|
||||
self::$instance = new self($config);
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
public static function get(): self
|
||||
{
|
||||
if (self::$instance === null) {
|
||||
throw new \RuntimeException('App not initialized. Call App::init() in bootstrap.');
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
public function config(): Config { return $this->config; }
|
||||
public function request(): Request { return $this->request; }
|
||||
public function session(): SessionManager { return $this->session; }
|
||||
public function assets(): Assets { return $this->assets; }
|
||||
public function i18n(): I18n { return $this->i18n; }
|
||||
public function flash(): Flash { return $this->flash; }
|
||||
public function pdo(): ?\PDO { return $this->pdo; }
|
||||
}
|
||||
44
src/App/Assets.php
Normal file
44
src/App/Assets.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
final class Assets
|
||||
{
|
||||
private array $styles = [];
|
||||
private array $scriptsHeader = [];
|
||||
private array $scriptsFooter = [];
|
||||
|
||||
public function __construct(private Config $config) {}
|
||||
|
||||
public function addStyle(string $href, string $priority = 'normal', ?string $version = null): void
|
||||
{
|
||||
$version ??= $this->config->assetVersion;
|
||||
$this->styles[] = [
|
||||
'href' => $href,
|
||||
'priority' => $priority,
|
||||
'version' => $version,
|
||||
];
|
||||
}
|
||||
|
||||
public function addScript(string $src, string $pos = 'footer', bool $defer = true, bool $async = false, ?string $version = null): void
|
||||
{
|
||||
$version ??= $this->config->assetVersion;
|
||||
$row = [
|
||||
'src' => $src,
|
||||
'defer' => $defer,
|
||||
'async' => $async,
|
||||
'version' => $version,
|
||||
];
|
||||
|
||||
if ($pos === 'header') {
|
||||
$this->scriptsHeader[] = $row;
|
||||
} else {
|
||||
$this->scriptsFooter[] = $row;
|
||||
}
|
||||
}
|
||||
|
||||
public function styles(): array { return $this->styles; }
|
||||
public function headerScripts(): array { return $this->scriptsHeader; }
|
||||
public function footerScripts(): array { return $this->scriptsFooter; }
|
||||
}
|
||||
60
src/App/Config.php
Normal file
60
src/App/Config.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
final class Config
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $env,
|
||||
public readonly string $prefix,
|
||||
public readonly string $primaryDomain,
|
||||
public readonly string $primaryUrl,
|
||||
public readonly string $apiBase,
|
||||
public readonly string $assetVersion,
|
||||
public readonly bool $dbEnabled,
|
||||
public readonly array $db,
|
||||
) {}
|
||||
|
||||
public static function fromPhpConstants(string $configDir): self
|
||||
{
|
||||
// config.php defines these constants.
|
||||
$env = defined('APP_ENV') ? (string) APP_ENV : 'prod';
|
||||
$prefix = defined('APP_PREFIX') ? (string) APP_PREFIX : 'app';
|
||||
$primaryDom = defined('APP_DOMAIN_PRIMARY') ? (string) APP_DOMAIN_PRIMARY : 'example.test';
|
||||
$primaryUrl = defined('APP_URL_PRIMARY') ? (string) APP_URL_PRIMARY : 'https://example.test';
|
||||
$apiBase = defined('APP_API_BASE') ? (string) APP_API_BASE : ($primaryUrl . '/api');
|
||||
$assetVersion = defined('ASSET_VERSION') ? (string) ASSET_VERSION : '';
|
||||
|
||||
$dbEnabled = defined('APP_DB_ENABLED') ? (bool) APP_DB_ENABLED : false;
|
||||
|
||||
$dbFile = rtrim($configDir, '/\\') . DIRECTORY_SEPARATOR . 'db.php';
|
||||
$db = file_exists($dbFile) ? (array) require $dbFile : [];
|
||||
|
||||
return new self(
|
||||
env: $env,
|
||||
prefix: $prefix,
|
||||
primaryDomain: $primaryDom,
|
||||
primaryUrl: rtrim($primaryUrl, '/'),
|
||||
apiBase: rtrim($apiBase, '/'),
|
||||
assetVersion: $assetVersion,
|
||||
dbEnabled: $dbEnabled,
|
||||
db: $db
|
||||
);
|
||||
}
|
||||
|
||||
public function cookiePrefix(): string
|
||||
{
|
||||
// Example: add suffix for staging
|
||||
if ($this->env === 'staging') {
|
||||
return $this->prefix . '_stg_';
|
||||
}
|
||||
return $this->prefix . '_';
|
||||
}
|
||||
|
||||
public function cookieDomain(): string
|
||||
{
|
||||
// Leading dot for subdomain-wide cookies
|
||||
return '.' . ltrim($this->primaryDomain, '.');
|
||||
}
|
||||
}
|
||||
123
src/App/Database.php
Normal file
123
src/App/Database.php
Normal file
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
final class Database
|
||||
{
|
||||
public static function createPdo(Config $config): ?\PDO
|
||||
{
|
||||
if (!$config->dbEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$db = $config->db;
|
||||
$driver = (string)($db['driver'] ?? '');
|
||||
|
||||
if ($driver === '') {
|
||||
throw new \RuntimeException('DB enabled but config/db.php missing "driver"');
|
||||
}
|
||||
|
||||
$dsn = match ($driver) {
|
||||
'mysql' => self::buildMysqlDsn($db),
|
||||
'pgsql' => self::buildPgsqlDsn($db),
|
||||
'sqlite' => self::buildSqliteDsn($db),
|
||||
default => throw new \RuntimeException('Unsupported PDO driver: ' . $driver),
|
||||
};
|
||||
|
||||
try {
|
||||
$pdo = new \PDO(
|
||||
$dsn,
|
||||
// sqlite braucht user/pass nicht, PDO ignoriert es aber; wir geben leer zurück
|
||||
(string)($db['user'] ?? ''),
|
||||
(string)($db['password'] ?? ''),
|
||||
(array)($db['options'] ?? [])
|
||||
);
|
||||
|
||||
// Optional: PostgreSQL schema/search_path setzen
|
||||
if ($driver === 'pgsql' && !empty($db['schema'])) {
|
||||
// Minimaler Schutz gegen Injection über schema
|
||||
$schema = preg_replace('/[^a-zA-Z0-9_]/', '', (string)$db['schema']);
|
||||
if ($schema !== '') {
|
||||
$pdo->exec('SET search_path TO ' . $schema);
|
||||
}
|
||||
}
|
||||
|
||||
return $pdo;
|
||||
} catch (\PDOException $e) {
|
||||
// In Prod würdest du loggen; hier minimal
|
||||
http_response_code(500);
|
||||
echo 'Database connection error.';
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
private static function buildMysqlDsn(array $db): string
|
||||
{
|
||||
if (empty($db['dbname'])) {
|
||||
throw new \RuntimeException('MySQL config missing "dbname"');
|
||||
}
|
||||
|
||||
$charset = (string)($db['charset'] ?? 'utf8mb4');
|
||||
|
||||
// Unix socket takes precedence
|
||||
if (!empty($db['unix_socket'])) {
|
||||
return sprintf(
|
||||
'mysql:unix_socket=%s;dbname=%s;charset=%s',
|
||||
(string)$db['unix_socket'],
|
||||
(string)$db['dbname'],
|
||||
$charset
|
||||
);
|
||||
}
|
||||
|
||||
$host = (string)($db['host'] ?? 'localhost');
|
||||
$port = (int)($db['port'] ?? 3306);
|
||||
|
||||
return sprintf(
|
||||
'mysql:host=%s;port=%d;dbname=%s;charset=%s',
|
||||
$host,
|
||||
$port,
|
||||
(string)$db['dbname'],
|
||||
$charset
|
||||
);
|
||||
}
|
||||
|
||||
private static function buildPgsqlDsn(array $db): string
|
||||
{
|
||||
if (empty($db['dbname'])) {
|
||||
throw new \RuntimeException('PostgreSQL config missing "dbname"');
|
||||
}
|
||||
|
||||
$host = (string)($db['host'] ?? 'localhost');
|
||||
$port = (int)($db['port'] ?? 5432);
|
||||
|
||||
// Hinweis: charset gehört bei pgsql nicht in den DSN
|
||||
return sprintf(
|
||||
'pgsql:host=%s;port=%d;dbname=%s',
|
||||
$host,
|
||||
$port,
|
||||
(string)$db['dbname']
|
||||
);
|
||||
}
|
||||
|
||||
private static function buildSqliteDsn(array $db): string
|
||||
{
|
||||
// SQLite kann :memory: oder einen Pfad nutzen
|
||||
$path = (string)($db['path'] ?? '');
|
||||
|
||||
if ($path === '') {
|
||||
// Default: Memory-DB
|
||||
$path = ':memory:';
|
||||
}
|
||||
|
||||
// Wenn es ein Pfad ist, stelle sicher, dass das Verzeichnis existiert.
|
||||
if ($path !== ':memory:') {
|
||||
$dir = \dirname($path);
|
||||
if ($dir && !is_dir($dir)) {
|
||||
@mkdir($dir, 0775, true);
|
||||
}
|
||||
}
|
||||
|
||||
return 'sqlite:' . $path;
|
||||
}
|
||||
}
|
||||
33
src/App/Flash.php
Normal file
33
src/App/Flash.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
final class Flash
|
||||
{
|
||||
public function __construct(private SessionManager $session) {}
|
||||
|
||||
public function set(string $type, string $message): void
|
||||
{
|
||||
$this->session->start();
|
||||
$_SESSION['flash'] = [
|
||||
'type' => $type,
|
||||
'message' => $message,
|
||||
];
|
||||
}
|
||||
|
||||
public function get(): ?array
|
||||
{
|
||||
$this->session->start();
|
||||
if (empty($_SESSION['flash']) || !is_array($_SESSION['flash'])) {
|
||||
return null;
|
||||
}
|
||||
$f = $_SESSION['flash'];
|
||||
unset($_SESSION['flash']);
|
||||
|
||||
return [
|
||||
'type' => (string)($f['type'] ?? 'info'),
|
||||
'message' => (string)($f['message'] ?? ''),
|
||||
];
|
||||
}
|
||||
}
|
||||
59
src/App/I18n.php
Normal file
59
src/App/I18n.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
final class I18n
|
||||
{
|
||||
private array $current = [];
|
||||
private array $fallback = [];
|
||||
|
||||
public function __construct(private Config $config, private string $lang = 'en')
|
||||
{
|
||||
// Minimal example translations (normally load JSON/PHP arrays from disk)
|
||||
$this->fallback = [
|
||||
'common' => [
|
||||
'title' => 'Mini Example Landingpage',
|
||||
'intro' => 'This is a tiny project showing a clean bootstrap.',
|
||||
],
|
||||
'cta' => [
|
||||
'primary' => 'Continue',
|
||||
],
|
||||
];
|
||||
|
||||
$this->current = $this->fallback;
|
||||
}
|
||||
|
||||
private function traverse(array $data, string $key): mixed
|
||||
{
|
||||
$node = $data;
|
||||
foreach (explode('.', $key) as $seg) {
|
||||
if (!is_array($node) || !array_key_exists($seg, $node)) {
|
||||
return null;
|
||||
}
|
||||
$node = $node[$seg];
|
||||
}
|
||||
return $node;
|
||||
}
|
||||
|
||||
public function get(string $key, $default = '', array $vars = []): string
|
||||
{
|
||||
$val = $this->traverse($this->current, $key);
|
||||
if ($val === null) {
|
||||
$val = $this->traverse($this->fallback, $key);
|
||||
}
|
||||
if (!is_string($val)) {
|
||||
$val = (string)($default ?? '');
|
||||
}
|
||||
|
||||
// Built-ins
|
||||
$val = str_replace('{year}', date('Y'), $val);
|
||||
$val = str_replace('{{primary_url}}', $this->config->primaryUrl, $val);
|
||||
|
||||
foreach ($vars as $k => $v) {
|
||||
$val = str_replace('{' . $k . '}', (string)$v, $val);
|
||||
$val = str_replace('{{' . $k . '}}', (string)$v, $val);
|
||||
}
|
||||
return $val;
|
||||
}
|
||||
}
|
||||
47
src/App/Request.php
Normal file
47
src/App/Request.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
final class Request
|
||||
{
|
||||
public function scheme(): string
|
||||
{
|
||||
// Proxy / LB
|
||||
if (!empty($_SERVER['HTTP_X_FORWARDED_PROTO'])) {
|
||||
$proto = strtolower((string)$_SERVER['HTTP_X_FORWARDED_PROTO']);
|
||||
if ($proto === 'https' || $proto === 'http') {
|
||||
return $proto;
|
||||
}
|
||||
}
|
||||
if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') {
|
||||
return 'https';
|
||||
}
|
||||
return 'http';
|
||||
}
|
||||
|
||||
public function host(): string
|
||||
{
|
||||
return (string)($_SERVER['HTTP_HOST'] ?? 'localhost');
|
||||
}
|
||||
|
||||
public function baseUrl(): string
|
||||
{
|
||||
return $this->scheme() . '://' . $this->host();
|
||||
}
|
||||
|
||||
public function path(): string
|
||||
{
|
||||
return (string) strtok((string)($_SERVER['REQUEST_URI'] ?? '/'), '?');
|
||||
}
|
||||
|
||||
public function currentUrl(bool $withQuery = true): string
|
||||
{
|
||||
$base = $this->baseUrl();
|
||||
$uri = (string)($_SERVER['REQUEST_URI'] ?? '/');
|
||||
if ($withQuery) {
|
||||
return $base . $uri;
|
||||
}
|
||||
return $base . (string) strtok($uri, '?');
|
||||
}
|
||||
}
|
||||
71
src/App/SessionManager.php
Normal file
71
src/App/SessionManager.php
Normal file
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
final class SessionManager
|
||||
{
|
||||
private string $sessionCookieName;
|
||||
private string $clientCookieName;
|
||||
|
||||
public function __construct(private Config $config)
|
||||
{
|
||||
$prefix = $config->cookiePrefix();
|
||||
$this->sessionCookieName = $prefix . 'session';
|
||||
$this->clientCookieName = $prefix . 'client';
|
||||
}
|
||||
|
||||
public function start(): void
|
||||
{
|
||||
if (PHP_SAPI === 'cli') {
|
||||
return;
|
||||
}
|
||||
if (session_status() !== PHP_SESSION_NONE) {
|
||||
return;
|
||||
}
|
||||
|
||||
session_name($this->sessionCookieName);
|
||||
|
||||
$secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|
||||
|| (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https');
|
||||
|
||||
session_set_cookie_params([
|
||||
'lifetime' => 0,
|
||||
'path' => '/',
|
||||
'domain' => $this->config->cookieDomain(),
|
||||
'secure' => $secure,
|
||||
'httponly' => true,
|
||||
'samesite' => 'Lax',
|
||||
]);
|
||||
|
||||
session_start();
|
||||
}
|
||||
|
||||
public function ensureClientId(int $lifetimeSeconds = 31536000): string
|
||||
{
|
||||
if (PHP_SAPI === 'cli') {
|
||||
return 'cli';
|
||||
}
|
||||
|
||||
$id = $_COOKIE[$this->clientCookieName] ?? null;
|
||||
if (!is_string($id) || !preg_match('/^[a-f0-9]{64}$/', $id)) {
|
||||
$id = bin2hex(random_bytes(32));
|
||||
|
||||
$secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|
||||
|| (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https');
|
||||
|
||||
setcookie($this->clientCookieName, $id, [
|
||||
'expires' => time() + $lifetimeSeconds,
|
||||
'path' => '/',
|
||||
'domain' => $this->config->cookieDomain(),
|
||||
'secure' => $secure,
|
||||
'httponly' => false, // accessible to JS if needed
|
||||
'samesite' => 'Lax',
|
||||
]);
|
||||
|
||||
$_COOKIE[$this->clientCookieName] = $id;
|
||||
}
|
||||
|
||||
return $id;
|
||||
}
|
||||
}
|
||||
78
src/helpers.php
Normal file
78
src/helpers.php
Normal file
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\App;
|
||||
|
||||
function app(): App
|
||||
{
|
||||
return App::get();
|
||||
}
|
||||
|
||||
function t(string $key, $default = '', array $vars = []): string
|
||||
{
|
||||
return app()->i18n()->get($key, $default, $vars);
|
||||
}
|
||||
|
||||
function tpl(string $file, string $type = 'structure', string $site = 'main'): void
|
||||
{
|
||||
$base = __DIR__ . '/../partials/';
|
||||
|
||||
// very small validation
|
||||
foreach ([$file, $type, $site] as $v) {
|
||||
if (preg_match('/[^a-zA-Z0-9_\-]/', $v)) {
|
||||
echo "<!-- tpl(): invalid parameter -->";
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if ($type === 'landing') {
|
||||
$path = $base . "landing/$site/$file.php";
|
||||
} else {
|
||||
$path = $base . "structure/$file.php";
|
||||
}
|
||||
|
||||
if (file_exists($path)) {
|
||||
include $path;
|
||||
} else {
|
||||
echo "<!-- tpl(): not found: $path -->";
|
||||
}
|
||||
}
|
||||
|
||||
function asset_styles(): void
|
||||
{
|
||||
$styles = app()->assets()->styles();
|
||||
|
||||
// simple priority order
|
||||
$order = ['early' => 0, 'normal' => 1, 'late' => 2];
|
||||
usort($styles, fn($a,$b) => ($order[$a['priority']] ?? 1) <=> ($order[$b['priority']] ?? 1));
|
||||
|
||||
foreach ($styles as $s) {
|
||||
$href = $s['href'];
|
||||
$v = $s['version'];
|
||||
if ($v !== null && $v !== '') {
|
||||
$sep = (str_contains($href, '?') ? '&' : '?');
|
||||
$href = $href . $sep . 'v=' . rawurlencode((string)$v);
|
||||
}
|
||||
echo '<link rel="stylesheet" href="' . htmlspecialchars($href, ENT_QUOTES) . '">' . "\n";
|
||||
}
|
||||
}
|
||||
|
||||
function asset_scripts(string $pos = 'footer'): void
|
||||
{
|
||||
$scripts = ($pos === 'header') ? app()->assets()->headerScripts() : app()->assets()->footerScripts();
|
||||
|
||||
foreach ($scripts as $s) {
|
||||
$src = $s['src'];
|
||||
$v = $s['version'];
|
||||
if ($v !== null && $v !== '') {
|
||||
$sep = (str_contains($src, '?') ? '&' : '?');
|
||||
$src = $src . $sep . 'v=' . rawurlencode((string)$v);
|
||||
}
|
||||
|
||||
$attrs = '';
|
||||
if (!empty($s['defer'])) $attrs .= ' defer';
|
||||
if (!empty($s['async'])) $attrs .= ' async';
|
||||
|
||||
echo '<script src="' . htmlspecialchars($src, ENT_QUOTES) . '"' . $attrs . '></script>' . "\n";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user