diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..2e30e92e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +.git +vendor +storage/logs/* +storage/app/* +storage/framework/cache/* +storage/framework/sessions/* +storage/framework/views/* +*.log diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..337ab492 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.md] +trim_trailing_whitespace = false + +[*.{yml,yaml,json}] +indent_size = 2 diff --git a/.gitignore b/.gitignore index 4565ba90..5d488901 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,23 @@ +# Editor / IDE .idea/ + +# System +.DS_Store +Thumbs.db + +# Build / Runtime vendor/ -node_modules/ .env *.log *.retry ansible/.vault_pass +*.Zone.Identifier + +# Backup Dateien +*~ + + +.php-cs-fixer.php + + +#ssl/*.pem diff --git a/Makefile b/Makefile index 427811a3..0abac6cc 100644 --- a/Makefile +++ b/Makefile @@ -1,16 +1,107 @@ -.PHONY: deploy setup stop build restart +# ---------------------------------- +# Projekt: michaelschiemer.de +# Docker & Ansible Makefile +# ---------------------------------- -setup: - ansible-playbook -i ansible/inventory.ini ansible/setup.yml +PROJECT_NAME = michaelschiemer +ENV ?= dev -deploy: - ansible-playbook -i ansible/inventory.ini ansible/deploy.yml +# Standart Docker Compose Befehle -stop: - docker compose down +up: ## Startet alle Docker-Container + ./bin/up + +down: ## Stoppt alle Container + ./bin/down build: - docker compose build --no-cache + docker compose build -restart: stop build - docker compose up -d +restart: ## Neustart aller Container + ./bin/restart + +logs: ## Zeigt Logs aus Docker + docker compose logs -f + +ps: ## Docker PS + docker compose ps + +reload: ## Dump Autoload & Restart PHP + docker-compose exec php composer dump-autoload -o + docker-compose restart php + + +# Wähle dev- oder prod-PHP-Konfig je nach ENV +phpinfo: + @echo "Aktive PHP-Konfiguration: php.$(ENV).ini" + +# Ansible Deployment + +setup: ## Führt Ansible Setup aus + ./bin/setup + +deploy: ## Führt Ansible Deploy aus + ./bin/deploy + +test: ## Führt Tests aus (Platzhalter) + ./bin/test + +# Cleanup temporärer/metadaten-Dateien +clean: ## Entfernt temporäre Dateien + find . -type f -name "*Zone.Identifier" -delete + find . -type f -name "*.retry" -delete + +# Projektstatus +status: ## Zeigt Container-Status + @echo "Aktuelles Projekt: $(PROJECT_NAME)" + @echo "Umgebung: $(ENV)" + +doctor: ## Prüft ob Komponenten installiert sind + @echo "🔍 Prüfe Voraussetzungen..." + @which docker > /dev/null || echo "❌ Docker fehlt" + @which ansible-playbook > /dev/null || echo "❌ Ansible fehlt" + @test -f .env || echo "⚠️ .env-Datei fehlt" + +# Helfer: Automatische Zielübersicht +help: ## Zeigt diese Hilfe an + @echo "" + @echo "🛠 Verfügbare Make-Befehle:" + @grep -E '^[a-zA-Z_-]+:.*?## ' Makefile | sort | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}' + @echo "" + + +composer: ## Use Composer + docker compose exec php composer $(ARGS) + +fix-perms: ## Fix permissions + sudo chown -R $(USER):$(USER) . + +cs: + @$(MAKE) composer ARGS="cs" + +cs-fix-file: ## Fix code style for a specific file + docker compose exec -e PHP_CS_FIXER_IGNORE_ENV=1 php ./vendor/bin/php-cs-fixer fix $(subst \,/,$(FILE)) + +cs-fix: ## Fix code style for all PHP files + docker compose exec -e PHP_CS_FIXER_IGNORE_ENV=1 php ./vendor/bin/php-cs-fixer fix + +health: + ansible-playbook ansible/check.yml + + +# Konfiguration +ANSIBLE_INVENTORY=ansible/inventory/hosts.ini +PLAYBOOK_DIR=ansible/playbooks/deploy + +.PHONY: dev staging production + +dev: + ansible-playbook -i $(ANSIBLE_INVENTORY) $(PLAYBOOK_DIR)/dev.yml #--ask-become-pass + +staging: + ansible-playbook -i $(ANSIBLE_INVENTORY) $(PLAYBOOK_DIR)/staging.yml + +production: + ansible-playbook -i $(ANSIBLE_INVENTORY) $(PLAYBOOK_DIR)/production.yml + +.PHONY: up down build restart logs ps phpinfo deploy setup clean status diff --git a/README.md b/README.md new file mode 100644 index 00000000..0480423d --- /dev/null +++ b/README.md @@ -0,0 +1,14 @@ +## 🚀 Quick Start + +```bash +# Starten +make up + +# Logs anzeigen +make logs + +# Setup-Playbook (Server einmalig vorbereiten) +make setup + +# Deployment (Code + Compose auf Server bringen) +make deploy diff --git a/ansible.cfg b/ansible.cfg new file mode 100644 index 00000000..9c3c58f5 --- /dev/null +++ b/ansible.cfg @@ -0,0 +1,26 @@ +[defaults] +#inventory = ./ansible/inventory.ini +inventory = ./ansible/inventory/hosts.ini +roles_path = ./ansible/roles +remote_tmp = ~/.ansible/tmp +forks = 5 +timeout = 10 +retry_files_enabled = False +deprecation_warnings = False +interpreter_python = auto_silent +#stdout_callback = json +host_key_checking = False +command_warnings = False +gathering = smart +fact_caching = jsonfile +fact_caching_connection = .ansible/cache +fact_caching_timeout = 3600 + +[privilege_escalation] +become = true +become_method = sudo +become_user = root + +[ssh_connection] +pipelining = true +ssh_args = -o ControlMaster=auto -o ControlPersist=60s diff --git a/ansible/client-configs/michael.conf b/ansible/client-configs/michael.conf new file mode 100644 index 00000000..4b20e65e --- /dev/null +++ b/ansible/client-configs/michael.conf @@ -0,0 +1,10 @@ +[Interface] +PrivateKey = +DcT11ipmMwPXpzEqmCPGwy7cSmseG1YzZWk+tTtM30= +Address = 10.8.0.2/32 +DNS = 1.1.1.1 + +[Peer] +PublicKey = 3qFEUREx6VfqrKoGVtzHt2ojgaly7LvwxjPQPNsFyxM= +Endpoint = 94.16.110.151:51820 +AllowedIPs = 10.8.0.0/24, 94.16.110.151/32 +PersistentKeepalive = 25 diff --git a/ansible/deploy.yml b/ansible/deploy.yml deleted file mode 100644 index fd525065..00000000 --- a/ansible/deploy.yml +++ /dev/null @@ -1,4 +0,0 @@ -- hosts: web - become: false - roles: - - deploy diff --git a/ansible/group_vars/all.yml b/ansible/group_vars/all.yml new file mode 100644 index 00000000..7a473892 --- /dev/null +++ b/ansible/group_vars/all.yml @@ -0,0 +1,39 @@ +# Basis-Konfiguration +app_name: michaelschiemer +app_domain: test.michaelschiemer.de +app_email: kontakt@michaelschiemer.de + +# Verzeichnisse +project_root: "{{ playbook_dir | dirname }}" +app_root: /var/www/{{ app_name }} +app_public: "{{ app_root }}/public" + +# Docker +docker_version: "20.10" +docker_compose_version: "2.24.5" + +# Benutzer +deploy_user: deploy + +# Let's Encrypt +letsencrypt_enabled: true +letsencrypt_certbot_method: webroot # oder standalone oder nginx + + +#netcup_customer_id: "218722" +#netcup_api_key: "dmJINUMyNjRmOG1aNDViajZHN2JkOTFRUjU3ckE5ZjJ1Zm1vUz" +#netcup_api_password: "iGWL8Hl4m93DgESsP/MPXmtDd0hEVkZ3480Na0psTlXRALnopl" +#netcup_vserver_id: "v2202309206672239295" + + +# fallback_ip: + +wg_all_clients_private_keys: + michael: "PITbFZ3UfY5vD5dYUCELO37Qo2W8I4R8+r6D9CeMrm4=" + + + +wireguard_clients: + - name: michael + address: 10.8.0.2 + public_key: DEIN_PUBLIC_KEY diff --git a/ansible/group_vars/vpn.yml b/ansible/group_vars/vpn.yml new file mode 100644 index 00000000..da39c7a3 --- /dev/null +++ b/ansible/group_vars/vpn.yml @@ -0,0 +1,4 @@ +wg_privkey: "HIER_DEIN_PRIVATER_KEY_ODER_DATEIPFAD" + +wg_all_clients_private_keys: + michael: "PITbFZ3UfY5vD5dYUCELO37Qo2W8I4R8+r6D9CeMrm4=" diff --git a/ansible/group_vars/web.yml b/ansible/group_vars/web.yml new file mode 100644 index 00000000..e69de29b diff --git a/ansible/inventory.ini b/ansible/inventory.ini index 09cdead9..d6bc2e36 100644 --- a/ansible/inventory.ini +++ b/ansible/inventory.ini @@ -1,2 +1,8 @@ +#[web] +#localhost ansible_connection=local + [web] -localhost ansible_connection=local \ No newline at end of file +94.16.110.151 ansible_user=deploy ansible_ssh_private_key_file=/mnt/c/Users/Mike/.ssh/test.michaelschiemer.de + +[vpn] +94.16.110.151 ansible_user=deploy diff --git a/ansible/inventory/hosts.ini b/ansible/inventory/hosts.ini new file mode 100644 index 00000000..e766ecf8 --- /dev/null +++ b/ansible/inventory/hosts.ini @@ -0,0 +1,12 @@ +[localhost] +127.0.0.1 ansible_connection=local + +[staging] +94.16.110.151 ansible_user=deploy ansible_ssh_private_key_file=/mnt/c/Users/Mike/.ssh/test.michaelschiemer.de + +[production] + + +[vpn] +94.16.110.151 ansible_user=deploy + diff --git a/ansible/playbooks/check.yml b/ansible/playbooks/check.yml new file mode 100644 index 00000000..4b1aa343 --- /dev/null +++ b/ansible/playbooks/check.yml @@ -0,0 +1,40 @@ +#- name: Check ob /ping erreichbar ist +# uri: +# url: "http://localhost/ping" +# status_code: 200 +# return_content: yes +# register: ping_response +# +#- debug: +# var: ping_response.content + +- name: Healthcheck nach dem Deployment + hosts: localhost + connection: local + gather_facts: false + become: false + + vars: + healthcheck_url: "http://127.0.0.1:8080/ping" + max_retries: 10 + delay_between_retries: 3 + + tasks: + - name: Warte, bis der Webserver erreichbar ist + uri: + url: "{{ healthcheck_url }}" + status_code: 200 + return_content: yes + register: healthcheck_response + retries: "{{ max_retries }}" + delay: "{{ delay_between_retries }}" + until: > + healthcheck_response is defined and + healthcheck_response.status is defined and + healthcheck_response.status == 200 + failed_when: healthcheck_response.status != 200 + ignore_errors: false + + - name: Ausgabe des Healthcheck-Resultats + debug: + msg: "Healthcheck erfolgreich: {{ healthcheck_response.content }}" diff --git a/ansible/playbooks/deploy.yml b/ansible/playbooks/deploy.yml new file mode 100644 index 00000000..359dbcb8 --- /dev/null +++ b/ansible/playbooks/deploy.yml @@ -0,0 +1,24 @@ +- name: Deployment in jeweilige Umgebung + hosts: all + become: true + gather_facts: false + + vars: + docker_compose_project_path: "/var/www/michaelschiemer/" + env_file_path: "/var/www/michaelschiemer/.env" + + deploy_root: /var/www/michaelschiemer + deploy_public: "{{ deploy_root }}/public" + deploy_user: deploy + + + app_domain: "example.com" # Passe ggf. an + project_root: "{{ playbook_dir }}/../.." + + + roles: + - app + - nginx + - php + - redis + diff --git a/ansible/playbooks/deploy/dev.yml b/ansible/playbooks/deploy/dev.yml new file mode 100644 index 00000000..d1beaee6 --- /dev/null +++ b/ansible/playbooks/deploy/dev.yml @@ -0,0 +1,26 @@ +- name: Deployment für DEV (localhost) + hosts: localhost + become: true + gather_facts: false + + vars: + docker_compose_project_path: "/home/michael/dev/michaelschiemer" + env_file_path: "/var/www/michaelschiemer/.env" + + deploy_root: /var/www/michaelschiemer + deploy_public: "{{ deploy_root }}/public" + deploy_user: deploy + + app_domain: "localhost" # Passe ggf. an + project_root: "/home/michael/dev/michaelschiemer" + + + roles: + #- app + - nginx + - php + - redis + + tasks: + - name: Common Deployment Tasks + import_tasks: ../deploy/includes/deploy_common.yml diff --git a/ansible/playbooks/deploy/includes/deploy_common.yml b/ansible/playbooks/deploy/includes/deploy_common.yml new file mode 100644 index 00000000..1aad0745 --- /dev/null +++ b/ansible/playbooks/deploy/includes/deploy_common.yml @@ -0,0 +1,29 @@ +- name: Docker Compose Files & Konfigurationen synchronisieren + ansible.builtin.copy: + src: "{{ item.src }}" + dest: "{{ docker_compose_project_path }}/{{ item.dest }}" + owner: root + group: root + mode: 0644 + loop: + - { src: '{{ project_root }}/docker-compose.yml', dest: 'docker-compose.yml' } + - { src: '{{ project_root }}/.env', dest: '.env' } + # Weitere Konfigdateien nach Bedarf (z.B. nginx.conf, redis.conf, ...) + - { src: '{{ project_root }}/docker/nginx/nginx.conf', dest: 'nginx.conf' } + +- name: "Docker Compose: Container hochfahren (Build & Start)" + ansible.builtin.command: | + docker-compose -f {{ docker_compose_project_path }}/docker-compose.yml up -d --build + args: + chdir: "{{ docker_compose_project_path }}" + +- name: Status prüfen + ansible.builtin.command: | + docker-compose -f {{ docker_compose_project_path }}/docker-compose.yml ps + args: + chdir: "{{ docker_compose_project_path }}" + register: compose_ps + +- name: Ergebnis anzeigen + ansible.builtin.debug: + var: compose_ps.stdout_lines diff --git a/ansible/playbooks/deploy/production.yml b/ansible/playbooks/deploy/production.yml new file mode 100644 index 00000000..039ef595 --- /dev/null +++ b/ansible/playbooks/deploy/production.yml @@ -0,0 +1,23 @@ +- name: Deployment für PRODUCTION + hosts: production + become: true + gather_facts: false + + vars: + docker_compose_project_path: "/var/www/www.michaelschiemer.de/" + env_file_path: "/var/www/www.michaelschiemer.de/.env" + deploy_root: /var/www/www.michaelschiemer.de + deploy_public: "{{ deploy_root }}/public" + deploy_user: deploy + app_domain: "michaelschiemer.de" + project_root: "{{ playbook_dir }}/../.." + + roles: + - app + - nginx + - php + - redis + + tasks: + - name: Common Deployment Tasks + import_tasks: ../deploy/includes/deploy_common.yml diff --git a/ansible/playbooks/deploy/staging.yml b/ansible/playbooks/deploy/staging.yml new file mode 100644 index 00000000..e9b74f17 --- /dev/null +++ b/ansible/playbooks/deploy/staging.yml @@ -0,0 +1,23 @@ +- name: Deployment für STAGING + hosts: staging + become: true + gather_facts: false + + vars: + docker_compose_project_path: "/var/www/stage.michaelschiemer/" + env_file_path: "/var/www/stage.michaelschiemer/.env" + deploy_root: /var/www/stage.michaelschiemer + deploy_public: "{{ deploy_root }}/public" + deploy_user: deploy + app_domain: "stage.example.com" + project_root: "{{ playbook_dir }}/../.." + + roles: + - app + - nginx + - php + - redis + + tasks: + - name: Common Deployment Tasks + import_tasks: ../deploy/includes/deploy_common.yml diff --git a/ansible/playbooks/setup.yml b/ansible/playbooks/setup.yml new file mode 100644 index 00000000..19aad3c3 --- /dev/null +++ b/ansible/playbooks/setup.yml @@ -0,0 +1,11 @@ +--- +- name: Basis Setup für alle Zielsysteme + hosts: all + become: true + #gather_facts: true + + roles: + #- common + - docker + #- webserver + #- app diff --git a/ansible/playbooks/test.yml b/ansible/playbooks/test.yml new file mode 100644 index 00000000..4ae994ff --- /dev/null +++ b/ansible/playbooks/test.yml @@ -0,0 +1,6 @@ +- hosts: web + become: true + gather_facts: true + + roles: + - console diff --git a/ansible/playbooks/wireguard.yml b/ansible/playbooks/wireguard.yml new file mode 100644 index 00000000..eb932b48 --- /dev/null +++ b/ansible/playbooks/wireguard.yml @@ -0,0 +1,7 @@ +# ansible/wireguard.yml +- hosts: vpn + become: false + gather_facts: false + + roles: + - wireguard diff --git a/ansible/roles/app/tasks/main.yml b/ansible/roles/app/tasks/main.yml new file mode 100644 index 00000000..ebc2d544 --- /dev/null +++ b/ansible/roles/app/tasks/main.yml @@ -0,0 +1,134 @@ +- name: Zielverzeichnis erstellen + file: + path: "{{ deploy_root }}" + state: directory + owner: "{{ deploy_user }}" + group: "{{ deploy_user }}" + mode: '0755' + +- name: SSL-Verzeichnis sicherstellen + file: + path: "{{ deploy_root }}/ssl" + state: directory + owner: "{{ deploy_user }}" + group: "{{ deploy_user }}" + mode: '0755' + +- name: SSL-Zertifikate prüfen + stat: + path: "/etc/letsencrypt/live/{{ app_domain }}/fullchain.pem" + register: ssl_certs + +- name: SSL-Zertifikate kopieren (falls vorhanden) + copy: + src: "{{ item.src }}" + dest: "{{ item.dest }}" + remote_src: yes + owner: "{{ deploy_user }}" + group: "{{ deploy_user }}" + mode: '0644' + loop: + - { src: "/etc/letsencrypt/live/{{ app_domain }}/fullchain.pem", dest: "{{ deploy_root }}/ssl/fullchain.pem" } + - { src: "/etc/letsencrypt/live/{{ app_domain }}/privkey.pem", dest: "{{ deploy_root }}/ssl/privkey.pem" } + when: ssl_certs.stat.exists + +- name: public-Verzeichnis synchronisieren + synchronize: + src: "{{ playbook_dir }}/../../public/" + dest: "{{ deploy_public }}/" + delete: yes + recursive: yes + +- name: Projekt-Stammdaten kopieren + copy: + src: "{{ playbook_dir }}/../../docker-compose.yml" + dest: "{{ deploy_root }}/docker-compose.yml" + owner: "{{ deploy_user }}" + group: "{{ deploy_user }}" + mode: '0644' + +- name: .env-Datei prüfen + stat: + path: "{{ project_root }}/.env" + register: env_file + +- name: .env kopieren (falls vorhanden) + copy: + src: "{{ project_root }}/.env" + dest: "{{ deploy_root }}/.env" + mode: '0644' + when: env_file.stat.exists + +- name: Quellcode synchronisieren + synchronize: + src: "{{ playbook_dir }}/../../src/" + dest: "{{ deploy_root }}/src/" + delete: yes + recursive: yes + +- name: Docker-Verzeichnis prüfen + stat: + path: "{{ project_root }}/docker" + register: docker_dir + delegate_to: localhost + become: false + +- name: Docker-Configs synchronisieren (falls vorhanden) + synchronize: + src: "{{ project_root }}/docker/" + dest: "{{ deploy_root }}/docker/" + delete: yes + recursive: yes + when: docker_dir.stat.exists + +- name: Rechte im Zielverzeichnis korrigieren + file: + path: "{{ deploy_root }}" + state: directory + owner: "{{ deploy_user }}" + group: "{{ deploy_user }}" + mode: '0755' + recurse: yes + +# Cache-Verzeichnis für UID/GID 1000 (z.B. appuser im Container) +- name: Stelle Schreibrechte für Cache-Verzeichnis her + file: + path: "{{ deploy_root }}/cache" + state: directory + owner: 1000 + group: 1000 + mode: '0775' + recurse: yes + +- name: Docker Compose neu bauen und starten + shell: | + docker compose down + docker compose up -d --build + args: + chdir: "{{ deploy_root }}" + +- name: PHP-Container für Composer starten + shell: docker compose up -d php + args: + chdir: "{{ deploy_root }}" + +- name: Kurze Wartezeit bis PHP-Container bereit + wait_for: + timeout: 5 + +- name: Composer Abhängigkeiten installieren + shell: docker compose exec -T php composer install --no-interaction + args: + chdir: "{{ deploy_root }}" + register: composer_result + ignore_errors: yes + +- name: Composer-Ergebnis anzeigen + debug: + var: composer_result.stdout_lines + when: composer_result.stdout is defined + +- name: Composer-Fehler anzeigen + debug: + var: composer_result.stderr_lines + when: composer_result.stderr is defined diff --git a/ansible/roles/common/tasks/main.yml b/ansible/roles/common/tasks/main.yml new file mode 100644 index 00000000..09b5c525 --- /dev/null +++ b/ansible/roles/common/tasks/main.yml @@ -0,0 +1,26 @@ +# Grundlegende Systemkonfiguration +- name: Basis-Pakete aktualisieren und installieren + apt: + name: + - sudo + - vim + - htop + - git + - zip + - unzip + - curl + - wget + state: present + update_cache: yes + become: true + +# Passwordless sudo für den deploy-Benutzer einrichten +- name: Konfiguriere passwordless sudo für deploy-Benutzer + lineinfile: + path: "/etc/sudoers.d/{{ deploy_user }}" + line: "{{ deploy_user }} ALL=(ALL) NOPASSWD: ALL" + state: present + create: yes + mode: '0440' + validate: 'visudo -cf %s' + become: true diff --git a/ansible/roles/console/tasks/main.yml b/ansible/roles/console/tasks/main.yml new file mode 100644 index 00000000..186fe5e8 --- /dev/null +++ b/ansible/roles/console/tasks/main.yml @@ -0,0 +1,9 @@ +- name: Füge Funktion für ms (mit Argumenten) hinzu + blockinfile: + path: "/home/{{ ansible_user }}/.bashrc" + marker: "# {mark} ms docker alias" + block: | + ms() { + docker compose exec php php ms "$@" + } + become: false diff --git a/ansible/roles/docker/README.md b/ansible/roles/docker/README.md new file mode 100644 index 00000000..0775d479 --- /dev/null +++ b/ansible/roles/docker/README.md @@ -0,0 +1,9 @@ +# Rolle: Docker + +Diese Rolle installiert Docker Engine, CLI, Compose-Plugin sowie (optional) Docker Compose V1 als Fallback. +- Fügt den gewünschten User zur Docker-Gruppe hinzu. +- Startet und aktiviert den Docker-Dienst. + +## Variablen +- `docker_compose_version`: Version von Docker Compose V1 für Fallback (Standard: 1.29.2). +- `docker_user`: Benutzer, der in die Gruppe `docker` aufgenommen werden soll (Standard: aktueller Ansible-User). diff --git a/ansible/roles/docker/defaults/main.yml b/ansible/roles/docker/defaults/main.yml new file mode 100644 index 00000000..9da18b8d --- /dev/null +++ b/ansible/roles/docker/defaults/main.yml @@ -0,0 +1,3 @@ +docker_compose_version: "v2.29.2" +docker_install_compose: true +docker_user: "{{ ansible_user || default('michael' }}" diff --git a/ansible/roles/docker/handlers/main.yml b/ansible/roles/docker/handlers/main.yml new file mode 100644 index 00000000..431208a7 --- /dev/null +++ b/ansible/roles/docker/handlers/main.yml @@ -0,0 +1,4 @@ +- name: restart docker + ansible.builtin.service: + name: docker + state: restarted diff --git a/ansible/roles/docker/tasks/main.yml b/ansible/roles/docker/tasks/main.yml new file mode 100644 index 00000000..7f719412 --- /dev/null +++ b/ansible/roles/docker/tasks/main.yml @@ -0,0 +1,58 @@ +- name: Docker-Abhängigkeiten installieren + apt: + name: + - apt-transport-https + - ca-certificates + - curl + - gnupg + - lsb-release + state: present + update_cache: yes + +- name: Docker GPG-Schlüssel hinzufügen + apt_key: + url: https://download.docker.com/linux/{{ ansible_distribution | lower }}/gpg + state: present + +- name: Docker Repository hinzufügen + apt_repository: + repo: "deb [arch=amd64] https://download.docker.com/linux/{{ ansible_distribution | lower }} {{ ansible_distribution_release }} stable" + state: present + +- name: Docker Engine installieren + apt: + name: + - docker-ce + - docker-ce-cli + - containerd.io + - docker-compose-plugin + state: present + update_cache: yes + +- name: Docker Compose installieren (V1 als Fallback) + get_url: + url: "https://github.com/docker/compose/releases/download/v{{ docker_compose_version }}/docker-compose-linux-x86_64" + dest: /usr/local/bin/docker-compose + mode: '0755' + +- name: Benutzer zur Docker-Gruppe hinzufügen + user: + name: "{{ ansible_user }}" + groups: docker + append: yes + +- name: Docker-Service starten und aktivieren + service: + name: docker + state: started + enabled: yes + notify: restart docker + + +- name: Starte Docker-Container via Compose + community.docker.docker_compose_v2: + #project_src: "{{ playbook_dir | dirname }}/../" # ggf. anpassen auf deinen Compose-Pfad! + project_src: "{{ app_root }}" + build: always + recreate: always + diff --git a/ansible/roles/nginx/defaults/main.yml b/ansible/roles/nginx/defaults/main.yml new file mode 100644 index 00000000..0537db87 --- /dev/null +++ b/ansible/roles/nginx/defaults/main.yml @@ -0,0 +1,5 @@ +nginx_conf_template: nginx.conf.j2 +nginx_default_site_template: default.conf.j2 +nginx_ssl_src_dir: "{{ app_root }}/ssl" +nginx_ssl_dest_dir: "/var/www/michaelschiemer/ssl" +nginx_target_dir: "/var/www/michaelschiemer/docker/nginx" diff --git a/ansible/roles/nginx/files/docker-entrypoint.sh b/ansible/roles/nginx/files/docker-entrypoint.sh new file mode 100644 index 00000000..e69de29b diff --git a/ansible/roles/nginx/handlers/main.yml b/ansible/roles/nginx/handlers/main.yml new file mode 100644 index 00000000..f063c43f --- /dev/null +++ b/ansible/roles/nginx/handlers/main.yml @@ -0,0 +1,2 @@ +- name: reload nginx + ansible.builtin.command: docker exec nginx -s reload diff --git a/ansible/roles/nginx/tasks/main.yml b/ansible/roles/nginx/tasks/main.yml new file mode 100644 index 00000000..9d1cbc57 --- /dev/null +++ b/ansible/roles/nginx/tasks/main.yml @@ -0,0 +1,37 @@ +- name: Stelle das nginx-Verzeichnis sicher + ansible.builtin.file: + path: "{{ nginx_target_dir }}" + state: directory + recurse: yes + mode: '0755' + +- name: Kopiere nginx-Konfiguration (nginx.conf) + ansible.builtin.template: + src: "{{ nginx_conf_template }}" + dest: "{{ nginx_target_dir }}/nginx.conf" + mode: '0644' + +- name: Kopiere default site conf + ansible.builtin.template: + src: "{{ nginx_default_site_template }}" + dest: "{{ nginx_target_dir }}/default.conf" + mode: '0644' + +- name: Kopiere docker-entrypoint Skript + ansible.builtin.copy: + src: docker-entrypoint.sh + dest: "{{ nginx_target_dir }}/docker-entrypoint.sh" + mode: '0755' + +- name: Baue und starte Nginx-Container (optional, wenn Compose separat genutzt wird, dann hier nicht nötig) + ansible.builtin.command: docker-compose up -d --build web + args: + chdir: "{{ docker_compose_project_path }}" + when: nginx_target_dir is defined + register: nginx_compose_result + ignore_errors: true + +- name: Zeige Compose-Resultat + ansible.builtin.debug: + var: nginx_compose_result.stdout_lines + when: nginx_compose_result is defined diff --git a/ansible/roles/nginx/templates/default.conf.j2 b/ansible/roles/nginx/templates/default.conf.j2 new file mode 100644 index 00000000..e69de29b diff --git a/ansible/roles/nginx/templates/nginx.conf.j2 b/ansible/roles/nginx/templates/nginx.conf.j2 new file mode 100644 index 00000000..e69de29b diff --git a/ansible/roles/setup/handlers/main.yml b/ansible/roles/setup/handlers/main.yml new file mode 100644 index 00000000..1819a682 --- /dev/null +++ b/ansible/roles/setup/handlers/main.yml @@ -0,0 +1,4 @@ +- name: Reload nginx + service: + name: nginx + state: reloaded diff --git a/ansible/roles/setup/tasks/main.yml b/ansible/roles/setup/tasks/main.yml index af820109..34d36c99 100644 --- a/ansible/roles/setup/tasks/main.yml +++ b/ansible/roles/setup/tasks/main.yml @@ -1,3 +1,47 @@ -- name: Test-Task Setup-Rolle lokal - debug: - msg: "Setup-Rolle ist vorbereitet – echte Installation folgt auf Server." +- name: Docker installieren + apt: + name: + - docker.io + - docker-compose + state: present + update_cache: yes + +- name: Certbot + Plugin installieren + apt: + name: + - certbot + - python3-certbot-nginx + state: present + update_cache: yes + +- name: Challenge-Verzeichnis für Let's Encrypt anlegen + file: + path: /var/www/html/.well-known/acme-challenge + state: directory + owner: www-data + group: www-data + mode: '0755' + recurse: yes + +- name: Füge Let's Encrypt Challenge-Pfad in den Nginx-Vhost ein + blockinfile: + path: /etc/nginx/sites-available/default + marker: "# {mark} ANSIBLE LETSENCRYPT" + insertafter: "^\\s*server\\s*{" + block: | + location ^~ /.well-known/acme-challenge/ { + root /var/www/html; + allow all; + default_type "text/plain"; + } + notify: Reload nginx + + + +- name: Let's Encrypt Zertifikat anfordern + command: > + certbot --nginx -n --agree-tos --redirect + -m kontakt@michaelschiemer.de + -d test.michaelschiemer.de + args: + creates: /etc/letsencrypt/live/test.michaelschiemer.de/fullchain.pem diff --git a/ansible/roles/system_update/tasks/main.yml b/ansible/roles/system_update/tasks/main.yml new file mode 100644 index 00000000..e091e624 --- /dev/null +++ b/ansible/roles/system_update/tasks/main.yml @@ -0,0 +1,18 @@ +- name: Systempakete aktualisieren + apt: + update_cache: yes + upgrade: safe + autoremove: yes + autoclean: yes + register: upgrade_result + become: true + +- name: Zeige ggf. Anzahl aktualisierter Pakete + debug: + msg: "Anzahl aktualisierter Pakete: {{ upgrade_result.stdout_lines | default([]) | length }}" + +- name: Reboot durchführen, wenn notwendig + reboot: + msg: "Reboot wegen Kernel-/System-Update erforderlich" + pre_reboot_delay: 30 + when: upgrade_result.changed diff --git a/ansible/roles/webserver/tasks/main.yml b/ansible/roles/webserver/tasks/main.yml new file mode 100644 index 00000000..6ff209a4 --- /dev/null +++ b/ansible/roles/webserver/tasks/main.yml @@ -0,0 +1,50 @@ +- name: Certbot + Plugin installieren + apt: + name: + - certbot + - python3-certbot-nginx + state: present + update_cache: yes + when: letsencrypt_enabled + +- name: Challenge-Verzeichnis für Let's Encrypt anlegen + file: + path: "{{ app_public }}/.well-known/acme-challenge" + state: directory + owner: www-data + group: www-data + mode: '0755' + recurse: yes + when: letsencrypt_enabled and letsencrypt_certbot_method == 'webroot' + +- name: Stoppe Nginx für Standalone-Methode + service: + name: nginx + state: stopped + when: letsencrypt_enabled and letsencrypt_certbot_method == 'standalone' + +- name: Let's Encrypt Zertifikat anfordern (Standalone) + command: > + certbot certonly --standalone -n --agree-tos + -m {{ app_email }} + -d {{ app_domain }} + args: + creates: /etc/letsencrypt/live/{{ app_domain }}/fullchain.pem + when: letsencrypt_enabled and letsencrypt_certbot_method == 'standalone' + +- name: Let's Encrypt Zertifikat anfordern (Webroot) + command: > + certbot certonly --webroot -w {{ app_public }} -n --agree-tos + -m {{ app_email }} + -d {{ app_domain }} + args: + creates: /etc/letsencrypt/live/{{ app_domain }}/fullchain.pem + when: letsencrypt_enabled and letsencrypt_certbot_method == 'webroot' + +- name: Kopiere SSL-Zertifikate für Docker + copy: + src: "/etc/letsencrypt/live/{{ app_domain }}/" + dest: "{{ app_root }}/ssl/" + remote_src: yes + mode: '0644' + when: letsencrypt_enabled diff --git a/ansible/roles/wireguard/defaults/main.yml b/ansible/roles/wireguard/defaults/main.yml new file mode 100644 index 00000000..afdb7189 --- /dev/null +++ b/ansible/roles/wireguard/defaults/main.yml @@ -0,0 +1,6 @@ +wireguard_interface: wg0 +wireguard_port: 51820 +wireguard_address: 10.8.0.1/24 +wireguard_server_ip: 94.16.110.151 # oder deine Domain + +wireguard_network: "10.8.0.0/24" diff --git a/ansible/roles/wireguard/tasks/configure.yml b/ansible/roles/wireguard/tasks/configure.yml new file mode 100644 index 00000000..dbcaf692 --- /dev/null +++ b/ansible/roles/wireguard/tasks/configure.yml @@ -0,0 +1,133 @@ +# -------------------------------------------------------- +# WireGuard installieren +# -------------------------------------------------------- + +- name: Stelle sicher, dass WireGuard installiert ist + apt: + name: wireguard + state: present + update_cache: yes + become: true + when: ansible_connection != "local" + +# -------------------------------------------------------- +# Server-Schlüssel erzeugen und speichern +# -------------------------------------------------------- + +- name: Prüfe ob privater Server-Schlüssel existiert + stat: + path: /etc/wireguard/privatekey + register: privkey_file + become: true + when: ansible_connection != "local" + +- name: Erstelle Schlüsselpaar für Server (wenn nicht vorhanden) + command: wg genkey + register: server_private_key + when: ansible_connection != "local" and (not privkey_file.stat.exists | default(true)) + +- name: Speichere privaten Schlüssel + copy: + content: "{{ server_private_key.stdout }}" + dest: /etc/wireguard/privatekey + mode: "0600" + when: server_private_key.stdout is defined and server_private_key.stdout is defined + +- name: Lies privaten Schlüssel ein + slurp: + src: /etc/wireguard/privatekey + become: true + when: ansible_connection != "local" + +- name: Erzeuge öffentlichen Server-Schlüssel + command: "echo '{{ wg_privkey }}' | wg pubkey" + register: wg_pubkey + when: ansible_connection != "local" + +- name: Privaten Server-Schlüssel anzeigen + debug: + msg: "{{ server_private_key }}" + when: ansible_connection != "local" + +# -------------------------------------------------------- +# Client-Key-Erzeugung lokal (einmalig pro Client) +# -------------------------------------------------------- + +- name: Generiere privaten Schlüssel für Clients (auf dem Server) + command: wg genkey + args: + creates: "/etc/wireguard/client-{{ item.name }}.key" + loop: "{{ wireguard_clients }}" + loop_control: + label: "{{ item.name }}" + register: client_private_keys + when: ansible_connection != "local" + + +- name: Erzeuge öffentlichen Schlüssel für Clients + command: "echo '{{ client_privkey_result.stdout }}' | wg pubkey" + register: client_pubkey_result + when: + - ansible_connection != "local" + - client_privkey_result is defined + - client_privkey_result.stdout is defined + +- name: wireguard_clients mit public_key anreichern + set_fact: + wireguard_clients: "{{ wireguard_clients_with_pubkey | default([]) + [ item.0 | combine({'public_key': item.1.stdout|trim }) ] }}" + loop: "{{ wireguard_clients | zip(client_public_keys.results) | list }}" + when: client_public_keys is defined + +- name: Aktuelles wireguard_clients-Set überschreiben + set_fact: + wireguard_clients: "{{ wireguard_clients_with_pubkey }}" + when: wireguard_clients_with_pubkey is defined + +# -------------------------------------------------------- +# Konfigurationsdatei erzeugen +# -------------------------------------------------------- + +#- debug: +# var: wireguard_clients + +- name: Render wg0.conf + template: + src: wg0.conf.j2 + dest: /etc/wireguard/wg0.conf + when: wg_privkey is defined and wg_privkey != "" + +# -------------------------------------------------------- +# IP Forwarding & WireGuard aktivieren +# -------------------------------------------------------- + +- name: Aktiviere IP-Forwarding + sysctl: + name: net.ipv4.ip_forward + value: 1 + state: present + sysctl_set: yes + reload: yes + become: true + when: ansible_connection != "local" + +- name: Starte und aktiviere WireGuard + systemd: + name: wg-quick@wg0 + enabled: true + state: started + daemon_reload: yes + become: true + when: ansible_connection != "local" + +- name: Verteilt für jeden Client die Client-Config + template: + src: client.conf.j2 + dest: "/etc/wireguard/clients/{{ item.name }}.conf" + owner: root + group: root + mode: 0600 + loop: "{{ wireguard_clients }}" + #delegate_to: localhost + run_once: true + become: true + when: ansible_connection != "local" diff --git a/ansible/roles/wireguard/tasks/failsafe.yml b/ansible/roles/wireguard/tasks/failsafe.yml new file mode 100644 index 00000000..1d6283d8 --- /dev/null +++ b/ansible/roles/wireguard/tasks/failsafe.yml @@ -0,0 +1,54 @@ +--- +# roles/wireguard/tasks/failsafe.yml +# Sicherstellt, dass SSH über VPN funktioniert und ein Fallback vorhanden ist + +- name: Stelle sicher, dass wireguard_network gesetzt ist + assert: + that: + - wireguard_network is defined + fail_msg: "wireguard_network muss gesetzt sein (z. B. 10.8.0.0/24)" + +- name: Automatisch externe IP als fallback_ip setzen (nur wenn nicht gesetzt) + shell: curl -s ifconfig.me + register: detected_fallback_ip + when: fallback_ip is not defined + changed_when: false + +- name: Setze fallback_ip dynamisch als Ansible-Fact (wenn nicht gesetzt) + set_fact: + fallback_ip: "{{ detected_fallback_ip.stdout }}" + when: fallback_ip is not defined + +- name: (Optional) Erlaube temporär Fallback-SSH von aktueller IP + ufw: + rule: allow + port: 22 + proto: tcp + from_ip: "{{ fallback_ip }}" + +- name: Erlaube SSH-Zugriff über VPN + ufw: + rule: allow + port: 22 + proto: tcp + from_ip: "{{ wireguard_network }}" + +- name: (Warnung) Prüfe ob VPN-Interface aktiv ist + shell: ip a show dev wg0 + register: vpn_interface_check + failed_when: false + +- name: Hinweis, wenn VPN-Interface nicht aktiv ist + debug: + msg: "⚠️ VPN-Interface wg0 scheint nicht aktiv zu sein. SSH über VPN wird nicht funktionieren." + when: vpn_interface_check.rc != 0 + +- name: (Optional) SSH von überall blockieren – nur wenn VPN aktiv + when: + - ssh_lockdown | default(false) + - vpn_interface_check.rc == 0 + ufw: + rule: deny + port: 22 + proto: tcp + from_ip: 0.0.0.0/0 diff --git a/ansible/roles/wireguard/tasks/firewall.yml b/ansible/roles/wireguard/tasks/firewall.yml new file mode 100644 index 00000000..2110bdf3 --- /dev/null +++ b/ansible/roles/wireguard/tasks/firewall.yml @@ -0,0 +1,83 @@ +# Beispiel: Passe jeden Task in dieser Datei so an: +- name: Aktiviere Firewall-Regeln für WireGuard + ufw: + rule: allow + port: "{{ wireguard_port }}" + proto: udp + become: true + when: ansible_connection != "local" +- name: Prüfe, ob UFW installiert ist + command: which ufw + register: ufw_installed + ignore_errors: true + changed_when: false + +- name: Installiere UFW (falls nicht vorhanden) + apt: + name: ufw + state: present + update_cache: yes + when: ufw_installed.rc != 0 + +# Setze Standardrichtlinien (erst Konfiguration, dann am Ende aktivieren) +- name: Setze Policy für eingehenden Traffic auf "deny" + ufw: + direction: incoming + policy: deny + +- name: Setze Policy für ausgehenden Traffic auf "allow" + ufw: + direction: outgoing + policy: allow + +# WireGuard-Port freigeben (UDP) +- name: WireGuard-Port erlauben + ufw: + rule: allow + port: "{{ wireguard_port | default(51820) }}" + proto: udp + +# SSH von bestimmter IP erlauben +- name: SSH von deiner IP erlauben (empfohlen) + ufw: + rule: allow + port: 22 + proto: tcp + from_ip: "{{ fallback_ip }}" + when: fallback_ip is defined and fallback_ip | length > 0 + +# Temporär für Tests: SSH für alle erlauben (nur bei Bedarf!) +- name: SSH von überall erlauben (fail-safe, NUR während Setup/Test) + ufw: + rule: allow + port: 22 + proto: tcp + when: (not (fallback_ip is defined and fallback_ip | length > 0)) or (enable_ssh_from_anywhere | default(false)) + +# Masquerading für WireGuard +- name: NAT für WireGuard aktivieren + iptables: + table: nat + chain: POSTROUTING + out_interface: "{{ wireguard_exit_interface | default('eth0') }}" + source: "{{ wireguard_network }}" + jump: MASQUERADE + +- name: WireGuard Kernel-Modul laden + modprobe: + name: wireguard + state: present + +# UFW ganz am Schluss aktivieren +- name: UFW aktivieren + ufw: + state: enabled + +- name: Aktive UFW-Regeln anzeigen (zum Debuggen) + command: ufw status verbose + register: ufw_status + changed_when: false + +- name: Zeige UFW-Regeln im Ansible-Output + debug: + var: ufw_status.stdout diff --git a/ansible/roles/wireguard/tasks/generate_client_single.yml b/ansible/roles/wireguard/tasks/generate_client_single.yml new file mode 100644 index 00000000..e7c9dae6 --- /dev/null +++ b/ansible/roles/wireguard/tasks/generate_client_single.yml @@ -0,0 +1,60 @@ +- name: Key-Verzeichnis für Client anlegen + file: + path: "{{ role_path }}/client-keys/{{ client.name }}" + state: directory + mode: "0700" + become: true + +- name: Existenz des privaten Schlüssels prüfen + stat: + path: "{{ role_path }}/client-keys/{{ client.name }}/private.key" + register: client_private_key_stat + +- name: Privaten Schlüssel generieren (nur falls nicht vorhanden) + command: wg genkey + register: genpriv + args: + chdir: "{{ role_path }}/client-keys/{{ client.name }}" + when: not client_private_key_stat.stat.exists + +- name: Privaten Schlüssel speichern (nur falls nicht vorhanden) + copy: + content: "{{ genpriv.stdout }}" + dest: "{{ role_path }}/client-keys/{{ client.name }}/private.key" + mode: "0600" + when: not client_private_key_stat.stat.exists + +- name: Public Key aus privaten Schlüssel generieren (bei Neuerstellung) + command: wg pubkey + args: + stdin: "{{ genpriv.stdout }}" + chdir: "{{ role_path }}/client-keys/{{ client.name }}" + register: genpub + when: not client_private_key_stat.stat.exists + +- name: Bestehenden privaten Schlüssel laden (falls vorhanden) + slurp: + src: "{{ role_path }}/client-keys/{{ client.name }}/private.key" + register: loaded_private + when: client_private_key_stat.stat.exists + +- name: Public Key aus gespeichertem Private Key erzeugen (falls vorhanden) + command: wg pubkey + args: + stdin: "{{ loaded_private.content | b64decode }}" + chdir: "{{ role_path }}/client-keys/{{ client.name }}" + register: genpub_existing + when: client_private_key_stat.stat.exists + +- name: Public Key für Client in Datei schreiben + copy: + content: > + {{ (genpub.stdout if not client_private_key_stat.stat.exists else genpub_existing.stdout) }} + dest: "{{ role_path }}/client-keys/{{ client.name }}/public.key" + mode: "0644" + +- name: Variablen für Client setzen (private/public key, Adresse) + set_fact: + "wg_{{ client.name }}_private_key": "{{ (genpriv.stdout if not client_private_key_stat.stat.exists else loaded_private.content | b64decode) }}" + "wg_{{ client.name }}_public_key": "{{ (genpub.stdout if not client_private_key_stat.stat.exists else genpub_existing.stdout) }}" + "wg_{{ client.name }}_address": "{{ client.address }}" diff --git a/ansible/roles/wireguard/tasks/generate_clients.yml b/ansible/roles/wireguard/tasks/generate_clients.yml new file mode 100644 index 00000000..f615a5de --- /dev/null +++ b/ansible/roles/wireguard/tasks/generate_clients.yml @@ -0,0 +1,39 @@ +- name: Schleife über alle WireGuard-Clients + include_tasks: generate_client_single.yml + loop: "{{ wireguard_clients }}" + loop_control: + loop_var: client + +- name: Generiere privaten Schlüssel für jeden Client + shell: "wg genkey" + register: wg_client_private_keys + loop: "{{ wireguard_clients }}" + loop_control: + label: "{{ item.name }}" + # kein delegate_to mehr! + run_once: true # ggf. auch entfernen, siehe Anmerkung unten + +- name: Setze globale Client-Key-Facts für alle Clients + set_fact: + wg_all_clients_private_keys: >- + {{ + wg_all_clients_private_keys | default({}) | combine({ + item.1.name: item.0.stdout + }) + }} + loop: "{{ wg_client_private_keys.results | zip(wireguard_clients) | list }}" + delegate_to: localhost + run_once: true + + +- name: Generiere Private Keys für Clients + command: "wg genkey" + register: client_keys_raw + loop: "{{ wireguard_clients }}" + loop_control: + loop_var: client + changed_when: false + +- name: Mappe Keys nach Namen + set_fact: + wg_all_clients_private_keys: "{{ dict(wireguard_clients | map(attribute='name') | list | zip(client_keys_raw.results | map(attribute='stdout') | list)) }}" diff --git a/ansible/roles/wireguard/tasks/install.yml b/ansible/roles/wireguard/tasks/install.yml new file mode 100644 index 00000000..69f7b13d --- /dev/null +++ b/ansible/roles/wireguard/tasks/install.yml @@ -0,0 +1,7 @@ +- name: Stelle sicher, dass WireGuard installiert ist + apt: + name: wireguard + state: present + update_cache: yes + become: true + when: ansible_connection != "local" diff --git a/ansible/roles/wireguard/tasks/main.yml b/ansible/roles/wireguard/tasks/main.yml new file mode 100644 index 00000000..9d52278b --- /dev/null +++ b/ansible/roles/wireguard/tasks/main.yml @@ -0,0 +1,22 @@ +#- include_tasks: install.yml +#- include_tasks: configure.yml +#- include_tasks: generate_clients.yml +#- include_tasks: firewall.yml + + +- name: Installiere WireGuard + import_tasks: install.yml + when: ansible_connection != "local" + +- name: Konfiguriere WireGuard + import_tasks: configure.yml + +- name: Generiert .conf Dateien + import_tasks: generate_clients.yml + +- name: Setze Firewall-Regeln + import_tasks: firewall.yml + when: ansible_connection != "local" + +- name: Wende VPN-Failsafe-Regeln an + import_tasks: failsafe.yml diff --git a/ansible/roles/wireguard/templates/client.conf.j2 b/ansible/roles/wireguard/templates/client.conf.j2 new file mode 100644 index 00000000..c9c57564 --- /dev/null +++ b/ansible/roles/wireguard/templates/client.conf.j2 @@ -0,0 +1,10 @@ +[Interface] +PrivateKey = {{ wg_all_clients_private_keys[item.name] }} +Address = {{ item.address }}/32 +DNS = 1.1.1.1 + +[Peer] +PublicKey = {{ item.public_key }} +Endpoint = {{ wireguard_server_ip }}:{{ wireguard_port }} +AllowedIPs = {{ wireguard_network }}, {{ wireguard_server_ip }}/32 +PersistentKeepalive = 25 diff --git a/ansible/roles/wireguard/templates/wg0.conf.j2 b/ansible/roles/wireguard/templates/wg0.conf.j2 new file mode 100644 index 00000000..98209837 --- /dev/null +++ b/ansible/roles/wireguard/templates/wg0.conf.j2 @@ -0,0 +1,12 @@ +[Interface] +Address = {{ wireguard_address }} +PrivateKey = {{ wg_privkey | b64decode | trim }} +ListenPort = {{ wireguard_port }} +PostUp = iptables -A FORWARD -i {{ wireguard_interface }} -j ACCEPT; iptables -A FORWARD -o {{ wireguard_interface }} -j ACCEPT; iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE +PostDown = iptables -D FORWARD -i {{ wireguard_interface }} -j ACCEPT; iptables -D FORWARD -o {{ wireguard_interface }} -j ACCEPT; iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE + +{% for client in wireguard_clients %} +[Peer] +PublicKey = {{ client.public_key }} +AllowedIPs = {{ client.address }}/32 +{% endfor %} diff --git a/ansible/setup.yml b/ansible/setup.yml deleted file mode 100644 index 3786e69a..00000000 --- a/ansible/setup.yml +++ /dev/null @@ -1,4 +0,0 @@ -- hosts: web - become: false - roles: - - setup diff --git a/ansible/wireguard-create-config.yml b/ansible/wireguard-create-config.yml new file mode 100644 index 00000000..376f7145 --- /dev/null +++ b/ansible/wireguard-create-config.yml @@ -0,0 +1,7 @@ +# ansible/wireguard-create-config.yml +- hosts: vpn + gather_facts: false + roles: + - role: wireguard + tasks_from: generate_clients # Zum Beispiel, je nach Task + # tasks_from: generate_client_single # Oder für einzelne Clients diff --git a/ansible/wireguard-install-server.yml b/ansible/wireguard-install-server.yml new file mode 100644 index 00000000..11bb3261 --- /dev/null +++ b/ansible/wireguard-install-server.yml @@ -0,0 +1,9 @@ +# ansible/wireguard-install-server.yml +- hosts: vpn + become: true + gather_facts: true + roles: + - role: wireguard + tasks_from: install # z.B., je nach Namensschema deiner Rolle + - role: wireguard + tasks_from: configure # Für Config/Firewall usw. diff --git a/app/Dockerfile b/app/Dockerfile deleted file mode 100644 index 2f977791..00000000 --- a/app/Dockerfile +++ /dev/null @@ -1,3 +0,0 @@ -FROM nginx:stable-alpine - -COPY nginx/default.conf /etc/nginx/conf.d/default.conf \ No newline at end of file diff --git a/app/html/index.php b/app/html/index.php deleted file mode 100644 index 84f4b4b5..00000000 --- a/app/html/index.php +++ /dev/null @@ -1,2 +0,0 @@ - /etc/nginx/conf.d/default.conf.tmp +mv /etc/nginx/conf.d/default.conf.tmp /etc/nginx/conf.d/default.conf + +# Starte Nginx (Foreground) +exec nginx -g 'daemon off;' diff --git a/docker/nginx/nginx.conf b/docker/nginx/nginx.conf new file mode 100644 index 00000000..1a675e65 --- /dev/null +++ b/docker/nginx/nginx.conf @@ -0,0 +1,37 @@ +worker_processes auto; +pid /tmp/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + server_tokens off; + + # Rate-Limiting für besseren DDoS-Schutz + limit_req_zone $binary_remote_addr zone=mylimit:10m rate=10r/s; + + # Logging-Einstellungen + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + error_log /var/log/nginx/error.log warn; + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + + # TLS-Einstellungen + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + + # Include server configs + include /etc/nginx/conf.d/*.conf; +} diff --git a/docker/nginx/ssl/localhost+2-key.pem b/docker/nginx/ssl/localhost+2-key.pem new file mode 100644 index 00000000..ed2534ce --- /dev/null +++ b/docker/nginx/ssl/localhost+2-key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDAzwS8FGSCDwDg +7QX8OpGkX1SbSwbUyzXNjEta319BvAH2OfcFFCj6u/iqfL7gKOM83t8u71VBFsCx +ZlxX2Ilyu2+r72sCdGBXcK6riTHrkjTs4uV6YV98eJuYhvAzSijpsRQjwnwQ587c +axtCXZhOzee3Tnbtzq4plqmOKR10D+cvrOZxuoKI914blXpGe8ds3vWEixewrex0 +CYhzPj/zEF3yfCoSXeTmFBUbmmH/JwcCK8uO5t6XR1Dyo3M4GOMrmGtO7U4nuL6e +7JsbZfPaEW9wKtDjEwFDJSLy0ALEpiNWvbW4OaZWNkJk0jfKYwyBunNSs62B4307 +oF8lqVo1AgMBAAECggEAbPlU0ryv5fZ256nvlRTBVmbvGep4zPKh0TA3MwBHBY8u +iK1QWVWAp95v+GQTOfzCGphZCl0JEYW7mUiibqAbZ3Za8pGaKMP/48vzXU5ooZ18 +PlsrmlTItEAyqS2zOznyD8se9+snViK+f0QmHwdpWzjze15kx5nmQ+k8ofXJCNwq +q3dJIMI/WNuc0e/mMHYjZBsIwuoUi6YJHCE6RkWhGcnvlyXdKUV73/n8Loy6DUtW +VmshXag7+GfbVZIesMCjfnJ0gr9OG+XrFl6AcggzFA1ZHRoQliraVYGB2duQlIpW +o1wJMhFSGFPZxvl67hwXHJeo7ghHHfqNYXS1OuhV7QKBgQDBrvyzLtav51LzqOUY +2HPvaH86arbARc4Fy6ZJ0TaSlmKQ5GzRG0lG2CR03oZz+OcMV/BU8xUMM7CX0zUq +9RAmbE7rvXYOvqTe8pcdHeKKflzsr5p0HNROaeZdpMu8xoK1KLelAo6UCEBUGEny +oMtQWapuYvmdlHR2el2ICRGNzwKBgQD+1/iM1LcF9CYvEc8Sly9XuoRsdUCxavQa +sssv7eG5kkL8HroNs1pGZU8lNuZaT1V0ekWVOFk+X3+dGgCXg5/e/CluK9K7qOHX +3IkyUnZLEH5sDXGMGBzYA9AQTaB1PMTQYku6GNWYab6LFQTvpvvLcIILaFHokq8p +D/dGVJH8uwKBgQCBOxDBPe9hTye6DGdQPJyekUrS34EwqWLd2xQJDN8sz8rUgpVY +sKwj6PPqRs/PcbQ4ODTTeZ4BljuuEe7XyswL1xiRksjC7dF0MMlDVD1jywyVoFWe +Q94ks+RRdzO5sXplBdYC88HOY/MIKWytxzvhUPK21LNYwUU0CFGAAw0DYQKBgQD4 +mT/qSdscoLXa9tl0fiz9vIJPtvXb3MSxgra5U6n9t9NGVMcUdGBdCZjyaaK+eGOZ +U2mrjiNouAop++KV6x26jWvxACj7TVy6kXT4tP6WbUmWKGsaya7hfp6qOL+NfjFU +Qn8y0+URYB4zWNbO3asFIwSJEkPMx8K9IMkMP5WF3wKBgCYiqAhPDF4WxA3fAqP7 +95px8Clrety0mwOtE/rMQRf1nKJ78oA4pr+/VXRbyghAxtD4psbmBQofX3iwnn3B +o1DV3FLpNw004mvcKGScUcNwHQtWAtWX2nVDcxes5R2DgN+lpmWmf5Tq47p0r5ZP +nRb92drrnf8FoBv78CxLjIu+ +-----END PRIVATE KEY----- diff --git a/docker/nginx/ssl/localhost+2.pem b/docker/nginx/ssl/localhost+2.pem new file mode 100644 index 00000000..58a8c808 --- /dev/null +++ b/docker/nginx/ssl/localhost+2.pem @@ -0,0 +1,25 @@ +-----BEGIN CERTIFICATE----- +MIIEHjCCAoagAwIBAgIQLqhFNHvvWJKUpuypArU2CjANBgkqhkiG9w0BAQsFADBb +MR4wHAYDVQQKExVta2NlcnQgZGV2ZWxvcG1lbnQgQ0ExGDAWBgNVBAsMD21pY2hh +ZWxATWlrZS1QQzEfMB0GA1UEAwwWbWtjZXJ0IG1pY2hhZWxATWlrZS1QQzAeFw0y +NTA1MTgxOTUyMDlaFw0yNzA4MTgxOTUyMDlaMEMxJzAlBgNVBAoTHm1rY2VydCBk +ZXZlbG9wbWVudCBjZXJ0aWZpY2F0ZTEYMBYGA1UECwwPbWljaGFlbEBNaWtlLVBD +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwM8EvBRkgg8A4O0F/DqR +pF9Um0sG1Ms1zYxLWt9fQbwB9jn3BRQo+rv4qny+4CjjPN7fLu9VQRbAsWZcV9iJ +crtvq+9rAnRgV3Cuq4kx65I07OLlemFffHibmIbwM0oo6bEUI8J8EOfO3GsbQl2Y +Ts3nt0527c6uKZapjikddA/nL6zmcbqCiPdeG5V6RnvHbN71hIsXsK3sdAmIcz4/ +8xBd8nwqEl3k5hQVG5ph/ycHAivLjubel0dQ8qNzOBjjK5hrTu1OJ7i+nuybG2Xz +2hFvcCrQ4xMBQyUi8tACxKYjVr21uDmmVjZCZNI3ymMMgbpzUrOtgeN9O6BfJala +NQIDAQABo3YwdDAOBgNVHQ8BAf8EBAMCBaAwEwYDVR0lBAwwCgYIKwYBBQUHAwEw +HwYDVR0jBBgwFoAUhhzxUvThIGRX4MSoX91Vzm1zZ9AwLAYDVR0RBCUwI4IJbG9j +YWxob3N0hwR/AAABhxAAAAAAAAAAAAAAAAAAAAABMA0GCSqGSIb3DQEBCwUAA4IB +gQDUFLYZPo8RrfZh/vwT15LcIce8brdVegms6DvPK9lMZX6C4sGf4+rTJCwPuqHW +dqVZAhHdvcsyGI15xvVPT4qSh89RN1JB9uIHCk+weIzp+Rn06MMrB49m4abAvWp2 +hB8bCo80hMVIsCb3Wr9sHg7CsJItsdGz8jHYCvHpvPLR7gWhYjm1g0meglT3tZqd +TsKDMb3Vj/vsivEueM6Oj/of8xbamVSSkqljWbRls7Ti7xqXMbmf7nl0WvG9IXg3 +5Ucv1AWJIFEeLnMM5V0nEbO3sAhbNMLXieGPBWHXOgHuvVnQyu1mBESjgc5bjwfN +UjYBHluFkF9aYw3mGcFqAlb1FpGoMtHwTw0uGZzHzj5FY8oZix5edq/upriV6cU2 +t0tidlfhvkJNSSO4zjAPjU1wd+/QRZwY2PcB5kBxs5MzSmiMlEjTkGgHWqMWMBf1 +NPbyaxtjL69xBVonxpqD6BLJ2qLatgCs6fkZZF7AT38OFXr8Cv5vxt1rR5fs1P6X +mI0= +-----END CERTIFICATE----- diff --git a/docker/php/Dockerfile b/docker/php/Dockerfile new file mode 100644 index 00000000..18949481 --- /dev/null +++ b/docker/php/Dockerfile @@ -0,0 +1,69 @@ +# Dockerfile für PHP-FPM +FROM php:8.4-fpm AS base + +# System-Abhängigkeiten: Werden selten geändert, daher ein eigener Layer +RUN apt-get update && apt-get install -y \ + git \ + unzip \ + libzip-dev \ + zip \ + && docker-php-ext-install zip pdo pdo_mysql \ + && docker-php-ext-install opcache \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# Composer installieren +RUN curl -sS https://getcomposer.org/installer | php \ + && mv composer.phar /usr/local/bin/composer + +# Installiere Xdebug nur im Entwicklungsmodus +ARG ENV=prod +RUN if [ "$ENV" = "dev" ]; then \ + pecl install xdebug \ + && docker-php-ext-enable xdebug; \ + fi + +WORKDIR /var/www/html + +# Kopiere zuerst nur composer.json/lock für besseres Layer-Caching +COPY composer.json composer.lock ./ + +# Installiere Abhängigkeiten - variiert je nach Umgebung +RUN if [ "$ENV" = "prod" ]; then \ + composer install --no-dev --no-scripts --no-autoloader --optimize-autoloader; \ + else \ + composer install --no-scripts --no-autoloader; \ + fi + +# Kopiere PHP-Konfigurationen +COPY docker/php/php.common.ini /usr/local/etc/php/php.common.ini +COPY docker/php/php.${ENV}.ini /usr/local/etc/php/php.ini + +# Wenn dev, kopiere auch xdebug-Konfiguration +RUN if [ "$ENV" = "dev" ]; then \ + mkdir -p /usr/local/etc/php/conf.d/; \ + fi +COPY docker/php/xdebug.ini /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini + +# Kopiere den Rest des Projekts +COPY . . + +# Optimiere Autoloader +RUN composer dump-autoload --optimize + +# <<--- ALLE zusätzlichen System-Dateien und chmod noch als root! +COPY docker/php/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh +RUN chmod +x /usr/local/bin/docker-entrypoint.sh + +# Danach erst den Nutzer wechseln! +RUN groupadd -g 1000 appuser && useradd -u 1000 -g appuser -m appuser +RUN chown -R appuser:appuser /var/www/html + +USER appuser + +RUN mkdir -p /var/www/html/cache && \ + chown -R 1000:1000 /var/www/html/cache && \ + chmod -R 775 /var/www/html/cache + +ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] +CMD ["php-fpm"] diff --git a/docker/php/docker-entrypoint.sh b/docker/php/docker-entrypoint.sh new file mode 100644 index 00000000..db0575ff --- /dev/null +++ b/docker/php/docker-entrypoint.sh @@ -0,0 +1,4 @@ +#!/bin/bash +chown -R www-data:www-data /var/www/html/cache +chmod -R 775 /var/www/html/cache +exec "$@" diff --git a/docker/php/php.common.ini b/docker/php/php.common.ini new file mode 100644 index 00000000..1198d636 --- /dev/null +++ b/docker/php/php.common.ini @@ -0,0 +1,8 @@ +expose_php = Off + +session.cookie_secure = 1 +session.cookie_httponly = 1 +session.cookie_samesite = Lax + + +date.timezone = Europe/Berlin diff --git a/docker/php/php.development.ini b/docker/php/php.development.ini new file mode 100644 index 00000000..921652d4 --- /dev/null +++ b/docker/php/php.development.ini @@ -0,0 +1,29 @@ +; php.ini für Entwicklung +include = php.common.ini + + +[opcache] +opcache.enable=1 +opcache.enable_cli=0 +opcache.memory_consumption=128 +opcache.max_accelerated_files=10000 +; Häufigere Validierung im Dev-Modus +opcache.revalidate_freq=0 +; Timestamps-Validierung einschalten für Entwicklung +opcache.validate_timestamps=1 + +opcache.file_cache= +realpath_cache_ttl=0 + +opcache.interned_strings_buffer=16 + + +display_errors = On +display_startup_errors = On +error_reporting = E_ALL +memory_limit = 512M +upload_max_filesize = 20M +post_max_size = 25M +max_execution_time = 60 + +; Xdebug-Einstellungen können auch hier hinzugefügt werden, falls gewünscht diff --git a/docker/php/php.prod.ini b/docker/php/php.prod.ini new file mode 100644 index 00000000..557ca72e --- /dev/null +++ b/docker/php/php.prod.ini @@ -0,0 +1,31 @@ +; php.ini für Produktion +include = php.common.ini + +[opcache] +; Aktiviere OPcache +opcache.enable=1 +; Aktiviere OPcache für CLI-Anwendungen (optional) +opcache.enable_cli=0 +; Maximale Speichernutzung für Cache in MB +opcache.memory_consumption=128 +; Maximale Anzahl an gecachten Dateien +opcache.max_accelerated_files=10000 +; Wie oft wird der Cache validiert (0 = bei jedem Request, empfohlen für Entwicklung) +; In Produktion höher setzen für bessere Performance +opcache.revalidate_freq=60 +; Cache-Zeitstempel prüfen (0 für Produktionsumgebungen) +opcache.validate_timestamps=0 +; Performance-Optimierungen +opcache.interned_strings_buffer=16 +; JIT (Just-In-Time Compilation) - Optional für PHP 8.0+ +opcache.jit_buffer_size=100M +opcache.jit=1255 + + +display_errors = Off +display_startup_errors = Off +error_reporting = E_ALL & ~E_DEPRECATED & ~E_STRICT +memory_limit = 256M +upload_max_filesize = 10M +post_max_size = 12M +max_execution_time = 30 diff --git a/docker/php/xdebug.ini b/docker/php/xdebug.ini new file mode 100644 index 00000000..8852ee79 --- /dev/null +++ b/docker/php/xdebug.ini @@ -0,0 +1,7 @@ +; Xdebug 3 Konfiguration +xdebug.mode=${XDEBUG_MODE:-off} +xdebug.client_host=host.docker.internal +xdebug.client_port=9003 +xdebug.start_with_request=yes +xdebug.log=/var/log/xdebug.log +xdebug.idekey=PHPSTORM diff --git a/docker/redis/redis.conf b/docker/redis/redis.conf new file mode 100644 index 00000000..1af50745 --- /dev/null +++ b/docker/redis/redis.conf @@ -0,0 +1,7 @@ +bind 0.0.0.0 +#protected-mode yes +dir /data +save 900 1 +save 300 10 +save 60 10000 +appendonly yes diff --git a/docs/ARCHITECURE.md b/docs/ARCHITECURE.md new file mode 100644 index 00000000..9463c71f --- /dev/null +++ b/docs/ARCHITECURE.md @@ -0,0 +1,25 @@ +# Architektur-Prinzipien + +Dieses Dokument beschreibt die grundlegenden Architekturprinzipien unseres Frameworks. + +## 1. Immutabilität und Unveränderlichkeit + +Wo immer möglich, sollten Objekte unveränderlich (immutable) sein. Dies verbessert die Voraussagbarkeit und Testbarkeit. + +## 2. Final by Default + +Alle Klassen sollten standardmäßig als `final` deklariert werden, es sei denn, es gibt einen konkreten Grund für Vererbung. +Begründung: +- Vermeidet unbeabsichtigte Vererbungshierarchien +- Verbessert die Kapselung +- Ermöglicht interne Änderungen, ohne Kinderklassen zu beeinflussen + +## 3. Explizite über Implizite + +- Alle Abhängigkeiten sollten explizit injiziert werden +- Keine globalen Zustände oder Singletons +- Typen immer explizit deklarieren + +## 4. Modularität + +Jedes Modul sollte in sich geschlossen sein und minimale Abhängigkeiten nach außen haben. diff --git a/docs/ENV.md b/docs/ENV.md index 9f1c7fe1..29df0a57 100644 --- a/docs/ENV.md +++ b/docs/ENV.md @@ -8,7 +8,7 @@ Dieses Projekt verwendet `.env`-Dateien zur Konfiguration von Docker Compose und ```env COMPOSE_PROJECT_NAME=michaelschiemer -APP_PORT=8080 +APP_PORT=8000 PHP_VERSION=8.2 ``` diff --git a/docs/features.md b/docs/features.md new file mode 100644 index 00000000..a1901015 --- /dev/null +++ b/docs/features.md @@ -0,0 +1,42 @@ +# Framework Features + +## Core Features +- [ ] Routing mit Unterstützung für PHP-Attribute +- [ ] Dependency Injection Container +- [ ] Request/Response Abstraktion +- [ ] Template-Engine +- [ ] Error/Exception Handling +- [ ] Konfigurationssystem + +## Database Features +- [ ] PDO-Wrapper +- [ ] Query Builder +- [ ] Migrations-System +- [ ] Schema Manager +- [ ] Entity-Mapping (optional) + +## Security Features +- [ ] CSRF-Schutz +- [ ] XSS-Filtierung +- [ ] Input-Validierung +- [ ] Authentifizierung +- [ ] Autorisierung/Rechtemanagement + +## Module: Music +- [ ] Album-Verwaltung +- [ ] Track-Management +- [ ] Playlists +- [ ] Integrationsmöglichkeit mit Spotify/SoundCloud + +## Module: Content +- [ ] Blog-System +- [ ] Markdown-Support +- [ ] Medienbibliothek +- [ ] SEO-Optimierung +- [ ] Kommentarsystem + +## Admin Interface +- [ ] Dashboard +- [ ] Content-Editor +- [ ] Benutzer-/Rechteverwaltung +- [ ] Statistiken diff --git a/package.json b/package.json new file mode 100644 index 00000000..a73ec020 --- /dev/null +++ b/package.json @@ -0,0 +1,13 @@ +{ + "devDependencies": { + "@types/jest": "^29.2.5", + "jest": "^29.3.1", + "vite": "^6.3.5" + }, + "scripts": { + "test": "jest", + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + } +} diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 00000000..e6198e0e --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,18 @@ + + + + + ./tests + + + + + app + src + + + diff --git a/public/assets/css-CKd28aW2.js b/public/assets/css-CKd28aW2.js new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/public/assets/css-CKd28aW2.js @@ -0,0 +1 @@ + diff --git a/public/assets/css-CLfz37Tz.css b/public/assets/css-CLfz37Tz.css new file mode 100644 index 00000000..2e6f9e35 --- /dev/null +++ b/public/assets/css-CLfz37Tz.css @@ -0,0 +1 @@ +p{color:red}html{background:#00f} diff --git a/public/assets/css/styles.css b/public/assets/css/styles.css new file mode 100644 index 00000000..5d1fbafc --- /dev/null +++ b/public/assets/css/styles.css @@ -0,0 +1,12 @@ +* { + +} + + +footer > nav { + display: flex; +} + +footer > nav > li { + flex-direction: row; +} diff --git a/public/assets/js-DjO_n7Y6.js b/public/assets/js-DjO_n7Y6.js new file mode 100644 index 00000000..29481374 --- /dev/null +++ b/public/assets/js-DjO_n7Y6.js @@ -0,0 +1 @@ +import"./css-CKd28aW2.js"; diff --git a/public/favico.ico b/public/favico.ico new file mode 100644 index 00000000..e69de29b diff --git a/public/health.php b/public/health.php new file mode 100644 index 00000000..4cfb7daf --- /dev/null +++ b/public/health.php @@ -0,0 +1,7 @@ + 'ok']; + +echo json_encode($status); diff --git a/public/index.php b/public/index.php new file mode 100644 index 00000000..f30ff099 --- /dev/null +++ b/public/index.php @@ -0,0 +1,84 @@ +newLazyGhost(function (Discovery $object) { + // Initialize object in-place + $object->__construct(); +}); + + +/*$clientrequest = new \App\Framework\HttpClient\ClientRequest(HttpMethod::GET, 'https://jsonplaceholder.typicode.com/posts'); + +$client = new \App\Framework\HttpClient\CurlHttpClient(); + +var_dump($client->send($clientrequest));*/ + +$emitter = new \App\Framework\Http\ResponseEmitter(); +ErrorHandler::register($emitter); + +#echo dirname(__DIR__) . '/cache/routes.cache.php'; + +$discovery = new Discovery(new \App\Framework\Core\RouteMapper()); + +$results = $discovery->discover(__DIR__ . '/../src/Application/'); + + +$rc = new \App\Framework\Core\RouteCompiler(); + +$routes = $rc->compile($results[\App\Framework\Attributes\Route::class]); + +$cacheFile = dirname(__DIR__) . '/cache/routes.cache.php'; + +$routeCache = new \App\Framework\Core\RouteCache($cacheFile); + +$routeCache->save($routes); + +$request = new \App\Framework\Http\HttpRequest( + method: \App\Framework\Http\HttpMethod::tryFrom($_SERVER['REQUEST_METHOD'] ?? 'GET'), + path:parse_url( $_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH), +); + +#var_dump("
", $routeCache->load());
+
+$router = new \App\Framework\Router\HttpRouter(new RouteCollection($routeCache->load()));
+
+$match = $router->match($request->method->value, $request->path);
+
+
+$dispatcher = new \App\Framework\Router\RouteDispatcher();
+$return = $dispatcher->dispatch($match);
+
+$responder = new \App\Framework\Router\RouteResponder();
+$response = $responder->respond($return);
+
+$emitter = new \App\Framework\Http\ResponseEmitter();
+$emitter->emit($response);
+
+/*$redis = new Predis\Client([
+    'scheme' => 'tcp',
+    'host'   => 'redis', // Service-Name aus docker-compose
+    'port'   => 6379,
+]);
+
+$redis->set('hello', 'world');
+echo $redis->get('hello'); // Gibt: world aus*/
+
+exit;
diff --git a/resources/css/styles.css b/resources/css/styles.css
new file mode 100644
index 00000000..20b20fbd
--- /dev/null
+++ b/resources/css/styles.css
@@ -0,0 +1,7 @@
+p {
+    color: red;
+}
+
+html {
+    background: blue;
+}
diff --git a/resources/js/main.js b/resources/js/main.js
new file mode 100644
index 00000000..81faac4f
--- /dev/null
+++ b/resources/js/main.js
@@ -0,0 +1 @@
+import '../css/styles.css';
diff --git a/src/Application/Api/IrkEndpoint.php b/src/Application/Api/IrkEndpoint.php
new file mode 100644
index 00000000..4b0ee5b3
--- /dev/null
+++ b/src/Application/Api/IrkEndpoint.php
@@ -0,0 +1,21 @@
+ 'EPK!']);
+    }
+}
diff --git a/src/Application/Website/ShowHome.php b/src/Application/Website/ShowHome.php
new file mode 100644
index 00000000..26f8f512
--- /dev/null
+++ b/src/Application/Website/ShowHome.php
@@ -0,0 +1,33 @@
+ 'Michael','title' => 'HalloWeltTitel'],
+        );
+    }
+
+    #[Route(method: 'GET', path: '/epk')]
+    public function impressum(string $test = 'hallo'): ActionResult
+    {
+        return new ActionResult(
+            ResultType::Plain,
+            'test',
+            ['text' => 'EPK!'],
+        );
+    }
+}
diff --git a/src/Application/Website/ShowImpressum.php b/src/Application/Website/ShowImpressum.php
new file mode 100644
index 00000000..1fa8aef7
--- /dev/null
+++ b/src/Application/Website/ShowImpressum.php
@@ -0,0 +1,32 @@
+ 'Hallo Welt!'],
+        );
+    }
+
+    #[Route(method: 'GET', path: '/datenschutz')]
+    public function datenschutz(string $test = 'hallo'): ActionResult
+    {
+        return new ActionResult(
+            ResultType::Html,
+            'impressum',
+            ['title' => 'Datenschutz!'],
+        );
+    }
+}
diff --git a/src/Framework/Attributes/Route.php b/src/Framework/Attributes/Route.php
new file mode 100644
index 00000000..883ab03f
--- /dev/null
+++ b/src/Framework/Attributes/Route.php
@@ -0,0 +1,18 @@
+ */
+    private array $mapperMap;
+
+    private string $cacheFile;
+
+    /**
+     * @param AttributeMapper[] $mappers
+     */
+    public function __construct(AttributeMapper ...$mappers)
+    {
+        $this->mapperMap = [];
+        foreach ($mappers as $mapper) {
+            $this->mapperMap[$mapper->getAttributeClass()] = $mapper;
+        }
+
+        $this->cacheFile = __DIR__ .'/../../../cache/discovery.cache.php';
+    }
+
+    public function discover(string $directory): array
+    {
+        $hash = md5(realpath($directory));
+
+        $this->cacheFile = __DIR__ ."/../../../cache/discovery_{$hash}.cache.php";
+
+        $latestMTime = $this->getLatestMTime($directory);
+        $data = $this->loadCache($latestMTime);
+        if ($data !== null) {
+            return $data;
+        }
+
+        $results = [];
+        $rii = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($directory));
+
+        foreach ($rii as $file) {
+            if (! $file->isFile() || $file->getExtension() !== 'php') {
+                continue;
+            }
+
+            try {
+                $className = $this->getClassNameFromFile($file->getPathname());
+
+                if (! $className || ! class_exists($className)) {
+                    continue;
+                }
+
+                $refClass = new ReflectionClass($className);
+
+                $this->discoverClass($refClass, $results);
+
+            } catch (Exception $e) {
+                error_log("Discovery Warning: Fehler in Datei {$file->getPathname()}: " . $e->getMessage());
+            }
+        }
+
+        $results['__discovery_mtime'] = $latestMTime;
+        $this->storeCache($results);
+
+        return $results;
+    }
+
+    private function discoverClass(ReflectionClass $refClass, array &$results): void
+    {
+        $this->processAttributes($refClass, $results);
+
+        foreach ($refClass->getMethods(ReflectionMethod::IS_PUBLIC) as $method) {
+            $this->processAttributes($method, $results);
+        }
+    }
+
+    private function processAttributes(ReflectionClass|ReflectionMethod $ref, array &$results): void
+    {
+        foreach ($ref->getAttributes() as $attribute) {
+            $attrName = $attribute->getName();
+            if (! isset($this->mapperMap[$attrName])) {
+                continue;
+            }
+
+            $mapped = $this->mapperMap[$attrName]->map($ref, $attribute->newInstance());
+
+            if ($mapped !== null && $ref instanceof ReflectionMethod) {
+                $params = [];
+                foreach ($ref->getParameters() as $param) {
+                    $paramData = [
+                        'name' => $param->getName(),
+                        'type' => $param->getType()?->getName(),
+                        'isBuiltin' => $param->getType()?->isBuiltin() ?? false,
+                        'hasDefault' => $param->isDefaultValueAvailable(),
+                        'default' => $param->isDefaultValueAvailable() ? $param->getDefaultValue() : null,
+                        'attributes' => array_map(
+                            fn ($a) => $a->getName(),
+                            $param->getAttributes()
+                        ),
+                    ];
+                    $params[] = $paramData;
+                }
+                $mapped['parameters'] = $params;
+            }
+
+            if ($mapped !== null) {
+                $results[$attrName][] = $mapped;
+            }
+        }
+    }
+
+    public function getLatestMTime(string $directory): int
+    {
+        $maxMTime = 0;
+        $rii = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($directory));
+        foreach ($rii as $file) {
+            if ($file->isFile() && $file->getExtension() === 'php') {
+                $mtime = $file->getMTime();
+                if ($mtime > $maxMTime) {
+                    $maxMTime = $mtime;
+                }
+            }
+        }
+
+        return $maxMTime;
+    }
+
+    private function loadCache(int $latestMTime): ?array
+    {
+        if (! file_exists($this->cacheFile)) {
+            return null;
+        }
+        $data = include $this->cacheFile;
+        if (
+            ! is_array($data)
+            || ! isset($data['__discovery_mtime'])
+            || $data['__discovery_mtime'] !== $latestMTime
+        ) {
+            return null;
+        }
+        unset($data['__discovery_mtime']);
+
+        return $data;
+    }
+
+    private function storeCache(array $results): void
+    {
+        $export = var_export($results, true);
+        file_put_contents($this->cacheFile, " $v) {
+                $exported[] = var_export($key, true) . ' => ' . self::export($v);
+            }
+
+            return '[' . implode(",\n", $exported) . ']';
+        }
+        if ($value instanceof StaticRoute) {
+            return "new StaticRoute("
+                . var_export($value->controller, true) . ', '
+                . var_export($value->action, true) . ', '
+                . self::export($value->params, true)
+                . ")";
+        }
+        if ($value instanceof DynamicRoute) {
+            return "new DynamicRoute("
+                . var_export($value->regex, true) . ', '
+                . self::export($value->parameters, true) . ', '
+                . var_export($value->controller, true) . ', '
+                . var_export($value->method, true) . ', '
+                . var_export($value->parameters, true)
+                . ")";
+        }
+
+        // Fallback für skalare Werte
+        return var_export($value, true);
+    }
+}
diff --git a/src/Framework/Core/Route.php b/src/Framework/Core/Route.php
new file mode 100644
index 00000000..bde0f6b5
--- /dev/null
+++ b/src/Framework/Core/Route.php
@@ -0,0 +1,9 @@
+cacheFile, $phpExport);
+    }
+
+    public function load(): array
+    {
+        if (! file_exists($this->cacheFile)) {
+            throw new \RuntimeException("Route cache file not found: {$this->cacheFile}");
+        }
+        $data = include $this->cacheFile;
+
+        if (! is_array($data)) {
+            throw new \RuntimeException("Invalid route cache format.");
+        }
+
+        return $data;
+    }
+
+    public function isFresh(int $expectedMTime): bool
+    {
+        // Optional: prüfe eine Timestamp-Struktur etc.
+        if (! file_exists($this->cacheFile)) {
+            return false;
+        }
+
+        // Eigenes Format prüfen? Datei-mtime prüfen? Optional!
+        return true;
+    }
+}
diff --git a/src/Framework/Core/RouteCompiler.php b/src/Framework/Core/RouteCompiler.php
new file mode 100644
index 00000000..aec9dd04
--- /dev/null
+++ b/src/Framework/Core/RouteCompiler.php
@@ -0,0 +1,64 @@
+ $routes
+     * @return array, dynamic: array}>
+     */
+    public function compile(array $routes): array
+    {
+        $compiled = [];
+
+        foreach ($routes as $route) {
+            $method = strtoupper($route['http_method']);
+            $path = $route['path'];
+
+            $compiled[$method] ??= ['static' => [], 'dynamic' => []];
+
+            if (! str_contains($path, '{')) {
+                // Statische Route
+                $compiled[$method]['static'][$path] = new StaticRoute(
+                    $route['class'],
+                    $route['method'],
+                    $route['parameters']
+                );
+            } else {
+                // Dynamische Route
+                $regex = $this->convertPathToRegex($path, $paramNames);
+                $compiled[$method]['dynamic'][] = new DynamicRoute(
+                    $regex,
+                    $paramNames,
+                    $route['class'],
+                    $route['method'],
+                    $route['parameters']
+                );
+            }
+        }
+
+        return $compiled;
+    }
+
+    /**
+     * Konvertiert zB. /user/{id}/edit → ~^/user/([^/]+)/edit$~ und gibt ['id'] als Parameternamen zurück.
+     *
+     * @param string $path
+     * @param array &$paramNames
+     * @return string
+     */
+    private function convertPathToRegex(string $path, array &$paramNames): string
+    {
+        $paramNames = [];
+        $regex = preg_replace_callback('#\{(\w+)\}#', function ($matches) use (&$paramNames) {
+            $paramNames[] = $matches[1];
+
+            return '([^/]+)';
+        }, $path);
+
+        return '~^' . $regex . '$~';
+    }
+}
diff --git a/src/Framework/Core/RouteMapper.php b/src/Framework/Core/RouteMapper.php
new file mode 100644
index 00000000..daee48d9
--- /dev/null
+++ b/src/Framework/Core/RouteMapper.php
@@ -0,0 +1,30 @@
+ $reflectionTarget->getDeclaringClass()->getName(),
+            'method' => $reflectionTarget->getName(),
+            'http_method' => $attributeInstance->method,
+            'path' => $attributeInstance->path,
+        ];
+    }
+}
diff --git a/src/Framework/Core/StaticRoute.php b/src/Framework/Core/StaticRoute.php
new file mode 100644
index 00000000..7da378a6
--- /dev/null
+++ b/src/Framework/Core/StaticRoute.php
@@ -0,0 +1,15 @@
+singletons[$class])) {
+            return $this->singletons[$class];
+        }
+
+        $reflection = new \ReflectionClass($class);
+
+        $constructor = $reflection->getConstructor();
+        $dependencies = [];
+
+        if ($constructor !== null) {
+            foreach ($constructor->getParameters() as $param) {
+                $type = $param->getType();
+                if (!$type || $type->isBuiltin()) {
+                    throw new \RuntimeException("Cannot resolve parameter {$param->getName()}");
+                }
+                $dependencies[] = $this->get($type->getName());
+            }
+        }
+
+        $instance = $reflection->newInstanceArgs($dependencies);
+
+        if ($reflection->getAttributes(Singleton::class)) {
+            $this->singletons[$class] = $instance;
+        }
+
+        return $instance;
+    }
+}
diff --git a/src/Framework/ErrorHandling/ErrorHandler.php b/src/Framework/ErrorHandling/ErrorHandler.php
new file mode 100644
index 00000000..e3018204
--- /dev/null
+++ b/src/Framework/ErrorHandling/ErrorHandler.php
@@ -0,0 +1,63 @@
+getMessage(), $e->getFile(), $e->getLine(), $e->getTraceAsString())
+            : "Es ist ein interner Fehler aufgetreten.";
+
+        $headers = new Headers()->with('Content-Type', 'text/plain; charset=utf-8');
+
+        $response = new HttpResponse(
+            status: $status,
+            headers: $headers,
+            body: $message
+        );
+
+        // Sende die Fehlermeldung (so früh wie möglich!)
+        $emitter->emit($response);
+
+        exit(1);
+    }
+}
diff --git a/src/Framework/Http/Cookie.php b/src/Framework/Http/Cookie.php
new file mode 100644
index 00000000..cf412690
--- /dev/null
+++ b/src/Framework/Http/Cookie.php
@@ -0,0 +1,51 @@
+name) . '=' . urlencode($this->value);
+
+        if ($this->expires !== null) {
+            $cookie .= '; Expires=' . gmdate('D, d-M-Y H:i:s T', $this->expires);
+        }
+
+        if ($this->path) {
+            $cookie .= '; Path=' . $this->path;
+        }
+
+        if ($this->domain) {
+            $cookie .= '; Domain=' . $this->domain;
+        }
+
+        if ($this->secure) {
+            $cookie .= '; Secure';
+        }
+
+        if ($this->httpOnly) {
+            $cookie .= '; HttpOnly';
+        }
+
+        if ($this->sameSite) {
+            $cookie .= '; SameSite=' . $this->sameSite;
+        }
+
+        return $cookie;
+    }
+}
diff --git a/src/Framework/Http/Cookies.php b/src/Framework/Http/Cookies.php
new file mode 100644
index 00000000..13dd1c34
--- /dev/null
+++ b/src/Framework/Http/Cookies.php
@@ -0,0 +1,15 @@
+ */
+    private array $cookies = [];
+
+    public function __construct(array $rawCookies = [])
+    {
+    }
+}
diff --git a/src/Framework/Http/Headers.php b/src/Framework/Http/Headers.php
new file mode 100644
index 00000000..9f4810f8
--- /dev/null
+++ b/src/Framework/Http/Headers.php
@@ -0,0 +1,90 @@
+ ['Content-Type', ['text/html']],
+     *   'set-cookie'   => ['Set-Cookie', ['a=1', 'b=2']],
+     * ]
+     */
+
+    /**
+     * @var array
+     * Struktur: 'normalized-lower-name' => [Original-Name, [Wert1, Wert2, ...]]
+     */
+    public function __construct(
+        private readonly array $headers = []
+    ) {}
+
+    public function with(string $name, string|array $value): self
+    {
+        $key = strtolower($name);
+        $original = $this->normalizeName($name);
+        $values = is_array($value) ? array_values($value) : [$value];
+
+        $new = $this->headers;
+        $new[$key] = [$original, $values];
+
+        return new self($new);
+    }
+
+    public function withAdded(string $name, string $value): self
+    {
+        $key = strtolower($name);
+        $original = $this->normalizeName($name);
+
+        $new = $this->headers;
+        if (!isset($new[$key])) {
+            $new[$key] = [$original, [$value]];
+        } else {
+            $new[$key][1][] = $value;
+        }
+
+        return new self($new);
+    }
+
+    public function without(string $name): self
+    {
+        $key = strtolower($name);
+        $new = $this->headers;
+        unset($new[$key]);
+
+        return new self($new);
+    }
+
+    public function get(string $name): ?array
+    {
+        return $this->headers[strtolower($name)][1] ?? null;
+    }
+
+    public function getFirst(string $name): ?string
+    {
+        return $this->get($name)[0] ?? null;
+    }
+
+    public function has(string $name): bool
+    {
+        return isset($this->headers[strtolower($name)]);
+    }
+
+    /**
+     * Gibt die Header im Format ['Original-Name' => [Wert1, Wert2]]
+     */
+    public function all(): array
+    {
+        $output = [];
+        foreach ($this->headers as [$original, $values]) {
+            $output[$original] = $values;
+        }
+        return $output;
+    }
+
+    private function normalizeName(string $name): string
+    {
+        return preg_replace_callback('/(?:^|-)[a-z]/', fn($m) => strtoupper($m[0]), strtolower($name));
+    }
+}
diff --git a/src/Framework/Http/HttpMethod.php b/src/Framework/Http/HttpMethod.php
new file mode 100644
index 00000000..a04883c7
--- /dev/null
+++ b/src/Framework/Http/HttpMethod.php
@@ -0,0 +1,19 @@
+ $this->body;
+            set => $value;
+        }*/
+    ) {
+    }
+}
diff --git a/src/Framework/Http/Request.php b/src/Framework/Http/Request.php
new file mode 100644
index 00000000..a4a6086c
--- /dev/null
+++ b/src/Framework/Http/Request.php
@@ -0,0 +1,22 @@
+status->value);
+
+        // Header senden
+        /*foreach ($response->headers->all() as $name => $values) {
+            #foreach ((array)$values as $value) {
+                header("$name: $values", false);
+            #}
+        }*/
+
+        // Header senden
+        foreach ($response->headers->all() as $name => $value) {
+            // Sicherheitsprüfung
+            if (!preg_match('/^[A-Za-z0-9\-]+$/', $name)) {
+                throw new \InvalidArgumentException("Invalid header name: '$name'");
+            }
+
+            // Bei Mehrfach-Headern: ggf. als Array zulassen
+            if (is_array($value)) {
+                foreach ($value as $single) {
+                    header("$name: $single", false); // false = nicht ersetzen
+                }
+            } else {
+                header("$name: $value", true);
+            }
+        }
+
+        // Body ausgeben
+        echo $response->body;
+    }
+}
diff --git a/src/Framework/Http/ResponseManipulator.php b/src/Framework/Http/ResponseManipulator.php
new file mode 100644
index 00000000..4a43d635
--- /dev/null
+++ b/src/Framework/Http/ResponseManipulator.php
@@ -0,0 +1,35 @@
+status,
+            headers: $response->headers,
+            body: $body
+        );
+    }
+
+    public function withHeader(Response $response, string $name, string $value): Response
+    {
+        $headers = clone $response->headers;
+        $headers->with($name, $value);
+        return new HttpResponse(
+            status: $response->status,
+            headers: $headers,
+            body: $response->body
+        );
+    }
+
+    public function withStatus(Response $response, Status $status): Response
+    {
+        return new HttpResponse(
+            status: $status,
+            headers: $response->headers,
+            body: $response->body
+        );
+    }
+}
diff --git a/src/Framework/Http/Responses/Redirect.php b/src/Framework/Http/Responses/Redirect.php
new file mode 100644
index 00000000..7fdc72be
--- /dev/null
+++ b/src/Framework/Http/Responses/Redirect.php
@@ -0,0 +1,18 @@
+with('Location', $this->body);
+        }
+    }
+}
diff --git a/src/Framework/Http/Status.php b/src/Framework/Http/Status.php
new file mode 100644
index 00000000..1f16a301
--- /dev/null
+++ b/src/Framework/Http/Status.php
@@ -0,0 +1,13 @@
+ $request->url,
+            CURLOPT_CUSTOMREQUEST => $request->method->value,
+            CURLOPT_RETURNTRANSFER => true,
+            CURLOPT_HEADER => true,
+        ];
+        if ($request->body !== '') {
+            $options[CURLOPT_POSTFIELDS] = $request->body;
+        }
+        if (count($request->headers->all()) > 0) {
+            $options[CURLOPT_HTTPHEADER] = $request->headers->all();
+        }
+
+        curl_setopt_array($ch, $options);
+
+        $raw = curl_exec($ch);
+
+        if ($raw === false) {
+            throw new HttpException(curl_error($ch), curl_errno($ch));
+        }
+
+        $headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
+        $headersRaw = substr($raw, 0, $headerSize);
+        $body = substr($raw, $headerSize);
+        $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+
+        curl_close($ch);
+
+        #$headers = Headers::fromString($headersRaw);
+        $headers = new Headers();
+
+        return new ClientResponse(Status::from($status), $headers, $body);
+
+    }
+}
diff --git a/src/Framework/HttpClient/HttpClient.php b/src/Framework/HttpClient/HttpClient.php
new file mode 100644
index 00000000..a0848847
--- /dev/null
+++ b/src/Framework/HttpClient/HttpClient.php
@@ -0,0 +1,8 @@
+routes->has($method->value)) {
+            return new RouteContext(
+                match: new NoRouteMatch(),
+                method: $method->value,
+                path: $path
+            );
+        }
+
+        $match =  $this->matchStatic($method->value, $path)
+            ?? $this->matchDynamic($method->value, $path)
+            ?? new NoRouteMatch();
+
+        return new RouteContext(
+            match: $match,
+            method: $method->value,
+            path: $path
+        );
+    }
+
+    private function matchStatic(string $method, string $path): ?RouteMatch
+    {
+        if (isset($this->routes->getStatic($method)[$path])) {
+            $handler = $this->routes->getStatic($method)[$path];
+            return new RouteMatchSuccess($handler);
+        }
+        return null;
+    }
+
+    private function matchDynamic(string $method, string $path): ?RouteMatch
+    {
+        foreach ($this->routes->getDynamic($method) as $route) {
+            if (preg_match($route['regex'], $path, $matches)) {
+                array_shift($matches); // remove full match
+                $params = array_combine($route['params'], $matches);
+
+                $dynamicRoute = new DynamicRoute(
+                    $route["regex"],
+                    $route["params"],
+                    $route['method']['class'],
+                    $route['method']['method'],
+                    $params
+                );
+
+                return new RouteMatchSuccess($route);
+            }
+        }
+
+        return null;
+    }
+}
diff --git a/src/Framework/Router/NoRouteMatch.php b/src/Framework/Router/NoRouteMatch.php
new file mode 100644
index 00000000..3fdce413
--- /dev/null
+++ b/src/Framework/Router/NoRouteMatch.php
@@ -0,0 +1,13 @@
+, dynamic: array}> */
+    private array $routes;
+
+    public function __construct(array $routes) {
+        $this->routes = $routes;
+    }
+
+    public function getStatic(string $method): array {
+        return $this->routes[$method]['static'] ?? [];
+    }
+
+    public function getDynamic(string $method): array {
+        return $this->routes[$method]['dynamic'] ?? [];
+    }
+
+    public function has(string $method): bool {
+        return isset($this->routes[$method]);
+    }
+}
diff --git a/src/Framework/Router/RouteContext.php b/src/Framework/Router/RouteContext.php
new file mode 100644
index 00000000..9022e9d5
--- /dev/null
+++ b/src/Framework/Router/RouteContext.php
@@ -0,0 +1,27 @@
+ $this->isSuccess() ? $this->match->route->method : null;
+    }
+
+    public array $params {
+        get => $this->isSuccess() ? $this->match->route->parameters : [];
+    }
+
+    public function __construct(
+        public readonly RouteMatch $match,
+        public readonly string $method,
+        public readonly string $path
+    ) {}
+
+    public function isSuccess(): bool
+    {
+        return $this->match instanceof RouteMatchSuccess;
+    }
+}
diff --git a/src/Framework/Router/RouteDispatcher.php b/src/Framework/Router/RouteDispatcher.php
new file mode 100644
index 00000000..2c02fbd4
--- /dev/null
+++ b/src/Framework/Router/RouteDispatcher.php
@@ -0,0 +1,51 @@
+match;
+
+        if ($routeMatch->isMatch()) {
+            $controller = $routeMatch->route->controller;
+            $action = $routeMatch->route->action;
+            $params = $routeMatch->route->params;
+
+            $params = $this->prepareParameters(...$params);
+
+            $obj = new $controller();
+            $result = $obj->$action(...$params);
+
+            // Hier könntest du z. B. Response-Objekte erwarten oder generieren:
+            if ($result instanceof Response || $result instanceof ActionResult) {
+                return $result;
+            }
+        }
+
+        // Fehlerbehandlung z.B. 404
+        return new HttpResponse(status: Status::NOT_FOUND, body: 'Nicht gefunden');
+    }
+
+    public function prepareParameters(...$params): mixed
+    {
+        $parameters = [];
+        foreach ($params as $param) {
+            if ($param['isBuiltin'] === true) {
+                $parameters[] = $param['default'];
+            } else {
+                #Container!
+                var_dump($param['isBuiltin']);
+            }
+        }
+
+        return $parameters;
+    }
+}
diff --git a/src/Framework/Router/RouteMatch.php b/src/Framework/Router/RouteMatch.php
new file mode 100644
index 00000000..03bbefc3
--- /dev/null
+++ b/src/Framework/Router/RouteMatch.php
@@ -0,0 +1,10 @@
+resultType) {
+            case ResultType::Html:
+                $body = $this->renderTemplate(
+                    $result->template,
+                    $result->data,
+                        $result->layout ?? null,
+                        $result->slots ?? [],
+                    $result->controllerClass
+                );
+
+                $contentType = "text/html";
+
+                break;
+            case ResultType::Json:
+                $body = json_encode(
+                    $result->data,
+                    JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE
+                );
+                $contentType = "application/json";
+
+                break;
+            case ResultType::Plain:
+                $body = $result->data['text'] ?? '';
+                $contentType = "text/plain";
+                break;
+            default:
+                throw new \RuntimeException("Unknown result type: {$result->resultType}");
+        }
+
+        return new HttpResponse(
+            status: $result->status,
+            headers: new Headers()->with('Content-Type', $contentType),  //['Content-Type' => $contentType],
+            body: $body
+        );
+    }
+
+    private function renderTemplate(string $template, array $data, ?string $layout, array $slots = [], ?string $controllerName = null): string
+    {
+        $context = new RenderContext(
+            template: $template,
+            data: $data,
+            layout: $layout,
+            slots: $slots,
+            controllerClass: $controllerName
+        );
+
+        return $this->templateRenderer->render($context);
+    }
+}
diff --git a/src/Framework/View/Compiler.php b/src/Framework/View/Compiler.php
new file mode 100644
index 00000000..798c4dfc
--- /dev/null
+++ b/src/Framework/View/Compiler.php
@@ -0,0 +1,18 @@
+loadHTML($html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
+        libxml_clear_errors();
+
+        return $dom;
+    }
+}
diff --git a/src/Framework/View/ComponentRenderer.php b/src/Framework/View/ComponentRenderer.php
new file mode 100644
index 00000000..b05af4ca
--- /dev/null
+++ b/src/Framework/View/ComponentRenderer.php
@@ -0,0 +1,47 @@
+loader->getComponentPath($componentName);
+        if (!file_exists($path)) {
+            return "";
+        }
+
+        # Cache prüfen
+        $hash = md5_file($path) . '_' . md5(serialize($data));
+        $cacheFile = $this->cacheDir . "/{$componentName}_{$hash}.html";;
+
+        if(file_exists($cacheFile)) {
+            return file_get_contents($cacheFile);
+        }
+
+
+        $template = file_get_contents($path);
+        $compiled = $this->compiler->compile($template)->saveHTML();
+
+        $context = new RenderContext(
+            template: $componentName,
+            data: $data
+        );
+
+        $output = $this->processor->render($context, $compiled);
+
+        if(!is_dir($this->cacheDir)) {
+            mkdir($this->cacheDir, 0777, true);
+        }
+
+        file_put_contents($cacheFile, $output);
+        return $output;
+    }
+}
diff --git a/src/Framework/View/DOM/DomParser.php b/src/Framework/View/DOM/DomParser.php
new file mode 100644
index 00000000..1f02f1d7
--- /dev/null
+++ b/src/Framework/View/DOM/DomParser.php
@@ -0,0 +1,48 @@
+nodeType) {
+            case XML_ELEMENT_NODE:
+                $attributes = [];
+                if ($node instanceof \DOMElement && $node->hasAttributes()) {
+                    foreach ($node->attributes as $attr) {
+                        $attributes[$attr->nodeName] = $attr->nodeValue;
+                    }
+                }
+
+                $el = new HTMLElement(
+                    tagName: $node->nodeName,
+                    attributes: $attributes,
+                    children: [],
+                    textContent: null,
+                    nodeType: 'element',
+                    namespace: $node->namespaceURI,
+                    parent: $parent
+                );
+
+                foreach ($node->childNodes as $child) {
+                    $childEl = $this->domNodeToHTMLElement($child, $el);
+                    if ($childEl) {
+                        $el->addChild($childEl);
+                    }
+                }
+
+                return $el;
+
+            case XML_TEXT_NODE:
+                return new HTMLElement(textContent: $node->nodeValue, nodeType: 'text');
+
+            case XML_COMMENT_NODE:
+                return new HTMLElement(textContent: $node->nodeValue, nodeType: 'comment');
+
+            default:
+                return null;
+        }
+    }
+
+}
diff --git a/src/Framework/View/DOM/HTMLElement.php b/src/Framework/View/DOM/HTMLElement.php
new file mode 100644
index 00000000..2ef92b6d
--- /dev/null
+++ b/src/Framework/View/DOM/HTMLElement.php
@@ -0,0 +1,65 @@
+attributes[$name] ?? null;
+        }
+
+        $this->attributes[$name] = $value;
+        return $this;
+    }
+
+    public function text(?string $value = null): self|string|null
+    {
+        if ($value === null) {
+            return $this->textContent;
+        }
+
+        $this->textContent = $value;
+        return $this;
+    }
+
+    public function addChild(HTMLElement $child): self
+    {
+        $child->parent = $this;
+        $this->children[] = $child;
+        return $this;
+    }
+
+    public function __toString(): string
+    {
+        if ($this->nodeType === 'text') {
+            return htmlspecialchars($this->textContent ?? '', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
+        }
+
+        if ($this->nodeType === 'comment') {
+            return "";
+        }
+
+        $attrs = implode(' ', array_map(
+            fn($k, $v) => htmlspecialchars($k) . '="' . htmlspecialchars($v) . '"',
+            array_keys($this->attributes),
+            $this->attributes
+        ));
+
+        $content = implode('', array_map(fn($c) => (string)$c, $this->children));
+        $tag = htmlspecialchars($this->tagName);
+
+        return "<{$tag}" . ($attrs ? " $attrs" : "") . ">$content";
+    }
+}
diff --git a/src/Framework/View/DOM/HtmlDocument.php b/src/Framework/View/DOM/HtmlDocument.php
new file mode 100644
index 00000000..f63f1da8
--- /dev/null
+++ b/src/Framework/View/DOM/HtmlDocument.php
@@ -0,0 +1,57 @@
+dom = new \DOMDocument('1.0', 'UTF-8');
+        if ($html !== '') {
+            libxml_use_internal_errors(true);
+            $success = $this->dom->loadHTML($html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
+            if (!$success) {
+                throw new \RuntimeException("HTML Parsing failed.");
+            }
+            libxml_clear_errors();
+        }
+    }
+
+    public function querySelector(string $tagName): ?HtmlElement
+    {
+        $xpath = new \DOMXPath($this->dom);
+        $node = $xpath->query("//{$tagName}")->item(0);
+
+        return $node ? new DomParser()->domNodeToHTMLElement($node) : null;
+    }
+
+    public function querySelectorAll(string $tagName): NodeList
+    {
+        $xpath = new \DOMXPath($this->dom);
+        $nodes = $xpath->query("//{$tagName}");
+        $parser = new DomParser();
+
+        $elements = [];
+        foreach ($nodes as $node) {
+            $el = $parser->domNodeToHTMLElement($node);
+            if ($el) {
+                $elements[] = $el;
+            }
+        }
+
+        return new NodeList(...$elements);
+    }
+
+    public function toHtml(): string
+    {
+        return $this->dom->saveHTML() ?: '';
+    }
+
+    public function __toString(): string
+    {
+        return $this->toHtml();
+    }
+
+}
diff --git a/src/Framework/View/DOM/HtmlDocumentFormatter.php b/src/Framework/View/DOM/HtmlDocumentFormatter.php
new file mode 100644
index 00000000..ed535bd3
--- /dev/null
+++ b/src/Framework/View/DOM/HtmlDocumentFormatter.php
@@ -0,0 +1,55 @@
+formatter = $formatter ?? new HtmlFormatter();
+        $this->parser = new DomParser();
+    }
+
+    /**
+     * Wandelt ein HTML-String in ein strukturiertes HTMLElement-Baumobjekt und formatiert es
+     */
+    public function formatHtmlString(string $html): string
+    {
+        $document = new \DOMDocument('1.0', 'UTF-8');
+        libxml_use_internal_errors(true);
+        $success = $document->loadHTML($html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
+        if (!$success) {
+            throw new \RuntimeException("HTML Parsing failed.");
+        }
+        libxml_clear_errors();
+
+        $root = $document->documentElement;
+        if (!$root) {
+            return '';
+        }
+
+        $element = $this->parser->domNodeToHTMLElement($root);
+        return $this->formatter->format($element);
+    }
+
+    /**
+     * Formatiert ein HtmlDocument direkt
+     */
+    public function formatDocument(HtmlDocument $doc): string
+    {
+        $element = $doc->querySelector('html') ?? $doc->querySelector('body');
+        return $this->formatter->format($element);
+    }
+
+    /**
+     * Formatiert einen Teilbaum ab einem gegebenen HTMLElement
+     */
+    public function formatElement(HTMLElement $element): string
+    {
+        return $this->formatter->format($element);
+    }
+}
+}
diff --git a/src/Framework/View/DOM/HtmlFormatter.php b/src/Framework/View/DOM/HtmlFormatter.php
new file mode 100644
index 00000000..727d0942
--- /dev/null
+++ b/src/Framework/View/DOM/HtmlFormatter.php
@@ -0,0 +1,53 @@
+indentSize = $indentSize;
+        $this->indentChar = $indentChar;
+    }
+
+    public function format(HTMLElement $element, int $level = 0): string
+    {
+        $indent = str_repeat($this->indentChar, $level * $this->indentSize);
+
+        if ($element->nodeType === 'text') {
+            return $indent . htmlspecialchars($element->textContent ?? '', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . "\n";
+        }
+
+        if ($element->nodeType === 'comment') {
+            return $indent . "\n";
+        }
+
+        $tag = htmlspecialchars($element->tagName);
+
+        $attrs = '';
+        foreach ($element->attributes as $key => $value) {
+            $attrs .= ' ' . htmlspecialchars($key) . '="' . htmlspecialchars($value) . '"';
+        }
+
+        if (empty($element->children) && empty($element->textContent)) {
+            return $indent . "<{$tag}{$attrs} />\n";
+        }
+
+        $output = $indent . "<{$tag}{$attrs}>\n";
+
+        foreach ($element->children as $child) {
+            $output .= $this->format($child, $level + 1);
+        }
+
+        if ($element->textContent !== null && $element->textContent !== '') {
+            $output .= str_repeat($this->indentChar, ($level + 1) * $this->indentSize);
+            $output .= htmlspecialchars($element->textContent, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . "\n";
+        }
+
+        $output .= $indent . "\n";
+        return $output;
+    }
+}
diff --git a/src/Framework/View/DOM/NodeList.php b/src/Framework/View/DOM/NodeList.php
new file mode 100644
index 00000000..a9cede03
--- /dev/null
+++ b/src/Framework/View/DOM/NodeList.php
@@ -0,0 +1,73 @@
+nodes = $nodes;
+    }
+
+    public function getIterator(): \ArrayIterator
+    {
+        return new \ArrayIterator($this->nodes);
+    }
+
+    public function count(): int
+    {
+        return count($this->nodes);
+    }
+
+    public function offsetExists($offset): bool
+    {
+        return isset($this->nodes[$offset]);
+    }
+
+    public function offsetGet($offset): mixed
+    {
+        return $this->nodes[$offset] ?? null;
+    }
+
+    public function offsetSet($offset, $value): void
+    {
+        if (!$value instanceof HTMLElement) {
+            throw new \InvalidArgumentException("Only HTMLElement instances allowed.");
+        }
+
+        if ($offset === null) {
+            $this->nodes[] = $value;
+        } else {
+            $this->nodes[$offset] = $value;
+        }
+    }
+
+    public function offsetUnset($offset): void
+    {
+        unset($this->nodes[$offset]);
+    }
+
+    // Beispiel für Hilfsmethoden:
+    public function first(): ?HTMLElement
+    {
+        return $this->nodes[0] ?? null;
+    }
+
+    public function filter(callable $fn): self
+    {
+        return new self(...array_filter($this->nodes, $fn));
+    }
+
+    public function map(callable $fn): array
+    {
+        return array_map($fn, $this->nodes);
+    }
+
+    public function toArray(): array
+    {
+        return $this->nodes;
+    }
+}
diff --git a/src/Framework/View/DomProcessor.php b/src/Framework/View/DomProcessor.php
new file mode 100644
index 00000000..9c1fb0c8
--- /dev/null
+++ b/src/Framework/View/DomProcessor.php
@@ -0,0 +1,16 @@
+preserveWhiteSpace = false;
+        $dom->formatOutput = true;
+
+        $dom->loadHTML(
+            $html,
+            LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD
+        );
+
+        libxml_clear_errors();
+
+        return $dom;
+    }
+
+    public function toHtml(DOMDocument $dom): string
+    {
+        return $dom->saveHTML();
+    }
+}
diff --git a/src/Framework/View/Engine.php b/src/Framework/View/Engine.php
new file mode 100644
index 00000000..aa19710d
--- /dev/null
+++ b/src/Framework/View/Engine.php
@@ -0,0 +1,53 @@
+processor->registerDom(new ComponentProcessor());
+        $this->processor->registerDom(new LayoutTagProcessor());
+        $this->processor->registerDom(new PlaceholderReplacer());
+    }
+
+    public function render(RenderContext $context): string
+    {
+
+        $template = $context->template;
+        $data   = $context->data;
+
+        $cacheFile = __DIR__ . "/cache/{$template}.cache.html";
+
+        $templateFile = $this->loader->getTemplatePath($template); // Neue Methode in TemplateLoader
+
+        // Prüfen ob Cache existiert und nicht älter als das Template
+        if (file_exists($cacheFile) && filemtime($cacheFile) >= filemtime($templateFile)) {
+            $content = file_get_contents($cacheFile);
+            #$dom = $this->compiler->compile($content);
+        } else {
+            // Template normal laden und kompilieren
+            $content = $this->loader->load($template, $context->controllerClass);;
+            $content = "{$content}";
+            $dom = $this->compiler->compile($content);
+            $html = $dom->saveHTML();
+            // (Optional) VOR dynamischer Verarbeitung rohe Struktur cachen
+            file_put_contents($cacheFile, $dom->saveHTML());
+            $content = $html;
+        }
+
+
+        return $this->processor->render($context, $content);
+        #return $this->renderer->render($dom, $data, $this);
+    }
+}
diff --git a/src/Framework/View/Processors/CommentStripProcessor.php b/src/Framework/View/Processors/CommentStripProcessor.php
new file mode 100644
index 00000000..b1d4d8ac
--- /dev/null
+++ b/src/Framework/View/Processors/CommentStripProcessor.php
@@ -0,0 +1,18 @@
+query('//comment()') as $commentNode) {
+            $commentNode->parentNode?->removeChild($commentNode);
+        }
+    }
+}
diff --git a/src/Framework/View/Processors/ComponentProcessor.php b/src/Framework/View/Processors/ComponentProcessor.php
new file mode 100644
index 00000000..2a8980b2
--- /dev/null
+++ b/src/Framework/View/Processors/ComponentProcessor.php
@@ -0,0 +1,37 @@
+query('//component') as $node) {
+            $name = $node->getAttribute('name');
+            $attributes = [];
+
+            foreach ($node->attributes as $attr) {
+                if($attr->nodeName !== 'name') {
+                    $attributes[$attr->nodeName] = $attr->nodeValue;
+                }
+            }
+
+            $componentHtml = $this->renderer->render($name, array_merge($context->data, $attributes));
+
+            $fragment = $dom->createDocumentFragment();
+            $fragment->appendXML($componentHtml);
+
+            $node->parentNode->replaceChild($fragment, $node);
+        }
+    }
+}
diff --git a/src/Framework/View/Processors/DateFormatProcessor.php b/src/Framework/View/Processors/DateFormatProcessor.php
new file mode 100644
index 00000000..d2f02fb2
--- /dev/null
+++ b/src/Framework/View/Processors/DateFormatProcessor.php
@@ -0,0 +1,29 @@
+`.
+
+final readonly class DateFormatProcessor implements StringProcessor
+{
+
+    /**
+     * @inheritDoc
+     */
+    public function process(string $html, RenderContext $context): string
+    {
+        return preg_replace_callback('/{{\s*date\((\w+),\s*["\']([^"\']+)["\']\)\s*}}/', function ($matches) use ($context) {
+            $key = $matches[1];
+            $format = $matches[2];
+
+            if (!isset($context->data[$key]) || !($context->data[$key] instanceof \DateTimeInterface)) {
+                return $matches[0]; // Unverändert lassen
+            }
+
+            return $context->data[$key]->format($format);
+        }, $html);
+    }
+}
diff --git a/src/Framework/View/Processors/EscapeProcessor.php b/src/Framework/View/Processors/EscapeProcessor.php
new file mode 100644
index 00000000..4178a57a
--- /dev/null
+++ b/src/Framework/View/Processors/EscapeProcessor.php
@@ -0,0 +1,24 @@
+data[$matches[1]] ?? '';
+        }, $html);
+
+        // Dann alle übrigen Variablen escapen
+        $html = preg_replace_callback('/{{\s*(\w+)\s*}}/', function ($matches) use ($context) {
+            $value = $context->data[$matches[1]] ?? '';
+            return htmlspecialchars((string)$value, ENT_QUOTES | ENT_HTML5);
+        }, $html);
+
+        return $html;
+    }
+}
diff --git a/src/Framework/View/Processors/ForProcessor.php b/src/Framework/View/Processors/ForProcessor.php
new file mode 100644
index 00000000..499e7fe8
--- /dev/null
+++ b/src/Framework/View/Processors/ForProcessor.php
@@ -0,0 +1,59 @@
+query('//for[@var][@in]') as $node) {
+            $var = $node->getAttribute('var');
+            $in = $node->getAttribute('in');
+
+            $output = '';
+
+            if (isset($context->data[$in]) && is_iterable($context->data[$in])) {
+                foreach ($context->data[$in] as $item) {
+                    $clone = $node->cloneNode(true);
+                    foreach ($clone->childNodes as $child) {
+                        $this->replacePlaceholdersRecursive($child, [$var => $item] + $context->data);
+                    }
+
+                    $fragment = $dom->createDocumentFragment();
+                    foreach ($clone->childNodes as $child) {
+                        $fragment->appendChild($child->cloneNode(true));
+                    }
+
+                    $output .= $dom->saveHTML($fragment);
+                }
+            }
+
+            $replacement = $dom->createDocumentFragment();
+            @$replacement->appendXML($output);
+            $node->parentNode?->replaceChild($replacement, $node);
+        }
+    }
+
+    private function replacePlaceholdersRecursive(\DOMNode $node, array $data): void
+    {
+        if ($node->nodeType === XML_TEXT_NODE) {
+            $node->nodeValue = preg_replace_callback('/{{\s*(\w+)\s*}}/', function ($matches) use ($data) {
+                return htmlspecialchars((string)($data[$matches[1]] ?? $matches[0]), ENT_QUOTES | ENT_HTML5);
+            }, $node->nodeValue);
+        }
+
+        if ($node->hasChildNodes()) {
+            foreach ($node->childNodes as $child) {
+                $this->replacePlaceholdersRecursive($child, $data);
+            }
+        }
+    }
+}
diff --git a/src/Framework/View/Processors/IfProcessor.php b/src/Framework/View/Processors/IfProcessor.php
new file mode 100644
index 00000000..c8d1058d
--- /dev/null
+++ b/src/Framework/View/Processors/IfProcessor.php
@@ -0,0 +1,39 @@
+query('//*[@if]') as $node) {
+            $condition = $node->getAttribute('if');
+
+            $value = $context->data[$condition] ?? null;
+
+            // Entferne, wenn die Bedingung nicht erfüllt ist
+            if (!$this->isTruthy($value)) {
+                $node->parentNode?->removeChild($node);
+                continue;
+            }
+
+            // Entferne Attribut bei Erfolg
+            $node->removeAttribute('if');
+        }
+    }
+
+    private function isTruthy(mixed $value): bool
+    {
+        if (is_bool($value)) return $value;
+        if (is_null($value)) return false;
+        if (is_string($value)) return trim($value) !== '';
+        if (is_numeric($value)) return $value != 0;
+        if (is_array($value)) return count($value) > 0;
+
+        return true;
+    }
+}
diff --git a/src/Framework/View/Processors/IncludeProcessor.php b/src/Framework/View/Processors/IncludeProcessor.php
new file mode 100644
index 00000000..6b25211d
--- /dev/null
+++ b/src/Framework/View/Processors/IncludeProcessor.php
@@ -0,0 +1,41 @@
+query('//include[@file]') as $includeNode) {
+            $file = $includeNode->getAttribute('file');
+
+            try {
+                $html = $this->loader->load($file);
+                $includedDom = $this->parser->parse($html);
+
+                $fragment = $dom->createDocumentFragment();
+                foreach ($includedDom->documentElement->childNodes as $child) {
+                    $fragment->appendChild($dom->importNode($child, true));
+                }
+
+                $includeNode->parentNode?->replaceChild($fragment, $includeNode);
+            } catch (\Throwable $e) {
+                // Optional: Fehlerkommentar ins Template schreiben
+                $error = $dom->createComment("Fehler beim Laden von '$file': " . $e->getMessage());
+                $includeNode->parentNode?->replaceChild($error, $includeNode);
+            }
+        }
+    }
+}
diff --git a/src/Framework/View/Processors/LayoutTagProcessor.php b/src/Framework/View/Processors/LayoutTagProcessor.php
new file mode 100644
index 00000000..fe5c64f6
--- /dev/null
+++ b/src/Framework/View/Processors/LayoutTagProcessor.php
@@ -0,0 +1,102 @@
+query('//layout[@src]');
+
+        if ($layoutTags->length === 0) {
+            return;
+        }
+
+        $layoutTag = $layoutTags->item(0);
+        $layoutFile = $layoutTag->getAttribute('src');
+        $layoutHtml = $this->loader->load('/layouts/'.$layoutFile);
+        $layoutDom = $this->parser->parse($layoutHtml);
+
+
+        // Body-Slot finden
+        $layoutXPath = new \DOMXPath($layoutDom);
+        /*$slotNodes = $layoutXPath->query('//slot[@name="body"]');
+
+        if ($slotNodes->length === 0) {
+            return;
+        }
+
+        $slot = $slotNodes->item(0);*/
+
+        $slot = $layoutXPath->query('//main')->item(0);
+
+        if (! $slot) {
+            return; // Kein 
vorhanden → Layout kann nicht angewendet werden + } + + // Die Kindknoten des ursprünglichen -Elternelements einsammeln (ohne -Tag selbst) + $parent = $layoutTag->parentNode; + + // Alle Knoten nach einsammeln (direkt nach ) + $contentNodes = []; + for ($node = $layoutTag->nextSibling; $node !== null; $node = $node->nextSibling) { + $contentNodes[] = $node; + } + + // Vor dem Entfernen: Layout-Tag aus DOM löschen + $parent->removeChild($layoutTag); + + // Inhalt einfügen + $fragment = $layoutDom->createDocumentFragment(); + foreach ($contentNodes as $contentNode) { + // Hole alle noch im Original existierenden Nodes... + $fragment->appendChild($layoutDom->importNode($contentNode, true)); + } + + //
ersetzen + $slot->parentNode->replaceChild($fragment, $slot); + + // Ersetze gesamtes DOM + $newDom = $this->parser->parse($layoutDom->saveHTML()); + $dom->replaceChild( + $dom->importNode($newDom->documentElement, true), + $dom->documentElement + ); + + return; + + + // Layout-Tag aus Original entfernen + $layoutTag->parentNode?->removeChild($layoutTag); + + // Inhalt des Haupttemplates extrahieren (ohne das Layout-Tag selbst) + $fragment = $dom->createDocumentFragment(); + + + foreach ($dom->documentElement->childNodes as $child) { + $fragment->appendChild($layoutDom->importNode($child, true)); + } + + // Ersetze Slot im Layout durch den gerenderten Body + $slot->parentNode?->replaceChild($fragment, $slot); + + // Ersetze gesamtes DOM durch Layout-DOM + $newDom = $this->parser->parse($layoutDom->saveHTML()); + + $dom->replaceChild( + $dom->importNode($newDom->documentElement, true), + $dom->documentElement + ); + } +} diff --git a/src/Framework/View/Processors/MetaManipulator.php b/src/Framework/View/Processors/MetaManipulator.php new file mode 100644 index 00000000..3a8f46a2 --- /dev/null +++ b/src/Framework/View/Processors/MetaManipulator.php @@ -0,0 +1,24 @@ +query('//meta[@name][@content]') as $meta) { + $name = $meta->getAttribute('name'); + $content = $meta->getAttribute('content'); + + // Wenn Variable bereits im Context gesetzt ist, nicht überschreiben + if (!array_key_exists($name, $context->data)) { + $context->data[$name] = $content; + } + } + } +} diff --git a/src/Framework/View/Processors/PlaceholderReplacer.php b/src/Framework/View/Processors/PlaceholderReplacer.php new file mode 100644 index 00000000..19f93506 --- /dev/null +++ b/src/Framework/View/Processors/PlaceholderReplacer.php @@ -0,0 +1,42 @@ +query('//text()') as $textNode) { + $textNode->nodeValue = preg_replace_callback( + '/{{\s*([\w.]+)\s*}}/', + fn($m) => $this->resolveValue($context->data, $m[1]), + $textNode->nodeValue + ); + } + } + + private function resolveValue(array $data, string $expr): string + { + $keys = explode('.', $expr); + $value = $data; + foreach ($keys as $key) { + if (!is_array($value) || !array_key_exists($key, $value)) { + return "{{ $expr }}"; // Platzhalter bleibt erhalten + } + $value = $value[$key]; + } + return is_scalar($value) ? (string)$value : ''; + } + + public function supports(\DOMElement $element): bool + { + return $element->tagName === 'text'; + + } + +} diff --git a/src/Framework/View/Processors/SlotProcessor.php b/src/Framework/View/Processors/SlotProcessor.php new file mode 100644 index 00000000..03d487b8 --- /dev/null +++ b/src/Framework/View/Processors/SlotProcessor.php @@ -0,0 +1,42 @@ + + Default Header + Main Content + + +*/ + + +final readonly class SlotProcessor implements DomProcessor +{ + public function process(\DOMDocument $dom, RenderContext $context): void + { + $xpath = new \DOMXPath($dom); + + foreach ($xpath->query('//slot[@name]') as $slotNode) { + $slotName = $slotNode->getAttribute('name'); + $html = $context->slots[$slotName] ?? null; + + $replacement = $dom->createDocumentFragment(); + + if ($html !== null) { + @$replacement->appendXML($html); + } else { + // Fallback-Inhalt erhalten (die inneren Nodes des slot-Tags) + foreach ($slotNode->childNodes as $child) { + $replacement->appendChild($child->cloneNode(true)); + } + } + + $slotNode->parentNode?->replaceChild($replacement, $slotNode); + } + } +} diff --git a/src/Framework/View/Processors/SwitchCaseProcessor.php b/src/Framework/View/Processors/SwitchCaseProcessor.php new file mode 100644 index 00000000..8c1b408c --- /dev/null +++ b/src/Framework/View/Processors/SwitchCaseProcessor.php @@ -0,0 +1,61 @@ +` mit inneren ``-Elementen. + +final readonly class SwitchCaseProcessor implements DomProcessor +{ + + /** + * @inheritDoc + */ + public function process(\DOMDocument $dom, RenderContext $context): void + { + $xpath = new DOMXPath($dom); + + foreach ($xpath->query('//switch[@value]') as $switchNode) { + $key = $switchNode->getAttribute('value'); + $value = $context->data[$key] ?? null; + + $matchingCase = null; + $defaultCase = null; + + foreach ($switchNode->childNodes as $child) { + if (!($child instanceof \DOMElement)) { + continue; + } + + if ($child->tagName === 'case') { + $caseValue = $child->getAttribute('value'); + if ((string)$value === $caseValue) { + $matchingCase = $child; + break; + } + } + + if ($child->tagName === 'default') { + $defaultCase = $child; + } + } + + $replacement = $dom->createDocumentFragment(); + + if ($matchingCase) { + foreach ($matchingCase->childNodes as $child) { + $replacement->appendChild($child->cloneNode(true)); + } + } elseif ($defaultCase) { + foreach ($defaultCase->childNodes as $child) { + $replacement->appendChild($child->cloneNode(true)); + } + } + + $switchNode->parentNode?->replaceChild($replacement, $switchNode); + } + } +} diff --git a/src/Framework/View/RenderContext.php b/src/Framework/View/RenderContext.php new file mode 100644 index 00000000..1cf7f7aa --- /dev/null +++ b/src/Framework/View/RenderContext.php @@ -0,0 +1,14 @@ + '...'] + public ?string $layout = null, // Optionales Layout + public array $slots = [], // Benannte Slots wie ['main' => '

