diff --git a/config/config.php b/config/config.php new file mode 100644 index 0000000..c0a9546 --- /dev/null +++ b/config/config.php @@ -0,0 +1,39 @@ + 0, - 'path' => '/', - 'domain' => APP_COOKIE_DOMAIN ?: '', - 'secure' => (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'), - 'httponly' => true, - 'samesite' => 'Lax', - ]); - - session_start(); - } -} - - -// ----------------------------------------------------------- -// 2) Persistente Client-ID (für Tracking über Besuche hinweg) -// ----------------------------------------------------------- -if (php_sapi_name() !== 'cli') { - $clientId = $_COOKIE[$clientCookieName] ?? null; - - // Erwartet wird: 64 Hex-Zeichen (32 Bytes) - if ( - !is_string($clientId) || - $clientId === '' || - !preg_match('/^[a-f0-9]{64}$/', $clientId) - ) { - // neue ID erzeugen - try { - $clientId = bin2hex(random_bytes(32)); // 32 bytes → 64 hex - } catch (Throwable $e) { - $clientId = bin2hex(openssl_random_pseudo_bytes(32)); +// 2) Composer Autoloader (falls vorhanden) +$composerAutoload = __DIR__ . '/../vendor/autoload.php'; +if (file_exists($composerAutoload)) { + require_once $composerAutoload; +} else { + // 2b) Fallback: minimaler Autoloader + spl_autoload_register(function (string $class): void { + $prefix = 'App\\'; + if (!str_starts_with($class, $prefix)) { + return; } - $cookieOpts = [ - 'expires' => time() + APP_CLIENT_COOKIE_LIFETIME, - 'path' => '/', - 'secure' => (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'), - 'httponly' => false, // JS darf es lesen, wenn erwünscht - 'samesite' => 'Lax', - ]; + $rel = substr($class, strlen($prefix)); + $path = __DIR__ . '/../src/' . str_replace('\\', '/', $rel) . '.php'; - if (!empty(APP_COOKIE_DOMAIN)) { - $cookieOpts['domain'] = APP_COOKIE_DOMAIN; + if (file_exists($path)) { + require_once $path; } - - setcookie($clientCookieName, $clientId, $cookieOpts); - $_COOKIE[$clientCookieName] = $clientId; - } - - // global verfügbar machen (NEUER NAME!) - $GLOBALS['cookie_client_id'] = $clientId; + }); } +// 3) Global helper functions (tpl(), t(), asset_*()) +require_once __DIR__ . '/../src/helpers.php'; -// ----------------------------------------------------------- -// 3) Sprachlogik laden (bleibt sinnvoll zentral) -// ----------------------------------------------------------- -require_once __DIR__ . '/i18n.php'; +// 4) Initialize App (services) +$config = \App\Config::fromPhpConstants(__DIR__ . '/../config'); +\App\App::init($config); +// 5) Start session + create client-id cookie +$app = \App\App::get(); +$app->session()->start(); +$clientId = $app->session()->ensureClientId(); -// ----------------------------------------------------------- -// 4) Rest des Systems laden (DB, Funktionen, Hilfs-Libs) -// ----------------------------------------------------------- -require_once __DIR__ . "/db.php"; -require_once __DIR__ . '/../src/functions.php'; +// Optionally expose a single global for templates if desired +$GLOBALS['client_id'] = $clientId; diff --git a/config/i18n.php b/config/i18n.php deleted file mode 100644 index 2621bc8..0000000 --- a/config/i18n.php +++ /dev/null @@ -1,397 +0,0 @@ - 2) { - $code = substr($code, 0, 2); - } - - if ($code === '') { - return null; - } - - $label = isset($meta['label']) && $meta['label'] !== '' - ? (string)$meta['label'] - : strtoupper($code); - - $flag = isset($meta['flag']) ? (string)$meta['flag'] : ''; - - return [ - 'code' => $code, - 'label' => $label, - 'flag' => $flag, - ]; -} - -/** - * Alle verfügbaren Sprachen aus /public/assets/i18n/*.json ermitteln. - * Verfügbar = JSON mit meta.enabled === true. - * EN wird garantiert hinzugefügt (Fallback), falls nicht gefunden. - */ -function app_i18n_detect_available_languages(): array -{ - $baseDir = realpath(__DIR__ . '/../public/assets/i18n'); - if ($baseDir === false) { - // Wenn gar kein Verzeichnis da ist: minimaler EN-Fallback - return [ - 'en' => [ - 'code' => 'en', - 'label' => 'English', - 'flag' => '', - ], - ]; - } - - $files = glob($baseDir . '/*.json') ?: []; - $langs = []; - - foreach ($files as $file) { - $meta = app_i18n_load_language_meta_from_file($file); - if ($meta === null) { - continue; - } - - $code = $meta['code']; - - // Erste gültige Definition pro Code gewinnt - if (!isset($langs[$code])) { - $langs[$code] = $meta; - } - } - - // EN muss immer vorhanden sein (laut deiner Vorgabe) - if (!isset($langs['en'])) { - // Versuch: gibt es eine en.json, auch wenn enabled=false? - foreach ($files as $file) { - $base = strtolower(basename($file, '.json')); - if ($base === 'en') { - $json = @file_get_contents($file); - $data = json_decode($json, true); - $meta = is_array($data['meta'] ?? null) ? $data['meta'] : []; - - $label = isset($meta['label']) && $meta['label'] !== '' - ? (string)$meta['label'] - : 'English'; - $flag = isset($meta['flag']) ? (string)$meta['flag'] : ''; - - $langs['en'] = [ - 'code' => 'en', - 'label' => $label, - 'flag' => $flag, - ]; - break; - } - } - } - - // Wenn immer noch kein EN → minimaler Stub - if (!isset($langs['en'])) { - $langs['en'] = [ - 'code' => 'en', - 'label' => 'English', - 'flag' => '', - ]; - } - - ksort($langs); - - return $langs; -} - -/** - * Browsersprache aus HTTP_ACCEPT_LANGUAGE extrahieren (2-Buchstaben), - * aber nur, wenn sie in $available existiert. - */ -function app_i18n_detect_browser_lang(array $available): ?string -{ - $header = $_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? ''; - if ($header === '') { - return null; - } - - $parts = explode(',', $header); - foreach ($parts as $part) { - $part = trim($part); - if ($part === '') { - continue; - } - - $code = strtolower(substr($part, 0, 2)); // "de-DE" → "de" - if (isset($available[$code])) { - return $code; - } - } - - return null; -} - -/** - * Aktuelle Sprache bestimmen: - * 1) ?lang=xx (wenn in $available) - * 2) Browsersprache (wenn in $available) - * 3) Fallback "en" - */ -function app_i18n_resolve_current_lang(array $available): string -{ - // 1) URL-Parameter ?lang=xx - if (!empty($_GET['lang'])) { - $param = strtolower(substr($_GET['lang'], 0, 2)); - if (isset($available[$param])) { - return $param; - } - } - - // 2) Browsersprache - $browser = app_i18n_detect_browser_lang($available); - if ($browser !== null) { - return $browser; - } - - // 3) Standard EN - if (isset($available['en'])) { - return 'en'; - } - - // Sicherheitsfallback: erste verfügbare Sprache - $keys = array_keys($available); - return $keys[0] ?? 'en'; -} - -// ----------------------------------------------------- -// Bootstrap ausführen -// ----------------------------------------------------- - -$availableLangs = app_i18n_detect_available_languages(); -$currentLang = app_i18n_resolve_current_lang($availableLangs); - -// Global bereitstellen -$GLOBALS['availableLangs'] = $availableLangs; -$GLOBALS['lang'] = $currentLang; - -// Optional in Session merken (muss nicht, schadet aber auch nicht) -$_SESSION['lang'] = $currentLang; - -/** - * Frontend-Config für JS (usbConfig.i18n) - * → benutzt direkt die Meta-Daten aus den JSONs (code, label, flag) - */ -function app_i18n_get_frontend_config(): array -{ - return [ - 'current' => $GLOBALS['lang'] ?? 'en', - 'available' => $GLOBALS['availableLangs'] ?? [], - ]; -} - -/** - * Komplette JSON-Struktur für eine Sprache laden. - * Nutzt einfachen Request-Cache, damit pro Sprache nur einmal von Platte gelesen wird. - */ -function app_i18n_load_lang_json(string $lang): array -{ - static $cache = []; - - $lang = strtolower(substr($lang, 0, 5)); - - if (isset($cache[$lang])) { - return $cache[$lang]; - } - - $baseDir = realpath(__DIR__ . '/../public/assets/i18n'); - if ($baseDir === false) { - $cache[$lang] = []; - return $cache[$lang]; - } - - $path = $baseDir . '/' . $lang . '.json'; - if (!is_file($path)) { - // Fallback: en.json, falls vorhanden - $fallback = $baseDir . '/en.json'; - if (is_file($fallback)) { - $json = @file_get_contents($fallback); - $data = json_decode($json, true); - $cache[$lang] = is_array($data) ? $data : []; - return $cache[$lang]; - } - - $cache[$lang] = []; - return $cache[$lang]; - } - - $json = @file_get_contents($path); - $data = json_decode($json, true); - $cache[$lang] = is_array($data) ? $data : []; - - return $cache[$lang]; -} - -/** - * Aus einem Label einen stabilen i18n-Key für Nav-Anker bauen. - * Beispiel: "So funktioniert USBCheck!" -> "nav_so_funktioniert_usbcheck" - */ -function app_i18n_make_anchor_key(string $label): string -{ - // HTML-Entities entfernen (z. B. &) - $decoded = html_entity_decode($label, ENT_QUOTES | ENT_HTML5, 'UTF-8'); - - // Kleinbuchstaben - $decoded = mb_strtolower($decoded, 'UTF-8'); - - // Alles, was kein a-z oder 0-9 ist, durch Unterstrich ersetzen - $key = preg_replace('/[^a-z0-9]+/u', '_', $decoded); - - // Mehrfache Unterstriche trimmen - $key = trim($key, '_'); - - if ($key === '') { - $key = 'item'; - } - - // Prefix, damit klar ist, dass es Navigationskeys sind - return 'nav_' . $key; -} - -/** - * Nav-Anker für eine Seite aus der Sprachdatei holen. - * - * Haupt-Variante im JSON: - * - * "pages": { - * "landing": { - * "anchors": { - * "how": "So funktioniert USBCheck", - * "problem": "Warum gefälschte USB-Sticks gefährlich sind", - * "features": "Funktionen", - * "security": "Sicherheit", - * "faq": "FAQ" - * } - * } - * } - * - * Optional explizit: - * "anchors": { - * "how": { "label": "So funktioniert USBCheck", "i18n": "nav_how" }, - * "faq": { "i18n": "nav_faq" } - * } - * - * Rückgabe-Format: - * [ - * [ 'href' => '#how', 'label' => 'So funktioniert USBCheck', 'i18n' => 'nav_so_funktioniert_usbcheck' ], - * [ 'href' => '#faq', 'label' => '', 'i18n' => 'nav_faq' ], - * ] - */ -function app_get_nav_anchors(string $pageKey): array -{ - $lang = $GLOBALS['lang'] ?? 'en'; - $data = app_i18n_load_lang_json($lang); - - $cfg = $data['pages'][$pageKey]['anchors'] ?? null; - if (!is_array($cfg)) { - return []; - } - - $anchors = []; - - foreach ($cfg as $id => $value) { - $id = trim((string)$id); - if ($id === '') { - continue; - } - - $href = '#' . $id; - $label = ''; - $i18n = ''; - - if (is_string($value)) { - // String IMMER als Label übernehmen - $labelTrim = trim($value); - if ($labelTrim === '') { - continue; - } - - $label = $labelTrim; - // i18n-Key automatisch aus dem Label ableiten - $i18n = app_i18n_make_anchor_key($labelTrim); - - } elseif (is_array($value)) { - // Explizite Variante: - // "how": { "label": "...", "i18n": "nav_how" } - if (!empty($value['label'])) { - $label = trim((string)$value['label']); - } - if (!empty($value['i18n'])) { - $i18n = trim((string)$value['i18n']); - } - - if ($label === '' && $i18n === '') { - continue; - } - - // Wenn Label gesetzt, aber kein i18n: automatisch generieren - if ($label !== '' && $i18n === '') { - $i18n = app_i18n_make_anchor_key($label); - } - } else { - // Weder String noch Array → ignorieren - continue; - } - - $anchors[] = [ - 'href' => $href, - 'label' => $label, - 'i18n' => $i18n, - ]; - } - - return $anchors; -} diff --git a/config/prod/config.php b/config/prod/config.php deleted file mode 100644 index b11bace..0000000 --- a/config/prod/config.php +++ /dev/null @@ -1,34 +0,0 @@ - PDO::ERRMODE_EXCEPTION, - PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, - PDO::ATTR_EMULATE_PREPARES => false, +// ------------------------------------------------------------ +// 1) Driver selection (choose one) +// ------------------------------------------------------------ +$driver = 'pgsql'; +// $driver = 'mysql'; +// $driver = 'sqlite'; + +// ------------------------------------------------------------ +// 2) Driver-specific configuration sections +// ------------------------------------------------------------ + +// ---- PostgreSQL (PDO driver: pgsql) ------------------------- +$pgsql = [ + 'driver' => 'pgsql', + 'host' => 'localhost', + 'port' => 5432, + 'dbname' => 'mydb', + + // optional: schema/search_path (commonly "public") + 'schema' => 'public', + + 'user' => 'myuser', + 'password' => 'secret', + + 'options' => [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ], ]; -try { - $pdo = new PDO( - "mysql:host={$DB_HOST};dbname={$DB_NAME};charset=utf8mb4", - $DB_USER, - $DB_PASS, - $options - ); -} catch (PDOException $e) { - // In Produktion Logging, keine Details ausgeben - http_response_code(500); - echo 'Database connection error.'; - exit; +// ---- MySQL / MariaDB (PDO driver: mysql) ------------------- +$mysql = [ + 'driver' => 'mysql', + 'host' => 'localhost', + 'port' => 3306, + 'dbname' => 'mydb', + 'charset' => 'utf8mb4', + + // Alternative to host/port: + // 'unix_socket' => '/var/run/mysqld/mysqld.sock', + + 'user' => 'myuser', + 'password' => 'secret', + + 'options' => [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ], +]; + +// ---- SQLite (PDO driver: sqlite) --------------------------- +$sqlite = [ + 'driver' => 'sqlite', + + // Use an absolute path in production, e.g. /var/app/data/app.sqlite + // For demo/dev you can use a relative path. + 'path' => __DIR__ . '/../var/app.sqlite', + + // SQLite ignores host/port/user/pass + 'options' => [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ], +]; + +// ------------------------------------------------------------ +// 3) Select and return config +// ------------------------------------------------------------ +switch ($driver) { + case 'pgsql': + return $pgsql; + + case 'mysql': + return $mysql; + + case 'sqlite': + return $sqlite; + + default: + throw new RuntimeException('Unsupported DB driver in config/db.php: ' . $driver); } diff --git a/config/prod/domaindata.php b/config/prod/domaindata.php new file mode 100644 index 0000000..9a70f61 --- /dev/null +++ b/config/prod/domaindata.php @@ -0,0 +1,12 @@ + PDO::ERRMODE_EXCEPTION, - PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, - PDO::ATTR_EMULATE_PREPARES => false, +// ------------------------------------------------------------ +// 1) Driver selection (choose one) +// ------------------------------------------------------------ +$driver = 'pgsql'; +// $driver = 'mysql'; +// $driver = 'sqlite'; + +// ------------------------------------------------------------ +// 2) Driver-specific configuration sections +// ------------------------------------------------------------ + +// ---- PostgreSQL (PDO driver: pgsql) ------------------------- +$pgsql = [ + 'driver' => 'pgsql', + 'host' => 'localhost', + 'port' => 5432, + 'dbname' => 'mydb', + + // optional: schema/search_path (commonly "public") + 'schema' => 'public', + + 'user' => 'myuser', + 'password' => 'secret', + + 'options' => [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ], ]; -try { - $pdo = new PDO( - "mysql:host={$DB_HOST};dbname={$DB_NAME};charset=utf8mb4", - $DB_USER, - $DB_PASS, - $options - ); -} catch (PDOException $e) { - // In Produktion Logging, keine Details ausgeben - http_response_code(500); - echo 'Database connection error.'; - exit; +// ---- MySQL / MariaDB (PDO driver: mysql) ------------------- +$mysql = [ + 'driver' => 'mysql', + 'host' => 'localhost', + 'port' => 3306, + 'dbname' => 'mydb', + 'charset' => 'utf8mb4', + + // Alternative to host/port: + // 'unix_socket' => '/var/run/mysqld/mysqld.sock', + + 'user' => 'myuser', + 'password' => 'secret', + + 'options' => [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ], +]; + +// ---- SQLite (PDO driver: sqlite) --------------------------- +$sqlite = [ + 'driver' => 'sqlite', + + // Use an absolute path in production, e.g. /var/app/data/app.sqlite + // For demo/dev you can use a relative path. + 'path' => __DIR__ . '/../var/app.sqlite', + + // SQLite ignores host/port/user/pass + 'options' => [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ], +]; + +// ------------------------------------------------------------ +// 3) Select and return config +// ------------------------------------------------------------ +switch ($driver) { + case 'pgsql': + return $pgsql; + + case 'mysql': + return $mysql; + + case 'sqlite': + return $sqlite; + + default: + throw new RuntimeException('Unsupported DB driver in config/db.php: ' . $driver); } diff --git a/config/staging/domaindata.php b/config/staging/domaindata.php new file mode 100644 index 0000000..09426f6 --- /dev/null +++ b/config/staging/domaindata.php @@ -0,0 +1,12 @@ +assets()->addStyle('/assets/app.css', 'early'); +$app->assets()->addScript('/assets/app.js', 'footer', true); + +$flash = $app->flash()->get(); +?> +
+
env: config()->env, ENT_QUOTES) ?>
+

