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