...

'] + public ?string $controllerClass = null + ) {} +} diff --git a/src/Framework/View/Renderer.php b/src/Framework/View/Renderer.php new file mode 100644 index 00000000..18e773cf --- /dev/null +++ b/src/Framework/View/Renderer.php @@ -0,0 +1,145 @@ +"; + } + $content = file_get_contents($filename); + // Optional: Rekursive/statische Komponentenverarbeitung + return $this->renderComponentPartial($content, array_merge($data, $attributes), $engine); + }; + + $this->manipulator->manipulate($dom, $data, $componentRenderer); + + + $xpath = new \DOMXPath($dom); + + + // 2. Schleifen + foreach ($xpath->query('//*[local-name() = "for"]') as $forNode) { + $var = $forNode->getAttribute('var'); + $in = $forNode->getAttribute('in'); + $html = ''; + if (isset($data[$in]) && is_iterable($data[$in])) { + foreach ($data[$in] as $item) { + $clone = $forNode->cloneNode(true); + foreach ($clone->childNodes as $node) { + $this->renderNode($node, [$var => $item] + $data); + } + $frag = $dom->createDocumentFragment(); + foreach ($clone->childNodes as $child) { + $frag->appendChild($child->cloneNode(true)); + } + $html .= $dom->saveHTML($frag); + } + } + // Ersetze das -Element + $fragment = $dom->createDocumentFragment(); + $fragment->appendXML($html); + $forNode->parentNode->replaceChild($fragment, $forNode); + } + + // Analog: - und -Elemente einbauen (optional, auf Anfrage) + + return $dom->saveHTML(); + } + + private function renderNode(\DOMNode $node, array $data): void + { + // Rekursiv auf allen Kindknoten Platzhalter ersetzen (wie oben) + if ($node->nodeType === XML_TEXT_NODE) { + $original = $node->nodeValue; + $replaced = preg_replace_callback('/{{\s*(\w+(?:\.\w+)*)\s*}}/', function ($matches) use ($data) { + $keys = explode('.', $matches[1]); + $value = $data; + foreach ($keys as $key) { + $value = is_array($value) && array_key_exists($key, $value) ? $value[$key] : $matches[0]; + } + + return is_scalar($value) + ? htmlspecialchars((string)$value, ENT_QUOTES | ENT_HTML5) + : $matches[0]; + }, $original); + if ($original !== $replaced) { + $node->nodeValue = $replaced; + } + } + if ($node->hasChildNodes()) { + foreach ($node->childNodes as $child) { + $this->renderNode($child, $data); + } + } + } + + /** + * Rendert ein Komponententemplate inklusive rekursivem Komponenten-Parsing. + */ + private function renderComponentPartial(string $content, array $data, Engine $engine): string + { + $cacheDir = __DIR__ . "/cache/components"; + if (!is_dir($cacheDir)) { + mkdir($cacheDir, 0777, true); + } + + $hash = md5($content . serialize($data)); + $cacheFile = $cacheDir . "/component_$hash.html"; + + if (file_exists($cacheFile)) { + return file_get_contents($cacheFile); + } + + + $dom = new \DOMDocument(); + @$dom->loadHTML(''.$content.''); + + // Komponenten innerhalb des Partials auflösen (rekursiv!) + $this->manipulator->processComponents( + $dom, + $data, + function ($name, $attributes, $data) use ($engine) { + $filename = __DIR__ . "/templates/components/$name.html"; + if (!file_exists($filename)) { + return ""; + } + $subContent = file_get_contents($filename); + return $this->renderComponentPartial($subContent, array_merge($data, $attributes), $engine); + } + ); + // Platzhalter in diesem Partial ersetzen + $this->manipulator->replacePlaceholders($dom, $data); + + // Nur den Inhalt von extrahieren + $body = $dom->getElementsByTagName('body')->item(0); + $innerHTML = ''; + foreach ($body->childNodes as $child) { + $innerHTML .= $dom->saveHTML($child); + } + file_put_contents($cacheFile, $innerHTML); + return $innerHTML; + } + +} diff --git a/src/Framework/View/StringProcessor.php b/src/Framework/View/StringProcessor.php new file mode 100644 index 00000000..80037f72 --- /dev/null +++ b/src/Framework/View/StringProcessor.php @@ -0,0 +1,8 @@ +getTemplatePath($template, $controllerClass); + if (! file_exists($file)) { + throw new \RuntimeException("Template \"$template\" nicht gefunden ($file)."); + } + + return file_get_contents($file); + } + + public function getTemplatePath(string $template, ?string $controllerClass = null): string + { + if($controllerClass) { + $rc = new \ReflectionClass($controllerClass); + $dir = dirname($rc->getFileName()); + return $dir . DIRECTORY_SEPARATOR . $template . '.html'; + } + return $this->templatePath . '/' . $template . '.html'; + } + + public function getComponentPath(string $name): string + { + return __DIR__ . "/templates/components/{$name}.html"; + } +} diff --git a/src/Framework/View/TemplateManipulator.php b/src/Framework/View/TemplateManipulator.php new file mode 100644 index 00000000..3663d375 --- /dev/null +++ b/src/Framework/View/TemplateManipulator.php @@ -0,0 +1,73 @@ +processors = $processor; + } + + public function manipulate(\DOMDocument $dom, array $data, callable $componentRenderer):void + { + $xpath = new \DOMXPath($dom); + foreach ($xpath->query('//*') as $element) { + if (!$element instanceof \DOMElement) continue; + foreach ($this->processors as $processor) { + if ($processor->supports($element)) { + $processor->process($dom, $data, $componentRenderer); + } + } + } + + } + + // Du könntest hier noch Platzhalter, Komponenten etc. ergänzen + public function replacePlaceholders(\DOMDocument $dom, array $data): void + { + // Einfaches Beispiel für {{ variable }}-Platzhalter in Textknoten + $xpath = new \DOMXPath($dom); + foreach ($xpath->query('//text()') as $textNode) { + foreach ($data as $key => $value) { + $placeholder = '{{ ' . $key . ' }}'; + if (str_contains($textNode->nodeValue, $placeholder)) { + $textNode->nodeValue = str_replace($placeholder, $value, $textNode->nodeValue); + } + } + } + } + + /** + * Parst und ersetzt -Elemente im DOM. + * @param \DOMDocument $dom + * @param array $data + * @param callable $componentRenderer Funktion: (Name, Attribute, Daten) => HTML + */ + public function processComponents(\DOMDocument $dom, array $data, callable $componentRenderer): void + { + $xpath = new \DOMXPath($dom); + + // Alle -Elemente durchgehen (XPath ist hier namespace-unabhängig) + foreach ($xpath->query('//component') as $componentNode) { + /** @var \DOMElement $componentNode */ + $name = $componentNode->getAttribute('name'); + // Alle Attribute als Array sammeln + $attributes = []; + foreach ($componentNode->attributes as $attr) { + $attributes[$attr->nodeName] = $attr->nodeValue; + } + + // Hole das gerenderte HTML für diese Komponente + $componentHtml = $componentRenderer($name, $attributes, $data); + + // Ersetze das Node durch neues HTML-Fragment + $fragment = $dom->createDocumentFragment(); + $fragment->appendXML($componentHtml); + $componentNode->parentNode->replaceChild($fragment, $componentNode); + } + } + +} diff --git a/src/Framework/View/TemplateProcessor.php b/src/Framework/View/TemplateProcessor.php new file mode 100644 index 00000000..84bdd89e --- /dev/null +++ b/src/Framework/View/TemplateProcessor.php @@ -0,0 +1,40 @@ +domProcessors[] = $processor; + } + + public function registerString(StringProcessor $processor): void + { + $this->stringProcessors[] = $processor; + } + + public function render(RenderContext $context, string $html): string + { + $parser = new DomTemplateParser(); + $dom = $parser->parse($html); + + foreach ($this->domProcessors as $processor) { + $processor->process($dom, $context); + } + + $html = $parser->toHtml($dom); + + foreach ($this->stringProcessors as $processor) { + $html = $processor->process($html, $context); + } + + return $html; + } +} diff --git a/src/Framework/View/TemplateRenderer.php b/src/Framework/View/TemplateRenderer.php new file mode 100644 index 00000000..e25c08f3 --- /dev/null +++ b/src/Framework/View/TemplateRenderer.php @@ -0,0 +1,10 @@ + + +

