From c7d96bec85cadfa27f564c27a51eea09cd1769ea Mon Sep 17 00:00:00 2001 From: Lars Gebhardt-Kusche Date: Thu, 7 May 2026 23:33:43 +0200 Subject: [PATCH] Yyy --- .gitea/workflows/deploy.yml | 115 +++++++++++ .gitlab-ci.yml | 0 README.md | 0 api/.gitkeep | 0 config/.gitkeep | 0 config/domaindata.php | 4 + config/fileload.php | 121 +++++++++++ config/i18n.php | 397 ++++++++++++++++++++++++++++++++++++ config/prod/.gitkeep | 0 config/prod/config.php | 34 +++ config/prod/db.php | 28 +++ config/staging/.gitkeep | 0 config/staging/config.php | 36 ++++ config/staging/db.php | 28 +++ inc/helpers.php | 155 ++++++++++++++ partials/.gitkeep | 0 public/.gitkeep | 0 src/.gitkeep | 0 tools/.gitkeep | 0 19 files changed, 918 insertions(+) create mode 100644 .gitea/workflows/deploy.yml mode change 100644 => 100755 .gitlab-ci.yml mode change 100644 => 100755 README.md mode change 100644 => 100755 api/.gitkeep mode change 100644 => 100755 config/.gitkeep create mode 100755 config/domaindata.php create mode 100755 config/fileload.php create mode 100755 config/i18n.php mode change 100644 => 100755 config/prod/.gitkeep create mode 100755 config/prod/config.php create mode 100755 config/prod/db.php mode change 100644 => 100755 config/staging/.gitkeep create mode 100755 config/staging/config.php create mode 100755 config/staging/db.php create mode 100755 inc/helpers.php mode change 100644 => 100755 partials/.gitkeep mode change 100644 => 100755 public/.gitkeep mode change 100644 => 100755 src/.gitkeep mode change 100644 => 100755 tools/.gitkeep diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..577e2ca --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,115 @@ +name: Deploy + +on: + push: + branches: + - main + - develop + +env: + BASE_DIRS: "src public api partials tools" + CONFIG_BASE_DIR: "config" + +jobs: + deploy: + runs-on: private-server + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install lftp + shell: sh + run: | + if command -v lftp >/dev/null 2>&1; then + echo "✅ lftp bereits installiert" + elif command -v apk >/dev/null 2>&1; then + apk add --no-cache lftp ca-certificates + elif command -v apt-get >/dev/null 2>&1; then + apt-get update + apt-get install -y lftp ca-certificates + else + echo "❌ Kein unterstützter Paketmanager gefunden" + exit 1 + fi + + - name: Set environment + run: | + if [ "${{ gitea.ref_name }}" = "main" ]; then + echo "TARGET_PATH=${{ vars.FTP_PATH_PROD }}" >> "$GITHUB_ENV" + echo "CONFIG_ENV_DIR=config/prod" >> "$GITHUB_ENV" + elif [ "${{ gitea.ref_name }}" = "develop" ]; then + echo "TARGET_PATH=${{ vars.FTP_PATH_STAGING }}" >> "$GITHUB_ENV" + echo "CONFIG_ENV_DIR=config/staging" >> "$GITHUB_ENV" + else + echo "Unsupported branch" + exit 1 + fi + + - name: Debug workspace + run: | + echo "📂 CI Workspace:" + pwd + ls -la + + - name: Deploy via FTPS + run: | + set -e + + echo "🚀 Deploy to ${TARGET_PATH}" + + VALID_DIRS="" + + for d in $BASE_DIRS; do + if [ -d "$d" ]; then + VALID_DIRS="$VALID_DIRS $d" + else + echo "⚠️ Überspringe fehlendes Verzeichnis: $d" + fi + done + + if [ -z "$VALID_DIRS" ]; then + echo "❌ Kein deploybares Verzeichnis gefunden." + exit 1 + fi + + for d in $VALID_DIRS; do + echo "🔁 ${d}/ → ${TARGET_PATH}${d}/" + lftp -u "${{ secrets.FTP_USER }}","${{ secrets.FTP_PASSWORD }}" "${{ vars.FTP_HOST }}" -e " + set ftp:ssl-force true; + set ftp:passive-mode true; + set ftp:ssl-protect-data true; + set ssl:verify-certificate no; + mirror -R --delete --exclude .gitkeep ${d}/ ${TARGET_PATH}${d}/; + bye + " || exit 1 + done + + if [ -d "$CONFIG_BASE_DIR" ] && [ -d "$CONFIG_ENV_DIR" ]; then + echo "🧩 Baue gemischtes Config-Verzeichnis" + + rm -rf .ci_config_deploy + mkdir -p .ci_config_deploy + + for f in ${CONFIG_BASE_DIR}/*.php; do + [ -f "$f" ] && cp "$f" .ci_config_deploy/ + done + + cp -R ${CONFIG_ENV_DIR}/. .ci_config_deploy/ + + echo "🔁 config → ${TARGET_PATH}${CONFIG_BASE_DIR}/" + + lftp -u "${{ secrets.FTP_USER }}","${{ secrets.FTP_PASSWORD }}" "${{ vars.FTP_HOST }}" -e " + set ftp:ssl-force true; + set ftp:passive-mode true; + set ftp:ssl-protect-data true; + set ssl:verify-certificate no; + lcd .ci_config_deploy; + mirror -R --delete --exclude .gitkeep ./ ${TARGET_PATH}${CONFIG_BASE_DIR}/; + bye + " || exit 1 + else + echo "⚠️ Config-Deploy übersprungen: ${CONFIG_BASE_DIR} oder ${CONFIG_ENV_DIR} fehlt" + fi + + echo "✅ Deploy abgeschlossen" \ No newline at end of file diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml old mode 100644 new mode 100755 diff --git a/README.md b/README.md old mode 100644 new mode 100755 diff --git a/api/.gitkeep b/api/.gitkeep old mode 100644 new mode 100755 diff --git a/config/.gitkeep b/config/.gitkeep old mode 100644 new mode 100755 diff --git a/config/domaindata.php b/config/domaindata.php new file mode 100755 index 0000000..6d23440 --- /dev/null +++ b/config/domaindata.php @@ -0,0 +1,4 @@ + 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)); + } + + $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', + ]; + + if (!empty(APP_COOKIE_DOMAIN)) { + $cookieOpts['domain'] = APP_COOKIE_DOMAIN; + } + + setcookie($clientCookieName, $clientId, $cookieOpts); + $_COOKIE[$clientCookieName] = $clientId; + } + + // global verfügbar machen (NEUER NAME!) + $GLOBALS['cookie_client_id'] = $clientId; +} + + +// ----------------------------------------------------------- +// 3) Sprachlogik laden (bleibt sinnvoll zentral) +// ----------------------------------------------------------- +require_once __DIR__ . '/i18n.php'; + + +// ----------------------------------------------------------- +// 4) Rest des Systems laden (DB, Funktionen, Hilfs-Libs) +// ----------------------------------------------------------- +require_once __DIR__ . "/db.php"; +require_once __DIR__ . '/../src/functions.php'; diff --git a/config/i18n.php b/config/i18n.php new file mode 100755 index 0000000..2621bc8 --- /dev/null +++ b/config/i18n.php @@ -0,0 +1,397 @@ + 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/.gitkeep b/config/prod/.gitkeep old mode 100644 new mode 100755 diff --git a/config/prod/config.php b/config/prod/config.php new file mode 100755 index 0000000..b11bace --- /dev/null +++ b/config/prod/config.php @@ -0,0 +1,34 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, +]; + +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; +} diff --git a/config/staging/.gitkeep b/config/staging/.gitkeep old mode 100644 new mode 100755 diff --git a/config/staging/config.php b/config/staging/config.php new file mode 100755 index 0000000..e2ea33f --- /dev/null +++ b/config/staging/config.php @@ -0,0 +1,36 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, +]; + +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; +} diff --git a/inc/helpers.php b/inc/helpers.php new file mode 100755 index 0000000..bc762a5 --- /dev/null +++ b/inc/helpers.php @@ -0,0 +1,155 @@ + helper_append_version($src, $version), + 'defer' => $defer, + 'async' => $async, + 'type' => $type, + 'module' => $module, + ]; + + if ($pos === 'header') { + $GLOBALS['page_header_scripts'][] = $data; + } else { + $GLOBALS['page_footer_scripts'][] = $data; + } +} + +function tpl_add_style( + string $href, + string $pos = 'header', + string $priority = 'normal', + ?string $version = null, + string $media = 'all' +): void { + $GLOBALS['page_styles'][] = [ + 'href' => helper_append_version($href, $version), + 'pos' => $pos, + 'priority' => $priority, + 'media' => $media, + ]; +} + +function tpl(string $file, string $type = 'structure', string $site = 'admin'): void +{ + $base = __DIR__ . '/../partials/'; + + $safe = static function ($value) { + return preg_match('/^[a-zA-Z0-9_\-]+$/', $value) === 1; + }; + + if (!$safe($file) || !$safe($type) || !$safe($site)) { + echo ""; + return; + } + + $path = $type === 'landing' + ? $base . "landingpage/{$site}/{$file}.php" + : $base . "structure/{$file}.php"; + + extract($GLOBALS, EXTR_SKIP); + + if (is_file($path)) { + include $path; + } else { + echo ""; + } +} + +function tpl_render_scripts(?array $scripts = null, string $pos = 'footer'): void +{ + if ($scripts === null) { + $scripts = $pos === 'header' + ? ($GLOBALS['page_header_scripts'] ?? []) + : ($GLOBALS['page_footer_scripts'] ?? []); + } + + foreach ($scripts as $script) { + if (empty($script['src'])) { + continue; + } + $attrs = []; + if (!empty($script['module'])) { + $attrs[] = 'type="module"'; + } elseif (!empty($script['type'])) { + $attrs[] = 'type="' . htmlspecialchars((string)$script['type'], ENT_QUOTES) . '"'; + } + if (!empty($script['defer'])) { + $attrs[] = 'defer'; + } + if (!empty($script['async'])) { + $attrs[] = 'async'; + } + $attrString = ''; + if (!empty($attrs)) { + $attrString = ' ' . implode(' ', $attrs); + } + echo '' . PHP_EOL; + } +} + +function tpl_render_styles(?array $styles = null, string $pos = 'header'): void +{ + if ($styles === null) { + $styles = array_filter( + $GLOBALS['page_styles'] ?? [], + static fn ($style) => ($style['pos'] ?? 'header') === $pos + ); + } + + if (!$styles) { + return; + } + + $priorityOrder = ['critical' => 0, 'high' => 1, 'normal' => 2, 'low' => 3]; + usort($styles, static function ($a, $b) use ($priorityOrder) { + $aPriority = strtolower($a['priority'] ?? 'normal'); + $bPriority = strtolower($b['priority'] ?? 'normal'); + $aScore = $priorityOrder[$aPriority] ?? $priorityOrder['normal']; + $bScore = $priorityOrder[$bPriority] ?? $priorityOrder['normal']; + return $aScore <=> $bScore; + }); + + foreach ($styles as $style) { + if (empty($style['href'])) { + continue; + } + $media = trim((string)($style['media'] ?? '')); + $mediaAttr = $media && strtolower($media) !== 'all' ? ' media="' . htmlspecialchars($media, ENT_QUOTES) . '"' : ''; + echo '' . PHP_EOL; + } +} diff --git a/partials/.gitkeep b/partials/.gitkeep old mode 100644 new mode 100755 diff --git a/public/.gitkeep b/public/.gitkeep old mode 100644 new mode 100755 diff --git a/src/.gitkeep b/src/.gitkeep old mode 100644 new mode 100755 diff --git a/tools/.gitkeep b/tools/.gitkeep old mode 100644 new mode 100755