+ +

+ + +
+ : + +
+ + +
+
+

Runtime

+
Current URL: request()->currentUrl(), ENT_QUOTES) ?>
+
Client-ID:
+
+
+

Actions

+
+ +
+

Flash uses SessionManager, no direct globals.

+
+
+
diff --git a/partials/structure/layout_end.php b/partials/structure/layout_end.php new file mode 100644 index 0000000..1871bd7 --- /dev/null +++ b/partials/structure/layout_end.php @@ -0,0 +1,3 @@ + + + diff --git a/partials/structure/layout_start.php b/partials/structure/layout_start.php new file mode 100644 index 0000000..7d419fc --- /dev/null +++ b/partials/structure/layout_start.php @@ -0,0 +1,22 @@ + + + + + + + <?= htmlspecialchars(t('common.title'), ENT_QUOTES) ?> + + + + + diff --git a/public/..htaccess b/public/..htaccess new file mode 100644 index 0000000..8ca4b75 --- /dev/null +++ b/public/..htaccess @@ -0,0 +1,32 @@ +# ------------------------------------------------- +# Apache Front Controller Setup (public/.htaccess) +# ------------------------------------------------- + +RewriteEngine On + +# Sicherheit: keine Directory Listings +Options -Indexes + +# ------------------------------------------------- +# 1) Assets DIREKT ausliefern +# ------------------------------------------------- +RewriteRule ^assets/ - [L] + +# ------------------------------------------------- +# 2) page/ von außen sperren (nur intern per require nutzbar) +# ------------------------------------------------- +RewriteRule ^page/ - [F,L] + +# ------------------------------------------------- +# 3) Alles andere an den Front Controller +# ------------------------------------------------- +RewriteRule ^ index.php [L] + +# ------------------------------------------------- +# 4) (Optional) Zusätzliche Sicherheits-Header +# ------------------------------------------------- + + Header set X-Frame-Options "SAMEORIGIN" + Header set X-Content-Type-Options "nosniff" + Header set Referrer-Policy "strict-origin-when-cross-origin" + diff --git a/public/assets/app.css b/public/assets/app.css new file mode 100644 index 0000000..1867f66 --- /dev/null +++ b/public/assets/app.css @@ -0,0 +1 @@ +/* minimal css placeholder */ diff --git a/public/assets/app.js b/public/assets/app.js new file mode 100644 index 0000000..bbe8027 --- /dev/null +++ b/public/assets/app.js @@ -0,0 +1 @@ +console.log('mini example loaded'); diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..2926fea --- /dev/null +++ b/public/index.php @@ -0,0 +1,40 @@ +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; } +} diff --git a/src/App/Assets.php b/src/App/Assets.php new file mode 100644 index 0000000..6bd655b --- /dev/null +++ b/src/App/Assets.php @@ -0,0 +1,44 @@ +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; } +} diff --git a/src/App/Config.php b/src/App/Config.php new file mode 100644 index 0000000..c5ca3e5 --- /dev/null +++ b/src/App/Config.php @@ -0,0 +1,60 @@ +env === 'staging') { + return $this->prefix . '_stg_'; + } + return $this->prefix . '_'; + } + + public function cookieDomain(): string + { + // Leading dot for subdomain-wide cookies + return '.' . ltrim($this->primaryDomain, '.'); + } +} diff --git a/src/App/Database.php b/src/App/Database.php new file mode 100644 index 0000000..27f9071 --- /dev/null +++ b/src/App/Database.php @@ -0,0 +1,123 @@ +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; + } +} diff --git a/src/App/Flash.php b/src/App/Flash.php new file mode 100644 index 0000000..39efc44 --- /dev/null +++ b/src/App/Flash.php @@ -0,0 +1,33 @@ +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'] ?? ''), + ]; + } +} diff --git a/src/App/I18n.php b/src/App/I18n.php new file mode 100644 index 0000000..f03b1f9 --- /dev/null +++ b/src/App/I18n.php @@ -0,0 +1,59 @@ +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; + } +} diff --git a/src/App/Request.php b/src/App/Request.php new file mode 100644 index 0000000..f4a78ea --- /dev/null +++ b/src/App/Request.php @@ -0,0 +1,47 @@ +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, '?'); + } +} diff --git a/src/App/SessionManager.php b/src/App/SessionManager.php new file mode 100644 index 0000000..5e57034 --- /dev/null +++ b/src/App/SessionManager.php @@ -0,0 +1,71 @@ +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; + } +} diff --git a/src/helpers.php b/src/helpers.php new file mode 100644 index 0000000..76eed92 --- /dev/null +++ b/src/helpers.php @@ -0,0 +1,78 @@ +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 ""; + return; + } + } + + if ($type === 'landing') { + $path = $base . "landing/$site/$file.php"; + } else { + $path = $base . "structure/$file.php"; + } + + if (file_exists($path)) { + include $path; + } else { + echo ""; + } +} + +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 '' . "\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 '' . "\n"; + } +}