EPK

+

Das ist mein EPK

+ diff --git a/src/Framework/View/cache/impressum.cache.html b/src/Framework/View/cache/impressum.cache.html new file mode 100644 index 00000000..d0dd2c82 --- /dev/null +++ b/src/Framework/View/cache/impressum.cache.html @@ -0,0 +1,4 @@ + +

Impressum!

+

Das ist deine Seite

+
diff --git a/src/Framework/View/cache/test.cache.html b/src/Framework/View/cache/test.cache.html new file mode 100644 index 00000000..37ec8cd9 --- /dev/null +++ b/src/Framework/View/cache/test.cache.html @@ -0,0 +1,4 @@ + +

Willkommen!

+

Das ist deine Seite

+
diff --git a/src/Framework/View/templates/components/alert.html b/src/Framework/View/templates/components/alert.html new file mode 100644 index 00000000..a292df97 --- /dev/null +++ b/src/Framework/View/templates/components/alert.html @@ -0,0 +1,3 @@ +
+ {{ message }} +
diff --git a/src/Framework/View/templates/components/card.html b/src/Framework/View/templates/components/card.html new file mode 100644 index 00000000..811e3e61 --- /dev/null +++ b/src/Framework/View/templates/components/card.html @@ -0,0 +1,6 @@ +
+
{{ title }}
+
+ +
+
diff --git a/src/Framework/View/templates/epk.html b/src/Framework/View/templates/epk.html new file mode 100644 index 00000000..0efc6cb6 --- /dev/null +++ b/src/Framework/View/templates/epk.html @@ -0,0 +1,4 @@ + + +

EPK

+

Das ist mein EPK

diff --git a/src/Framework/View/templates/impressum.html b/src/Framework/View/templates/impressum.html new file mode 100644 index 00000000..3a8ea6fc --- /dev/null +++ b/src/Framework/View/templates/impressum.html @@ -0,0 +1,3 @@ + +

Impressum!

+

Das ist deine Seite

diff --git a/src/Framework/View/templates/layouts/main.html b/src/Framework/View/templates/layouts/main.html new file mode 100644 index 00000000..b08564c4 --- /dev/null +++ b/src/Framework/View/templates/layouts/main.html @@ -0,0 +1,73 @@ + + + + + + {{ title }} + + + + + + + + + + + + + + + + + + + + + + +
+
+ + Logo + + + +
+ +
+ +
+

Willkommen!

+

Das ist der Hauptinhalt deiner Seite.

+ + + + + + + +
Dies ist nur für Entwickler sichtbar.
+ +
Das ist öffentlich.
+ +
+ + + + + + + + diff --git a/src/Framework/View/templates/test.html b/src/Framework/View/templates/test.html new file mode 100644 index 00000000..e27c4af1 --- /dev/null +++ b/src/Framework/View/templates/test.html @@ -0,0 +1,3 @@ + +

Willkommen!

+

Das ist deine Seite

diff --git a/tests/Website/HomeControllerTest.php b/tests/Website/HomeControllerTest.php new file mode 100644 index 00000000..3276491b --- /dev/null +++ b/tests/Website/HomeControllerTest.php @@ -0,0 +1,12 @@ +show(); + $output = ob_get_clean(); + + expect($output)->toBe('Hello World'); +}); diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 00000000..c0eb72b7 --- /dev/null +++ b/vite.config.js @@ -0,0 +1,22 @@ +import { defineConfig } from 'vite'; +import { resolve } from 'path'; +// Optional: TypeScript support ist ohne extra Plugin enthalten! +// Bei Bedarf: npm install --save-dev typescript + +export default defineConfig({ + build: { + outDir: 'public', // Output bleibt im public-Verzeichnis + manifest: true, + emptyOutDir: false, + rollupOptions: { + input: { + css: resolve(__dirname, 'resources/css/styles.css'), + js: resolve(__dirname, 'resources/js/main.js') + // Falls TypeScript: + // ts: 'resources/ts/app.ts' + }, + plugins: [] + } + }, + plugins: [] +});