Merge remote-tracking branch 'refs/remotes/origin/main'

This commit is contained in:
2026-05-05 11:52:20 +02:00
15 changed files with 907 additions and 1071 deletions
+72
View File
@@ -63,6 +63,8 @@ Wichtige Ports:
- Gitea SSH: 2222
- Website Upstream: 4999
Hinweis: Die Website nutzt in Docker Compose intern den Service-Namen `mongodb` als MongoDB-Host. Wenn du die Website lokal mit `python` oder außerhalb des Containers startest, muss `MONGO_URI` auf einen hostzugänglichen MongoDB-Endpunkt zeigen oder `mongodb` über `27017:27017` erreichbar sein.
### Nginx
Datei: [nginx.sh](nginx.sh)
@@ -134,6 +136,40 @@ Hinweis:
- Development: [Website/launch_dev.sh](Website/launch_dev.sh)
- Production: [Website/launch_prod.sh](Website/launch_prod.sh)
### Neues: Start-Health-Check im `launch_dev.sh`
`Website/launch_dev.sh` führt jetzt vor dem Build eine kurze Prüfung durch und wartet nach dem Start auf eine erreichbare HTTP-Antwort:
- Vor dem Build wird geprüft, ob `gunicorn.conf.py` im `Website/`-Verzeichnis vorhanden ist; falls nicht, bricht das Skript mit einer erklärenden Fehlermeldung ab.
- Nach `docker compose up -d` wartet das Skript bis zu 60 Sekunden (Intervall 2s) auf einen erfolgreichen HTTP-Request (`/`) auf `127.0.0.1:4999`.
- Schlägt der Health-Check fehl, sammelt das Skript automatisch folgende Diagnosedaten und gibt Hinweise aus:
- `docker compose ps`
- `docker logs --tail 200 website-website-1`
Dies hilft, wiederkehrende Probleme (fehlende Dateien, fehlerhafte Container-Starts, nginx-Fehlkonfiguration) schneller zu finden.
Empfohlene manuelle Prüfbefehle (bei Problemen):
```bash
# Status aller Container (zeigt Port-Mappings)
docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'
# Prüfen, ob Host auf 4999 lauscht
ss -ltnp | grep 4999 || true
# Direktes Request vom Host (verbose)
curl -v http://127.0.0.1:4999/ --max-time 10
# Logs des Website-Containers (letzte 200 Zeilen)
docker logs --tail 200 website-website-1
# Testen von innerhalb des Containers (falls curl nicht installiert, nutze wget)
docker exec -it website-website-1 curl -v http://127.0.0.1:4999/ || \
docker exec -it website-website-1 wget -qO- http://127.0.0.1:4999/
```
Wenn du möchtest, passe ich das Timeout oder den Health-Endpoint (`/health`) an.
Beispiel Production:
```bash
@@ -144,6 +180,42 @@ export INSTANCE_WILDCARD_KEY_FILE=/etc/nginx/certs/wildcard.example.de.key
./launch_prod.sh
```
## SEO & LLM-Optimierung
Die Website ist für Suchmaschinen und LLM-Scraper optimiert:
- **Meta-Tags & OpenGraph**: Jede Seite enthält sinnvolle `<meta name="description">`, OpenGraph- und Twitter-Tags sowie einen Canonical-Link. Die Startseite und wichtige Unterseiten haben eine eigene `meta_description`.
- **JSON-LD**: Die Startseite enthält strukturierte Daten (Schema.org WebSite/Organization) als JSON-LD.
- **Maschinenlesbare Endpunkte**:
- `/health` — gibt `{ "status": "ok", "time": ... }` zurück (Status 200), geeignet für Health-Checks und Monitoring.
- `/sitemap.xml` — maschinenlesbare Sitemap für Suchmaschinen, dynamisch generiert.
- `/robots.txt` — erlaubt Crawling, verweist auf Sitemap, blockiert `/admin/` und Uploads.
- **Server-Side Rendering**: Alle wichtigen Inhalte sind serverseitig gerendert und ohne JavaScript zugänglich.
**Beispiel für Health-Check:**
```bash
curl -i http://127.0.0.1:4999/health
```
**Beispiel für Sitemap:**
```bash
curl -i http://127.0.0.1:4999/sitemap.xml
```
**robots.txt Beispiel:**
User-agent: *
Disallow: /admin/
Disallow: /static/uploads/
Allow: /
Sitemap: /sitemap.xml
**Hinweis:**
- Die SEO-Meta-Tags können pro Seite in den Templates überschrieben werden (`meta_description`).
- Die Sitemap kann bei Bedarf um weitere Seiten ergänzt werden.
## Empfohlener Ablauf
1. sudo ./gitea.sh start
+2 -2
View File
@@ -36,8 +36,8 @@ RUN set -eu; \
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN chmod +x /app/provision_instance.sh
# Copy application sources into the image (templates, static, main app, config)
COPY . /app
EXPOSE 4999
+2
View File
@@ -12,6 +12,8 @@ services:
mem_reservation: 192m
environment:
MONGO_INITDB_DATABASE: Invario_Website
ports:
- "27017:27017"
volumes:
- mongo_data:/data/db
healthcheck:
+31
View File
@@ -15,6 +15,12 @@ echo " INSTANCE_TLS_MODE=$INSTANCE_TLS_MODE"
echo " INSTANCE_PARENT_DOMAIN=$INSTANCE_PARENT_DOMAIN"
echo " DEV_HOSTS_REFRESH_INTERVAL=$DEV_HOSTS_REFRESH_INTERVAL"
# Ensure required runtime files exist before building
if [ ! -f "$SCRIPT_DIR/gunicorn.conf.py" ]; then
echo "[FEHLER] gunicorn.conf.py fehlt in $SCRIPT_DIR. Bitte prüfen." >&2
exit 2
fi
docker compose build website
docker compose up -d
@@ -24,6 +30,31 @@ if [[ -x "$NGINX_SCRIPT" ]]; then
sudo "$NGINX_SCRIPT" apply || echo "Warnung: Nginx konnte nicht automatisch aktualisiert werden."
fi
# Wait for website to become healthy (simple HTTP check)
check_url="http://127.0.0.1:4999/"
timeout_seconds=60
interval=2
elapsed=0
echo "Warte auf Website (${check_url}) bis ${timeout_seconds}s..."
while [ $elapsed -lt $timeout_seconds ]; do
if curl -sS --max-time 2 "$check_url" >/dev/null 2>&1; then
echo "Website erreichbar nach ${elapsed}s"
break
fi
sleep $interval
elapsed=$((elapsed + interval))
done
if [ $elapsed -ge $timeout_seconds ]; then
echo "[FEHLER] Website nicht erreichbar nach ${timeout_seconds}s. Sammle Diagnosedaten..."
echo "--- docker compose ps ---"
docker compose ps || true
echo "--- docker logs website (tail 200) ---"
docker logs --tail 200 website-website-1 || true
echo "Bitte prüfe Container-Logs und nginx-Konfiguration."
fi
if [[ -x "$SCRIPT_DIR/sync_dev_hosts.sh" ]]; then
"$SCRIPT_DIR/sync_dev_hosts.sh" || true
if [[ "${DEV_HOSTS_REFRESH_INTERVAL}" != "0" ]]; then
-53
View File
@@ -1,53 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
export SESSION_COOKIE_SECURE="1"
export INSTANCE_TLS_MODE="production"
export INSTANCE_PARENT_DOMAIN="${INSTANCE_PARENT_DOMAIN:-meine-domain}"
export INSTANCE_WILDCARD_CERT_FILE="${INSTANCE_WILDCARD_CERT_FILE:-/etc/nginx/certs/wildcard.meine-domain.crt}"
export INSTANCE_WILDCARD_KEY_FILE="${INSTANCE_WILDCARD_KEY_FILE:-/etc/nginx/certs/wildcard.meine-domain.key}"
if [[ ! -f "$INSTANCE_WILDCARD_CERT_FILE" || ! -f "$INSTANCE_WILDCARD_KEY_FILE" ]]; then
echo "Error: Wildcard certificate missing for production mode"
echo " CERT: $INSTANCE_WILDCARD_CERT_FILE"
echo " KEY: $INSTANCE_WILDCARD_KEY_FILE"
exit 1
fi
echo "Launching PRODUCTION stack"
echo " SESSION_COOKIE_SECURE=$SESSION_COOKIE_SECURE"
echo " INSTANCE_TLS_MODE=$INSTANCE_TLS_MODE"
echo " INSTANCE_PARENT_DOMAIN=$INSTANCE_PARENT_DOMAIN"
echo " INSTANCE_WILDCARD_CERT_FILE=$INSTANCE_WILDCARD_CERT_FILE"
echo " INSTANCE_WILDCARD_KEY_FILE=$INSTANCE_WILDCARD_KEY_FILE"
docker compose build website
docker compose up -d
NGINX_SCRIPT="$SCRIPT_DIR/../nginx.sh"
if [[ -x "$NGINX_SCRIPT" ]]; then
echo "Aktualisiere Nginx Reverse Proxy..."
sudo "$NGINX_SCRIPT" apply || echo "Warnung: Nginx konnte nicht automatisch aktualisiert werden."
fi
# Automatically sync hosts after container start
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if [[ -x "$SCRIPT_DIR/sync_dev_hosts.sh" ]]; then
echo "Warte auf MongoDB-Verfügbarkeit..."
sleep 5
"$SCRIPT_DIR/sync_dev_hosts.sh" || true
echo "Production Hosts werden synchronisiert (stündlich)"
# Starte Background-Daemon für stündliche Sync
if [[ "${PROD_HOSTS_REFRESH_INTERVAL}" != "0" ]]; then
nohup bash -lc "while true; do '$SCRIPT_DIR/sync_dev_hosts.sh' >/dev/null 2>&1 || true; sleep ${PROD_HOSTS_REFRESH_INTERVAL:-3600}; done" \
>"$SCRIPT_DIR/prod-hosts-sync.log" 2>&1 &
echo " Prod-Hosts-Sync läuft im Hintergrund (Log: $SCRIPT_DIR/prod-hosts-sync.log)"
fi
fi
echo "Production stack is running on http://localhost:4999"
echo "Provisioning expects wildcard certificate for all subdomains."
+42 -2
View File
@@ -1615,6 +1615,47 @@ def default():
return render_template("main.html")
@app.route('/health')
def health():
return jsonify({"status": "ok", "time": _utc_now_iso()}), 200
@app.route('/robots.txt')
def robots_txt():
path = os.path.join(BASE_DIR, 'static', 'robots.txt')
if os.path.exists(path):
return send_file(path, mimetype='text/plain')
return (
"User-agent: *\nDisallow: /admin/\nAllow: /\nSitemap: /sitemap.xml\n",
200,
{"Content-Type": "text/plain"},
)
@app.route('/sitemap.xml')
def sitemap_xml():
site_root = request.url_root.rstrip('/')
sitemap_path = os.path.join(BASE_DIR, 'static', 'sitemap.xml')
if os.path.exists(sitemap_path):
try:
with open(sitemap_path, 'r', encoding='utf-8') as fh:
content = fh.read().replace('{{ SITE_ROOT }}', site_root)
return app.response_class(content, mimetype='application/xml')
except Exception:
pass
xml = (
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
' <url>\n'
f' <loc>{site_root}</loc>\n'
' <changefreq>daily</changefreq>\n'
' <priority>1.0</priority>\n'
' </url>\n'
'</urlset>'
)
return app.response_class(xml, mimetype='application/xml')
@app.route('/dienstleistungen')
def dienstleistungen():
return render_template("dienstleistungen.html")
@@ -1785,8 +1826,7 @@ def appointments():
def book_option_package():
package_raw = _sanitize_text(request.form.get("package") or "", 40).lower()
package_map = {
"normal": "Normal",
"pro": "Pro",
"inventarsystem": "Inventarsystem",
"buecherei": "Bücherei",
}
selected_package = package_map.get(package_raw)
-765
View File
@@ -1,765 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ACTION="create"
SCHOOL_NAME=""
SUBDOMAIN=""
REPO_URL=""
BASE_DIR=""
PARENT_DOMAIN=""
HTTPS_PORT=""
HTTP_PORT=""
TLS_MODE="development"
APP_IMAGE_TAG="latest"
LIBRARY_ENABLED="0"
WILDCARD_CERT_FILE="/etc/nginx/certs/wildcard.meine-domain.crt"
WILDCARD_KEY_FILE="/etc/nginx/certs/wildcard.meine-domain.key"
NGINX_SITES_AVAILABLE="/etc/nginx/sites-available"
NGINX_SITES_ENABLED="/etc/nginx/sites-enabled"
RELEASE_BUNDLE_ASSET="inventarsystem-docker-bundle.tar.gz"
RELEASE_IMAGE_ASSET_PREFIX="inventarsystem-image-"
print_kv() {
local key="$1"
local value="$2"
printf '%s=%s\n' "$key" "$value"
}
fail() {
local message="$1"
print_kv "MESSAGE" "$message"
print_kv "ERROR" "$message"
exit 1
}
slugify() {
local input="$1"
input="${input,,}"
input="$(echo "$input" | sed -E 's/ä/ae/g; s/ö/oe/g; s/ü/ue/g; s/ß/ss/g')"
input="$(echo "$input" | sed -E 's/[^a-z0-9-]+/-/g; s/-+/-/g; s/^-+//; s/-+$//')"
printf '%s' "${input:0:63}"
}
is_valid_subdomain() {
local value="$1"
[[ "$value" =~ ^[a-z0-9]([a-z0-9-]{1,61}[a-z0-9])$ ]]
}
require_cmd() {
local cmd="$1"
command -v "$cmd" >/dev/null 2>&1 || fail "Fehlender Befehl: $cmd"
}
is_port_in_use() {
local port="$1"
# Running inside container namespaces cannot reliably see host listeners,
# but docker CLI can inspect host-published ports via the socket.
if docker ps --format '{{.Ports}}' | grep -Eq "(^|[,[:space:]])((0\.0\.0\.0|\[::\]):)?${port}->"; then
return 0
fi
if command -v ss >/dev/null 2>&1; then
ss -ltn "( sport = :$port )" 2>/dev/null | awk 'NR>1 {print $4}' | grep -q . && return 0
fi
return 1
}
next_free_port() {
local start_port="$1"
local current="$start_port"
while is_port_in_use "$current"; do
current=$((current + 1))
done
printf '%s' "$current"
}
write_env_file() {
local target_dir="$1"
local http_port="$2"
local https_port="$3"
local app_image_tag="$4"
cat > "$target_dir/.docker-build.env" <<EOF
NUITKA_BUILD=0
INVENTAR_HTTP_PORT=$http_port
INVENTAR_HTTPS_PORT=$https_port
INVENTAR_APP_IMAGE=ghcr.io/aiirondev/legendary-octo-garbanzo:$app_image_tag
INVENTAR_MULTITENANT_ENABLED=true
INVENTAR_SESSION_BACKEND=redis
INVENTAR_REDIS_HOST=redis
INVENTAR_REDIS_PORT=6379
INVENTAR_QUERY_CACHE_ENABLED=true
EOF
}
preferred_compose_file() {
if [ -f "docker-compose-multitenant.yml" ]; then
printf '%s' "docker-compose-multitenant.yml"
return 0
fi
printf '%s' "docker-compose.yml"
}
repo_slug_from_url() {
local repo_url="$1"
local normalized=""
normalized="$repo_url"
normalized="${normalized#https://github.com/}"
normalized="${normalized#http://github.com/}"
normalized="${normalized#git@github.com:}"
normalized="${normalized%.git}"
if [[ "$normalized" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]]; then
printf '%s' "$normalized"
return 0
fi
return 1
}
fetch_release_metadata() {
local repo_slug="$1"
local requested_tag="$2"
local out_file="$3"
local api_url=""
if [ "$requested_tag" = "latest" ]; then
api_url="https://api.github.com/repos/$repo_slug/releases/latest"
else
api_url="https://api.github.com/repos/$repo_slug/releases/tags/$requested_tag"
fi
if ! curl -fsSL "$api_url" -o "$out_file"; then
return 1
fi
return 0
}
extract_release_info() {
local meta_file="$1"
python3 - <<'PY' "$meta_file" "$RELEASE_BUNDLE_ASSET" "$RELEASE_IMAGE_ASSET_PREFIX"
import json
import sys
meta_file, bundle_asset, image_prefix = sys.argv[1], sys.argv[2], sys.argv[3]
with open(meta_file, "r", encoding="utf-8") as fh:
data = json.load(fh)
tag = (data.get("tag_name") or "").strip()
bundle_url = ""
image_url = ""
for asset in data.get("assets", []):
name = (asset.get("name") or "").strip()
url = (asset.get("browser_download_url") or "").strip()
if not url:
continue
if name == bundle_asset:
bundle_url = url
if tag and name == f"{image_prefix}{tag}.tar.gz":
image_url = url
print(tag)
print(bundle_url)
print(image_url)
PY
}
pin_compose_app_image() {
local instance_dir="$1"
local resolved_tag="$2"
local compose_file=""
for compose_file in "$instance_dir/docker-compose.yml" "$instance_dir/docker-compose-multitenant.yml"; do
[ -f "$compose_file" ] || continue
python3 - <<'PY' "$compose_file" "$resolved_tag"
import re
import sys
compose_file, tag = sys.argv[1], sys.argv[2]
target_image = f"ghcr.io/aiirondev/legendary-octo-garbanzo:{tag}"
with open(compose_file, "r", encoding="utf-8") as fh:
lines = fh.readlines()
out = []
in_app = False
in_build = False
image_set = False
for line in lines:
stripped = line.lstrip(" ")
indent = len(line) - len(stripped)
if not in_app and re.match(r"^\s{2}app:\s*$", line):
in_app = True
image_set = True
out.append(line)
out.append(f" image: {target_image}\n")
continue
if in_app:
if indent == 2 and re.match(r"^[A-Za-z0-9_-]+:\s*$", stripped):
in_app = False
in_build = False
if in_app:
if in_build:
if indent > 4:
continue
in_build = False
if re.match(r"^\s{4}build:\s*$", line):
in_build = True
continue
if re.match(r"^\s{4}image:\s*", line):
if image_set:
continue
out.append(f" image: {target_image}\n")
image_set = True
continue
out.append(line)
with open(compose_file, "w", encoding="utf-8") as fh:
fh.writelines(out)
PY
done
}
install_from_release() {
local target_dir="$1"
local repo_url="$2"
local requested_tag="$3"
local tmp_dir=""
local repo_slug=""
local meta_file=""
local bundle_path=""
local image_path=""
local tag=""
local bundle_url=""
local image_url=""
repo_slug="$(repo_slug_from_url "$repo_url")" || fail "Repository-URL nicht unterstützbar für Release-Install: $repo_url"
tmp_dir="$(mktemp -d)"
meta_file="$tmp_dir/release.json"
if ! fetch_release_metadata "$repo_slug" "$requested_tag" "$meta_file"; then
fail "Release-Metadaten konnten nicht geladen werden (Repo: $repo_slug, Tag: $requested_tag)."
fi
mapfile -t release_info < <(extract_release_info "$meta_file")
tag="${release_info[0]:-}"
bundle_url="${release_info[1]:-}"
image_url="${release_info[2]:-}"
[ -n "$tag" ] || fail "Release-Metadaten enthalten keinen gültigen Tag."
[ -n "$bundle_url" ] || fail "Release-Asset fehlt: $RELEASE_BUNDLE_ASSET"
[ -n "$image_url" ] || fail "Release-Image-Asset fehlt: ${RELEASE_IMAGE_ASSET_PREFIX}${tag}.tar.gz"
bundle_path="$tmp_dir/$RELEASE_BUNDLE_ASSET"
image_path="$tmp_dir/${RELEASE_IMAGE_ASSET_PREFIX}${tag}.tar.gz"
curl -fsSL "$bundle_url" -o "$bundle_path" || fail "Release-Bundle konnte nicht geladen werden."
curl -fsSL "$image_url" -o "$image_path" || fail "Release-Image konnte nicht geladen werden."
mkdir -p "$target_dir"
tar -xzf "$bundle_path" -C "$target_dir" || fail "Release-Bundle konnte nicht entpackt werden."
docker load -i "$image_path" >/dev/null 2>&1 || fail "Docker-Image konnte nicht geladen werden."
docker tag "ghcr.io/aiirondev/legendary-octo-garbanzo:$tag" "ghcr.io/aiirondev/legendary-octo-garbanzo:latest" >/dev/null 2>&1 || true
pin_compose_app_image "$target_dir" "$tag"
rm -rf "$tmp_dir" >/dev/null 2>&1 || true
print_kv "APP_IMAGE_TAG" "$tag"
}
ensure_requested_app_image() {
local app_image_tag="$1"
local app_image="ghcr.io/aiirondev/legendary-octo-garbanzo:$app_image_tag"
if docker image inspect "$app_image" >/dev/null 2>&1; then
return 0
fi
if ! docker pull "$app_image" >/dev/null 2>&1; then
return 1
fi
return 0
}
set_library_enabled() {
local instance_dir="$1"
local enabled="$2"
local cfg="$instance_dir/config.json"
[ -f "$cfg" ] || return 0
python3 - <<'PY' "$cfg" "$enabled"
import json
import sys
cfg_path = sys.argv[1]
enabled = sys.argv[2] == "1"
with open(cfg_path, "r", encoding="utf-8") as fh:
data = json.load(fh)
modules = data.get("modules")
if not isinstance(modules, dict):
modules = {}
data["modules"] = modules
library = modules.get("library")
if not isinstance(library, dict):
library = {}
modules["library"] = library
library["enabled"] = enabled
with open(cfg_path, "w", encoding="utf-8") as fh:
json.dump(data, fh, indent=2, ensure_ascii=False)
fh.write("\n")
PY
}
normalize_instance_compose() {
local compose_file=""
for compose_file in "docker-compose.yml" "docker-compose-multitenant.yml"; do
[ -f "$compose_file" ] || continue
python3 - <<'PY' "$compose_file"
from pathlib import Path
import sys
path = Path(sys.argv[1])
content = path.read_text(encoding="utf-8")
lines = content.splitlines()
out = []
in_nginx = False
in_ports = False
ports_indent = ""
in_app = False
seen_app_image = False
i = 0
while i < len(lines):
line = lines[i]
stripped = line.strip()
indent = len(line) - len(line.lstrip(" "))
if stripped.startswith("nginx:") and indent == 2:
in_nginx = True
in_ports = False
out.append(line)
i += 1
continue
if indent == 2 and stripped.endswith(":"):
in_app = stripped.startswith("app:")
seen_app_image = False
if in_nginx and indent == 2 and stripped.endswith(":") and not stripped.startswith("nginx:"):
in_nginx = False
in_ports = False
if stripped.startswith("container_name:") and indent == 4:
i += 1
continue
if in_app and indent == 4 and stripped.startswith("image:"):
if seen_app_image:
i += 1
continue
seen_app_image = True
if in_nginx and stripped.startswith("ports:") and indent == 4:
out.append(line)
out.append(" - \"${INVENTAR_HTTP_PORT:-80}:80\"")
out.append(" - \"${INVENTAR_HTTPS_PORT:-443}:443\"")
i += 1
in_ports = True
ports_indent = " "
while i < len(lines):
nxt = lines[i]
nxt_stripped = nxt.strip()
nxt_indent = len(nxt) - len(nxt.lstrip(" "))
if nxt_stripped.startswith("-") and nxt_indent >= len(ports_indent):
i += 1
continue
break
continue
out.append(line)
i += 1
normalized = "\n".join(out)
if content.endswith("\n"):
normalized += "\n"
if normalized != content:
path.write_text(normalized, encoding="utf-8")
PY
done
}
run_instance_start() {
local start_output
local retry_output
local update_output
local compose_file
local has_redis=0
compose_file="$(preferred_compose_file)"
if [ -f "$compose_file" ] && grep -Eq '^\s{2}redis:\s*$' "$compose_file"; then
has_redis=1
fi
stack_is_running() {
local running_services
running_services="$(docker compose -f "$compose_file" --env-file .docker-build.env ps --status running --services 2>/dev/null || true)"
printf '%s\n' "$running_services" | grep -Fxq app || return 1
printf '%s\n' "$running_services" | grep -Fxq nginx || return 1
printf '%s\n' "$running_services" | grep -Fxq mongodb || return 1
if [ "$has_redis" = "1" ]; then
printf '%s\n' "$running_services" | grep -Fxq redis || return 1
fi
return 0
}
if [ "$compose_file" = "docker-compose-multitenant.yml" ]; then
if start_output="$(docker compose -f "$compose_file" --env-file .docker-build.env up -d --remove-orphans 2>&1)"; then
print_kv "MESSAGE" "Instanz gestartet (Inventarsystem Multiinstancing aktiv)."
return 0
fi
if stack_is_running; then
print_kv "MESSAGE" "Instanz gestartet (Multiinstancing läuft, Healthcheck im Startskript war nicht erreichbar)."
return 0
fi
if printf '%s' "$start_output" | grep -qi "local app image not found"; then
if [ ! -x ./update.sh ]; then
fail "Multiinstancing-Start fehlgeschlagen und update.sh fehlt. Letzte Meldung: $(printf '%s' "$start_output" | tail -n1)"
fi
if ! update_output="$(bash ./update.sh 2>&1)"; then
if stack_is_running; then
print_kv "MESSAGE" "Instanz gestartet (update.sh meldete Healthcheck-Fehler, Multiinstancing-Dienste laufen)."
return 0
fi
fail "update.sh fehlgeschlagen: $(printf '%s' "$update_output" | tail -n1)"
fi
if retry_output="$(docker compose -f "$compose_file" --env-file .docker-build.env up -d --remove-orphans 2>&1)"; then
print_kv "MESSAGE" "Instanz gestartet (Multiinstancing-Image automatisch per update.sh geladen)."
return 0
fi
fail "Multiinstancing-Start nach update.sh fehlgeschlagen: $(printf '%s' "$retry_output" | tail -n1)"
fi
fail "Multiinstancing-Start fehlgeschlagen: $(printf '%s' "$start_output" | tail -n1)"
fi
if start_output="$(INVENTAR_SETUP_CRON=0 INVENTAR_HTTP_PORT="$HTTP_PORT" INVENTAR_HTTPS_PORT="$HTTPS_PORT" bash ./start.sh --no-cron 2>&1)"; then
return 0
fi
if stack_is_running; then
print_kv "MESSAGE" "Instanz gestartet (Healthcheck im Startskript war nicht erreichbar, Dienste laufen)."
return 0
fi
if printf '%s' "$start_output" | grep -qi "local app image not found"; then
if [ ! -x ./update.sh ]; then
fail "start.sh fehlgeschlagen und update.sh fehlt. Letzte Meldung: $(printf '%s' "$start_output" | tail -n1)"
fi
if ! update_output="$(bash ./update.sh 2>&1)"; then
if stack_is_running; then
print_kv "MESSAGE" "Instanz gestartet (update.sh meldete Healthcheck-Fehler, Dienste laufen)."
return 0
fi
fail "update.sh fehlgeschlagen: $(printf '%s' "$update_output" | tail -n1)"
fi
if retry_output="$(INVENTAR_SETUP_CRON=0 INVENTAR_HTTP_PORT="$HTTP_PORT" INVENTAR_HTTPS_PORT="$HTTPS_PORT" bash ./start.sh --no-cron 2>&1)"; then
print_kv "MESSAGE" "Instanz gestartet (Image automatisch per update.sh geladen)."
return 0
fi
fail "Start nach update.sh fehlgeschlagen: $(printf '%s' "$retry_output" | tail -n1)"
fi
fail "start.sh fehlgeschlagen: $(printf '%s' "$start_output" | tail -n1)"
}
setup_or_update_repo() {
local target_dir="$1"
local repo_url="$2"
local app_image_tag="$3"
if [ -f "$target_dir/start.sh" ]; then
# Existing installation: keep update flow inside Inventarsystem tooling.
if [ "$app_image_tag" = "latest" ] && [ -x "$target_dir/update.sh" ]; then
(cd "$target_dir" && bash ./update.sh >/dev/null 2>&1 || true)
fi
return 0
fi
if [ -d "$target_dir" ] && [ "$(find "$target_dir" -mindepth 1 -maxdepth 1 | wc -l)" -gt 0 ]; then
fail "Zielverzeichnis ist nicht leer: $target_dir"
fi
install_from_release "$target_dir" "$repo_url" "$app_image_tag"
}
write_nginx_site() {
local subdomain="$1"
local full_domain="$2"
local https_port="$3"
local cert_file=""
local key_file=""
local cert_dir="/etc/nginx/certs"
local site_name="inventarsystem-${subdomain}.conf"
local avail_file="$NGINX_SITES_AVAILABLE/$site_name"
local enabled_file="$NGINX_SITES_ENABLED/$site_name"
if [ ! -d "$NGINX_SITES_AVAILABLE" ] || [ ! -d "$NGINX_SITES_ENABLED" ]; then
print_kv "NGINX_STATUS" "skipped"
print_kv "MESSAGE" "Instanz gestartet. Nginx-Verzeichnisse nicht gefunden, Reverse-Proxy manuell anlegen."
return 0
fi
if [ ! -w "$NGINX_SITES_AVAILABLE" ] || [ ! -w "$NGINX_SITES_ENABLED" ]; then
print_kv "NGINX_STATUS" "manual_required"
print_kv "MESSAGE" "Instanz gestartet. Keine Schreibrechte auf Nginx-Konfiguration, bitte als root/sudo ausführen."
return 0
fi
if [ "$TLS_MODE" = "production" ]; then
cert_file="$WILDCARD_CERT_FILE"
key_file="$WILDCARD_KEY_FILE"
if [ ! -f "$cert_file" ] || [ ! -f "$key_file" ]; then
print_kv "NGINX_STATUS" "error"
print_kv "MESSAGE" "Produktivmodus aktiv, aber Wildcard-Zertifikat fehlt: $cert_file / $key_file"
return 0
fi
else
cert_file="$cert_dir/$full_domain.crt"
key_file="$cert_dir/$full_domain.key"
if [ ! -f "$cert_file" ] || [ ! -f "$key_file" ]; then
if ! command -v openssl >/dev/null 2>&1; then
print_kv "NGINX_STATUS" "manual_required"
print_kv "MESSAGE" "Instanz gestartet. openssl fehlt für Development-Zertifikat, Zertifikat bitte manuell anlegen."
return 0
fi
if [ ! -d "$cert_dir" ] || [ ! -w "$cert_dir" ]; then
print_kv "NGINX_STATUS" "manual_required"
print_kv "MESSAGE" "Instanz gestartet. Keine Schreibrechte auf $cert_dir für Development-Zertifikat."
return 0
fi
if ! openssl req -x509 -nodes -newkey rsa:2048 -sha256 \
-days 825 \
-keyout "$key_file" \
-out "$cert_file" \
-subj "/CN=$full_domain" \
-addext "subjectAltName=DNS:$full_domain" >/dev/null 2>&1; then
openssl req -x509 -nodes -newkey rsa:2048 -sha256 \
-days 825 \
-keyout "$key_file" \
-out "$cert_file" \
-subj "/CN=$full_domain" >/dev/null 2>&1 || {
print_kv "NGINX_STATUS" "manual_required"
print_kv "MESSAGE" "Instanz gestartet. Development-Zertifikat konnte nicht erstellt werden."
return 0
}
fi
chmod 600 "$key_file" 2>/dev/null || true
chmod 644 "$cert_file" 2>/dev/null || true
fi
fi
cat > "$avail_file" <<EOF
server {
listen 80;
server_name $full_domain;
return 301 https://\$host\$request_uri;
}
server {
listen 443 ssl;
server_name $full_domain;
ssl_certificate $cert_file;
ssl_certificate_key $key_file;
location / {
proxy_pass https://127.0.0.1:$https_port;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_ssl_verify off;
proxy_read_timeout 120s;
}
}
EOF
ln -sfn "$avail_file" "$enabled_file"
if ! command -v nginx >/dev/null 2>&1; then
print_kv "NGINX_STATUS" "manual_required"
print_kv "MESSAGE" "Instanz gestartet. Nginx-Site wurde geschrieben, Reload bitte auf dem Host ausführen."
return 0
fi
if nginx -t >/dev/null 2>&1; then
nginx -s reload >/dev/null 2>&1 || true
print_kv "NGINX_STATUS" "ok"
else
print_kv "NGINX_STATUS" "error"
print_kv "MESSAGE" "Instanz gestartet, aber nginx -t ist fehlgeschlagen."
return 0
fi
}
while [ "$#" -gt 0 ]; do
case "$1" in
--action)
ACTION="${2:-}"
shift 2
;;
--school-name)
SCHOOL_NAME="${2:-}"
shift 2
;;
--subdomain)
SUBDOMAIN="${2:-}"
shift 2
;;
--repo)
REPO_URL="${2:-}"
shift 2
;;
--base-dir)
BASE_DIR="${2:-}"
shift 2
;;
--domain)
PARENT_DOMAIN="${2:-}"
shift 2
;;
--https-port)
HTTPS_PORT="${2:-}"
shift 2
;;
--http-port)
HTTP_PORT="${2:-}"
shift 2
;;
--tls-mode)
TLS_MODE="${2:-}"
shift 2
;;
--wildcard-cert-file)
WILDCARD_CERT_FILE="${2:-}"
shift 2
;;
--wildcard-key-file)
WILDCARD_KEY_FILE="${2:-}"
shift 2
;;
--app-image-tag)
APP_IMAGE_TAG="${2:-latest}"
shift 2
;;
--library-enabled)
LIBRARY_ENABLED="${2:-0}"
shift 2
;;
*)
fail "Unbekannter Parameter: $1"
;;
esac
done
[ -n "$REPO_URL" ] || fail "Repository-URL fehlt"
[ -n "$BASE_DIR" ] || fail "Basisverzeichnis fehlt"
[ -n "$PARENT_DOMAIN" ] || fail "Parent-Domain fehlt"
if [ -z "$SUBDOMAIN" ]; then
SUBDOMAIN="$(slugify "$SCHOOL_NAME")"
else
SUBDOMAIN="$(slugify "$SUBDOMAIN")"
fi
is_valid_subdomain "$SUBDOMAIN" || fail "Ungueltige Subdomain: $SUBDOMAIN"
INSTANCE_DIR="$BASE_DIR/$SUBDOMAIN"
FULL_DOMAIN="$SUBDOMAIN.$PARENT_DOMAIN"
require_cmd docker
require_cmd bash
require_cmd curl
require_cmd tar
require_cmd python3
mkdir -p "$BASE_DIR"
if [ -z "$HTTPS_PORT" ]; then
HTTPS_PORT="$(next_free_port 15443)"
fi
if [ -z "$HTTP_PORT" ]; then
HTTP_PORT="$(next_free_port 15080)"
fi
if [ "$ACTION" = "create" ] || [ "$ACTION" = "start" ]; then
setup_or_update_repo "$INSTANCE_DIR" "$REPO_URL" "$APP_IMAGE_TAG"
[ -f "$INSTANCE_DIR/start.sh" ] || fail "start.sh im Zielrepository nicht gefunden: $INSTANCE_DIR"
if ! ensure_requested_app_image "$APP_IMAGE_TAG"; then
fail "App-Image konnte nicht geladen werden: ghcr.io/aiirondev/legendary-octo-garbanzo:$APP_IMAGE_TAG"
fi
write_env_file "$INSTANCE_DIR" "$HTTP_PORT" "$HTTPS_PORT" "$APP_IMAGE_TAG"
set_library_enabled "$INSTANCE_DIR" "$LIBRARY_ENABLED"
cd "$INSTANCE_DIR"
normalize_instance_compose
run_instance_start
write_nginx_site "$SUBDOMAIN" "$FULL_DOMAIN" "$HTTPS_PORT"
print_kv "SUBDOMAIN" "$SUBDOMAIN"
print_kv "DOMAIN" "$FULL_DOMAIN"
print_kv "HTTPS_PORT" "$HTTPS_PORT"
print_kv "HTTP_PORT" "$HTTP_PORT"
print_kv "APP_IMAGE_TAG" "$APP_IMAGE_TAG"
print_kv "LIBRARY_ENABLED" "$LIBRARY_ENABLED"
print_kv "INSTANCE_DIR" "$INSTANCE_DIR"
exit 0
fi
fail "Nicht unterstuetzte Action: $ACTION"
+51 -8
View File
@@ -5,16 +5,18 @@ import requests
_var = "/opt/legendary-octo-garbanzo"
_cmd = "run-tenant-cmd.sh"
def add_dns(name: str) -> bool:
def add_dns(name: str, port: int) -> bool:
"""
Adds the subdomain to the DNS Routes
Input:
- name (Name of the subdomain) -> String
- port (Port number for the subdomain) -> int
Output:
- bool (True if adding of the Subdomain is active)
"""
# This is a placeholder function, as the actual implementation would depend on the DNS provider and how the DNS records are managed.
pass
def clear_special(var_:str) -> str:
@@ -45,7 +47,7 @@ def clear_special(var_:str) -> str:
except:
return False
def execute_script(wd_: str, file_: str, com_: str, com2_: str="None"):
def execute_script(wd_: str, file_: str, com_: str, com2_: str="None", com3_: str="None"):
"""
executes a script with the option of to extra inputs
@@ -54,6 +56,7 @@ def execute_script(wd_: str, file_: str, com_: str, com2_: str="None"):
- file_ = working file that youre targeting -> String
- com_ = first option -> String
- com2_ = second option (Optional if needet)-> String
- com3_ = third option (Optional if needet)-> String
Output:
- ether False if failed -> bool
@@ -64,9 +67,11 @@ def execute_script(wd_: str, file_: str, com_: str, com2_: str="None"):
if not update_path:
return False
if com2_ != "None":
cmd = f'bash "{update_path}" {com_}'
else:
cmd = f'bash "{update_path}" {com_} {com2_}'
elif com3_ != "None":
cmd = f'bash "{update_path}" {com_} {com2_} {com3_}'
else:
cmd = f'bash "{update_path}" {com_}'
try:
result = subprocess.run(
["bash", "-lc", cmd],
@@ -99,7 +104,7 @@ class instace:
def __init__():
return list()
def new(name: str) -> bool:
def new(name: str):
"""
Generates a new instance with the subdomain [name].invario.eu
@@ -107,10 +112,16 @@ class instace:
- name -> String
Output:
- bool if the initiation works (True: generation worked; False: didnt work)
- int (Port number for the subdomain) or False if the creation of the instance didnt work
"""
if execute_script(_var, _cmd, "add", clear_special(name)):
return True
port_starter = 10002
port = port_starter
for i in instace.list():
port =+ 1
if execute_script(_var, _cmd, "add", clear_special(name), port):
add_dns(name, port)
return int(port)
else:
return False
@@ -180,6 +191,38 @@ class instace:
counter += 1
return result
def backup(name: str) -> bool:
"""
Creates a backup of an instance with the subdomain [name].invario.eu
Input:
- name -> String
Output:
- bool if the backup works (True: backup worked; False: didnt work)
"""
import subprocess
try:
subprocess.run(
[
"docker",
"compose",
"-f",
"docker-compose-multitenant.yml",
"exec",
"-T",
"mongodb",
"mongodump",
f"--archive=/data/backups/inventar_{name}-$(date +%Y%m%d%H%M%S).gz",
"--gzip",
"--db",
f"inventar_{name}"
],
check=True
)
return True
except subprocess.CalledProcessError:
return False
class ussage:
"""
+11
View File
@@ -0,0 +1,11 @@
User-agent: *
Disallow: /admin/
Disallow: /my/
Disallow: /login
Disallow: /register
Disallow: /chat
Disallow: /tickets
Disallow: /appointments
Disallow: /static/uploads/
Allow: /
Sitemap: /sitemap.xml
+53
View File
@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>{{ SITE_ROOT }}</loc>
<changefreq>daily</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>{{ SITE_ROOT }}/dienstleistungen</loc>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>{{ SITE_ROOT }}/projekte</loc>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>{{ SITE_ROOT }}/inventarsystem</loc>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>{{ SITE_ROOT }}/team</loc>
<changefreq>weekly</changefreq>
<priority>0.6</priority>
</url>
<url>
<loc>{{ SITE_ROOT }}/kontakt</loc>
<changefreq>monthly</changefreq>
<priority>0.6</priority>
</url>
<url>
<loc>{{ SITE_ROOT }}/blog</loc>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>{{ SITE_ROOT }}/datenschutz</loc>
<changefreq>yearly</changefreq>
<priority>0.2</priority>
</url>
<url>
<loc>{{ SITE_ROOT }}/impressum</loc>
<changefreq>yearly</changefreq>
<priority>0.2</priority>
</url>
<url>
<loc>{{ SITE_ROOT }}/nutzungsbedingungen</loc>
<changefreq>yearly</changefreq>
<priority>0.2</priority>
</url>
</urlset>
+237 -76
View File
@@ -5,137 +5,298 @@
{% block head %}
{{ super() }}
<style>
.packages-hero {
background: linear-gradient(140deg, #f6f2e6 0%, #e8efe4 55%, #dfe9ef 100%);
border: 1px solid #ced8d1;
border-radius: 18px;
padding: 1.4rem;
box-shadow: 0 14px 34px rgba(16, 36, 47, 0.08);
margin-bottom: 1rem;
.booking-hero {
background: linear-gradient(135deg, rgba(28, 90, 99, 0.14), rgba(255, 255, 255, 0.65)),
linear-gradient(180deg, #fffefb 0%, #f2f5f2 100%);
border: 1px solid rgba(28, 90, 99, 0.16);
border-radius: 24px;
padding: 2.2rem;
margin-bottom: 1.8rem;
box-shadow: 0 18px 44px rgba(18, 49, 54, 0.08);
overflow: hidden;
position: relative;
}
.packages-hero h1 {
color: #173f57;
font-size: clamp(1.65rem, 3.2vw, 2.45rem);
margin-bottom: 0.35rem;
.booking-hero::before {
content: "";
position: absolute;
inset: 0;
background: radial-gradient(circle at top right, rgba(34, 98, 128, 0.16), transparent 36%);
pointer-events: none;
}
.booking-hero > * {
position: relative;
z-index: 1;
}
.booking-hero h1 {
margin-bottom: 0.65rem;
color: #133d4a;
font-size: clamp(2rem, 3.5vw, 3.2rem);
}
.booking-hero p {
margin: 0.75rem 0 0;
max-width: 780px;
color: #3b5f69;
font-size: clamp(1rem, 1.05vw, 1.1rem);
}
.packages-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.9rem;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1.4rem;
margin-bottom: 1.8rem;
}
.package-card {
background: #ffffff;
border: 1px solid #d5e0e8;
border-radius: 16px;
padding: 1rem;
box-shadow: 0 10px 24px rgba(23, 44, 57, 0.05);
border: 1px solid #dfe7e7;
border-radius: 22px;
box-shadow: 0 18px 40px rgba(23, 44, 57, 0.05);
padding: 1.8rem;
display: grid;
gap: 0.7rem;
gap: 1.2rem;
transition: transform 0.3s ease, box-shadow 0.3s ease, border-color 0.3s ease;
}
.package-name {
.package-card:hover {
transform: translateY(-3px);
border-color: #b2d4dc;
box-shadow: 0 24px 48px rgba(23, 44, 57, 0.1);
}
.package-card__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
flex-wrap: wrap;
}
.package-card__header > div {
min-width: 0;
}
.package-card__header > div:first-child {
flex: 1 1 0;
}
.package-card__tag {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.45rem 0.8rem;
border-radius: 999px;
background: #e6f5f8;
color: #106476;
font-size: 0.9rem;
font-weight: 700;
letter-spacing: 0.01em;
}
.package-card__name {
margin: 0;
color: #184560;
font-size: 1.35rem;
color: #143f46;
font-size: clamp(1.4rem, 2.5vw, 2rem);
line-height: 1.08;
}
.package-headline {
color: #3a6177;
font-weight: 600;
.package-card__price {
display: inline-flex;
align-items: baseline;
gap: 0.35rem;
font-weight: 700;
color: #102d37;
white-space: nowrap;
}
.package-card__price span {
font-size: clamp(2rem, 3vw, 2.75rem);
letter-spacing: -0.04em;
}
.package-card__price small {
color: #57717b;
font-size: 0.95rem;
}
.feature-list {
.package-card__description {
margin: 0;
padding-left: 1.1rem;
color: #456274;
display: grid;
gap: 0.35rem;
color: #4b6a72;
line-height: 1.75;
}
.package-actions {
display: flex;
flex-wrap: wrap;
gap: 0.55rem;
.package-card__features {
margin: 1rem 0 0;
padding: 0;
list-style: none;
display: grid;
gap: 0.75rem;
}
.package-card__features li {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.75rem;
align-items: flex-start;
color: #4f6a72;
font-size: 0.98rem;
}
.package-card__features li::before {
content: "✓";
color: #187490;
font-weight: 700;
line-height: 1.2;
}
.package-card__actions {
margin-top: 0.5rem;
}
.package-card__actions form,
.package-card__actions a {
width: 100%;
display: inline-flex;
}
.btn {
border: 1px solid #0d4f77;
background: linear-gradient(120deg, #0d6294 0%, #0b476a 100%);
width: 100%;
border: 1px solid #1c5a63;
background: linear-gradient(120deg, #1d6d79 0%, #164b55 100%);
color: #ffffff;
border-radius: 999px;
padding: 0.45rem 0.88rem;
padding: 0.95rem 1.2rem;
font-weight: 700;
cursor: pointer;
font: inherit;
text-align: center;
transition: transform 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
}
.btn:hover,
.btn:focus-visible {
transform: translateY(-1px);
box-shadow: 0 12px 24px rgba(28, 90, 99, 0.22);
}
.btn.secondary {
border-color: #9ab5c8;
background: #f2f8fc;
color: #275a78;
border-color: #c7dde3;
background: #f7fbfb;
color: #1d5f6f;
}
.notice {
margin-top: 1rem;
padding: 1.3rem 1.35rem;
border-radius: 20px;
background: #ffffff;
border: 1px solid #d4e0e9;
border-radius: 14px;
padding: 0.9rem;
color: #274f65;
border: 1px solid #dfe7ea;
color: #304f5a;
box-shadow: 0 12px 24px rgba(23, 44, 57, 0.04);
}
.notice strong {
color: #173f57;
color: #15444c;
}
@media (max-width: 980px) {
.packages-grid {
grid-template-columns: 1fr;
.package-card__tag + .package-card__name {
margin-top: 0.4rem;
}
@media (max-width: 760px) {
.booking-hero {
padding: 1.6rem;
}
.package-card {
padding: 1.4rem;
}
.package-card__header {
align-items: stretch;
}
.package-card__price {
justify-content: flex-start;
}
}
</style>
{% endblock %}
{% block content %}
<section class="packages-hero">
<h1>Buchungsoptionen als Softwarepakete</h1>
<p>Alle Pakete sind frei einsehbar, auch ohne Admin.</p>
<p>Jedes Paket basiert auf derselben Sicherheitsarchitektur mit Rollenrechten, geschützten Sessions und nachvollziehbaren Aktionen.</p>
<section class="booking-hero">
<h1>Buchungsoptionen entdecken</h1>
<p>Unsere Pakete sind klar strukturiert und transparent. Wählen Sie die passende Lösung für Ihre Schule und senden Sie die Anfrage direkt an unser Admin-Team.</p>
</section>
<section class="packages-grid">
{% for package in software_packages %}
<article class="package-card">
<h2 class="package-name">{{ package.name }}</h2>
<p class="package-headline">{{ package.headline }}</p>
<ul class="feature-list">
{% for feature in package.features %}
<li>{{ feature }}</li>
{% endfor %}
</ul>
<div class="package-actions">
{% if 'username' in session %}
<form method="POST" action="{{ url_for('book_option_package') }}">
<input type="hidden" name="package" value="{{ package.slug }}">
<button class="btn" type="submit">Option buchen</button>
</form>
{% else %}
<a class="btn secondary" href="{{ url_for('login') }}">Mit Konto buchen</a>
{% endif %}
<section class="packages-grid" aria-label="Buchungspakete">
<article class="package-card">
<div class="package-card__header">
<div>
<span class="package-card__tag">Beliebt</span>
<h2 class="package-card__name">Inventarsystem</h2>
</div>
</article>
{% endfor %}
<div class="package-card__price">
<span>250 €</span>
<small>/ Monat</small>
</div>
</div>
<p class="package-card__description">Der stabile Einstieg in die Schulverwaltung mit Inventarverwaltung, Benutzerrechten und Berichten für den gesamten Schulbetrieb.</p>
<ul class="package-card__features">
<li>Inventarverwaltung mit Rollenrechten</li>
<li>Basis-Support und Ticketing</li>
<li>Schulweite Übersichten und Berichte</li>
<li>Erweiterte Instanzverwaltung</li>
</ul>
<div class="package-card__actions">
{% if 'username' in session %}
<form method="POST" action="{{ url_for('book_option_package') }}">
<input type="hidden" name="package" value="inventarsystem">
<button class="btn" type="submit">Jetzt buchen</button>
</form>
{% else %}
<a class="btn secondary" href="{{ url_for('login') }}">Mit Konto buchen</a>
{% endif %}
</div>
</article>
<article class="package-card">
<div class="package-card__header">
<div>
<span class="package-card__tag">Modul</span>
<h2 class="package-card__name">Bibliothekssystem</h2>
</div>
<div class="package-card__price">
<span>150 €</span>
<small>/ Monat</small>
</div>
</div>
<p class="package-card__description">Ein spezialisiertes Modul für Bibliotheken und Medienzentren mit Ausleihprozessen, Medienbeständen und historischer Transparenz.</p>
<ul class="package-card__features">
<li>Ausleih- und Rückgabeprozesse</li>
<li>Bestandsübersichten für Medien</li>
<li>Transparente Historie pro Medium</li>
<li>Einfaches Modul-Setup für Schulen</li>
</ul>
<div class="package-card__actions">
{% if 'username' in session %}
<form method="POST" action="{{ url_for('book_option_package') }}">
<input type="hidden" name="package" value="buecherei">
<button class="btn" type="submit">Jetzt buchen</button>
</form>
{% else %}
<a class="btn secondary" href="{{ url_for('login') }}">Mit Konto buchen</a>
{% endif %}
</div>
</article>
</section>
<section class="notice">
{% if 'username' in session %}
<p><strong>Hinweis:</strong> Buchungsanfragen werden direkt vom Admin-Team verarbeitet und Ihrer Instanz zugeordnet.</p>
<p><strong>Hinweis:</strong> Deine Buchungsanfrage wird direkt vom Admin-Team bearbeitet und deiner Schulinstanz zugeordnet.</p>
{% else %}
<p><strong>Hinweis:</strong> Bitte einloggen oder registrieren, um Buchungsoptionen anzufragen.</p>
<p><strong>Hinweis:</strong> Bitte logge dich ein oder registriere dich, um eine Buchungsoption anzufragen.</p>
{% endif %}
</section>
{% endblock %}
+227 -30
View File
@@ -8,6 +8,21 @@
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<title>{% block title %}Digitale Schulverwaltung{% endblock %}</title>
{# Basic SEO meta tags, can be overridden per-template by setting `meta_description` #}
<meta name="description" content="{{ meta_description|default('Invario — Lehrmittel-, Inventar- und Ausleihverwaltung für Schulen. Sicher, einfach, nachvollziehbar.') }}">
<meta name="robots" content="index, follow">
<link rel="canonical" href="{{ request.url_root.rstrip('/') }}{{ request.path }}">
<!-- OpenGraph / Twitter -->
<meta property="og:site_name" content="Invario">
<meta property="og:title" content="{{ meta_title|default('Digitale Schulverwaltung') }}">
<meta property="og:description" content="{{ meta_description|default('Invario — Lehrmittel-, Inventar- und Ausleihverwaltung für Schulen. Sicher, einfach, nachvollziehbar.') }}">
<meta property="og:type" content="website">
<meta property="og:url" content="{{ request.url_root.rstrip('/') }}{{ request.path }}">
<meta property="og:image" content="{{ url_for('static', filename='images/logo-1.jpeg') }}">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{{ meta_title|default('Digitale Schulverwaltung') }}">
<meta name="twitter:description" content="{{ meta_description|default('Invario — Lehrmittel-, Inventar- und Ausleihverwaltung für Schulen. Sicher, einfach, nachvollziehbar.') }}">
<meta name="twitter:image" content="{{ url_for('static', filename='images/logo-1.jpeg') }}">
{% block head %}
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
@@ -115,21 +130,22 @@
gap: 1rem;
}
.brand {
display: inline-flex;
align-items: center;
gap: 0.62rem;
gap: 0.72rem;
font-family: "Merriweather", Georgia, serif;
font-size: 1.15rem;
font-size: 1.22rem;
font-weight: 700;
letter-spacing: 0.03em;
color: var(--brand-strong);
}
.brand img {
width: 38px;
height: 38px;
border-radius: 10px;
width: 48px;
height: 48px;
border-radius: 12px;
object-fit: cover;
border: 1px solid #bccdc4;
box-shadow: 0 8px 16px rgba(19, 49, 54, 0.14);
@@ -140,21 +156,56 @@
display: flex;
align-items: center;
gap: 1.1rem;
flex-wrap: wrap;
font-weight: 600;
color: var(--text-soft);
font-size: 0.98rem;
}
.nav-links a {
white-space: nowrap;
transition: background 0.2s ease, color 0.2s ease;
}
.nav-links a:hover {
color: var(--brand);
}
.nav-links .nav-user-link,
.nav-links .nav-mobile-action {
display: none;
}
.nav-actions {
display: flex;
align-items: center;
gap: 0.65rem;
}
.nav-toggle {
display: none;
border: none;
background: transparent;
color: var(--text-main);
cursor: pointer;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: 14px;
transition: background 0.2s ease;
}
.nav-toggle:hover,
.nav-toggle:focus-visible {
background: rgba(28, 90, 99, 0.08);
}
.nav-toggle svg {
width: 1.1rem;
height: 1.1rem;
}
.nav-btn {
display: inline-flex;
align-items: center;
@@ -325,25 +376,81 @@
@media (max-width: 820px) {
.site-nav-inner {
min-height: 64px;
flex-wrap: wrap;
align-items: center;
gap: 0.8rem;
}
.nav-toggle {
display: inline-flex;
}
.nav-links {
display: none;
position: absolute;
left: 50%;
top: calc(100% + 0.6rem);
transform: translateX(-50%) translateY(-10px);
width: min(100%, calc(100% - 1.5rem));
max-width: 520px;
background: rgba(255, 255, 255, 0.98);
border: 1px solid #d8dfdb;
border-radius: 20px;
padding: 0.9rem;
display: grid;
gap: 0.6rem;
z-index: 25;
opacity: 0;
visibility: hidden;
pointer-events: none;
box-shadow: 0 18px 40px rgba(23, 44, 57, 0.12);
transition: opacity 0.2s ease, transform 0.2s ease, visibility 0.2s ease;
}
.nav-links.open {
opacity: 1;
visibility: visible;
pointer-events: auto;
transform: translateX(-50%) translateY(0);
}
.nav-links a {
display: block;
width: 100%;
padding: 1rem 1rem;
border-radius: 16px;
background: #f7f5f0;
color: var(--text-main);
font-weight: 700;
}
.nav-links a:hover,
.nav-links a:focus-visible {
background: #e7f0ec;
}
.nav-links .nav-user-link,
.nav-links .nav-mobile-action {
display: block;
}
.nav-links .nav-user-link {
padding-left: 1rem;
border-left: 3px solid rgba(28, 90, 99, 0.18);
background: #f1f6f4;
}
.nav-links .nav-mobile-action {
border: 1px solid #bcd0de;
background: #ffffff;
color: #1b4b52;
}
.nav-actions {
gap: 0.4rem;
display: none;
}
.nav-btn {
padding: 0.42rem 0.72rem;
font-size: 0.85rem;
}
.dropdown-menu {
right: auto;
left: 0;
min-width: 200px;
.main-wrap {
padding: 1.4rem 0 2.2rem;
}
.cookie-banner {
@@ -358,28 +465,73 @@
.cookie-btn {
width: 100%;
}
.dropdown-menu {
right: auto;
left: 0;
min-width: 200px;
}
}
@media (max-width: 520px) {
.site-shell {
width: min(100%, calc(100% - 1rem));
}
.nav-links {
top: calc(100% + 0.6rem);
padding: 0.9rem;
}
.nav-actions {
justify-content: stretch;
}
.nav-actions .nav-btn {
width: 100%;
}
.nav-links .nav-user-link {
display: block;
}
}
</style>
{% endblock %}
</head>
<body>
<nav class="site-nav">
<nav class="site-nav" role="navigation" aria-label="Hauptnavigation">
<div class="site-shell site-nav-inner">
<a class="brand" href="{{ url_for('default') }}">
<img src="{{ url_for('static', filename='images/Logo-clear.png') }}" alt="Invario Logo">
<span>Invario</span>
</a>
<div class="nav-links" aria-label="Main Navigation">
<a href="{{ url_for('dienstleistungen') }}">Leistungen für Schulen</a>
<a href="{{ url_for('projekte') }}">Praxisbeispiele</a>
<a href="{{ url_for('team') }}">Team</a>
<button type="button" class="nav-toggle" id="mobileMenuToggle" aria-expanded="false" aria-controls="mainNav" aria-label="Navigation öffnen">
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M4 7h16M4 12h16M4 17h16" stroke="currentColor" stroke-width="2" stroke-linecap="round"></path>
</svg>
</button>
<div class="nav-links" id="mainNav" aria-label="Main Navigation" aria-hidden="true">
<a href="{{ url_for('default') }}">Start</a>
<a href="{{ url_for('dienstleistungen') }}">Für Schulen</a>
<a href="{{ url_for('kontakt') }}">Kontakt</a>
<a href="{{ url_for('inventarsystem') }}">Inventarsystem</a>
{% if 'username' in session %}
<a class="nav-user-link" href="{{ url_for('my_invoices') }}">Meine Rechnungen</a>
<a class="nav-user-link" href="{{ url_for('my_instance_management') }}">Meine Instanz</a>
<a class="nav-user-link" href="{{ url_for('user_chat') }}">Chat mit Admin</a>
<a class="nav-user-link" href="{{ url_for('user_tickets') }}">Support Tickets</a>
<a class="nav-user-link" href="{{ url_for('logout') }}">Abmelden</a>
{% if session.get('is_admin') %}
<a class="nav-user-link" href="{{ url_for('admin_dashboard') }}">Admin Dashboard</a>
<a class="nav-user-link" href="{{ url_for('admin_users') }}">Userverwaltung</a>
<a class="nav-user-link" href="{{ url_for('admin_invoices') }}">Rechnungsverwaltung</a>
{% endif %}
{% else %}
<a class="nav-mobile-action nav-btn primary" href="{{ url_for('login') }}">Login</a>
<a class="nav-mobile-action nav-btn" href="{{ url_for('register') }}">Registrieren</a>
{% endif %}
</div>
<div class="nav-actions">
{% if 'username' in session %}
<a class="nav-btn" href="{{ url_for('appointments') }}">Buchungsoption</a>
<details class="nav-dropdown">
<summary class="nav-btn">Nutzerbereich</summary>
<div class="dropdown-menu">
@@ -389,7 +541,6 @@
<a href="{{ url_for('user_tickets') }}">Support Tickets</a>
</div>
</details>
{% if session.get('is_admin') %}
<details class="nav-dropdown">
<summary class="nav-btn">Admin</summary>
@@ -406,17 +557,16 @@
</div>
</details>
{% endif %}
<a class="nav-btn primary" href="{{ url_for('appointments') }}">Buchungsoption</a>
<a class="nav-btn" href="{{ url_for('logout') }}">Logout</a>
{% else %}
<a class="nav-btn" href="{{ url_for('blog') }}">Neuigkeiten</a>
<a class="nav-btn" href="{{ url_for('login') }}">Login</a>
<a class="nav-btn primary" href="{{ url_for('register') }}">Registrieren</a>
<a class="nav-btn primary" href="{{ url_for('login') }}">Login</a>
<a class="nav-btn" href="{{ url_for('register') }}">Registrieren</a>
{% endif %}
</div>
</div>
</nav>
<main class="site-shell main-wrap">
<main class="site-shell main-wrap" role="main">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="flashes">
@@ -554,6 +704,53 @@
<script src="{{ url_for('static', filename='js/ios_fixes.js') }}"></script>
<script>
(function () {
var menuToggle = document.getElementById('mobileMenuToggle');
var navLinks = document.getElementById('mainNav');
if (menuToggle && navLinks) {
menuToggle.addEventListener('click', function () {
var isExpanded = this.getAttribute('aria-expanded') === 'true';
this.setAttribute('aria-expanded', String(!isExpanded));
navLinks.classList.toggle('open');
navLinks.setAttribute('aria-hidden', String(isExpanded));
});
navLinks.querySelectorAll('a').forEach(function (link) {
link.addEventListener('click', function () {
if (navLinks.classList.contains('open')) {
menuToggle.setAttribute('aria-expanded', 'false');
navLinks.classList.remove('open');
navLinks.setAttribute('aria-hidden', 'true');
}
});
});
document.addEventListener('click', function (event) {
if (!navLinks.contains(event.target) && !menuToggle.contains(event.target) && navLinks.classList.contains('open')) {
menuToggle.setAttribute('aria-expanded', 'false');
navLinks.classList.remove('open');
navLinks.setAttribute('aria-hidden', 'true');
}
});
navLinks.querySelectorAll('a').forEach(function (link) {
link.addEventListener('click', function () {
if (navLinks.classList.contains('open')) {
menuToggle.setAttribute('aria-expanded', 'false');
navLinks.classList.remove('open');
navLinks.setAttribute('aria-hidden', 'true');
}
});
});
window.addEventListener('resize', function () {
if (window.innerWidth > 820 && navLinks.classList.contains('open')) {
menuToggle.setAttribute('aria-expanded', 'false');
navLinks.classList.remove('open');
navLinks.removeAttribute('aria-hidden');
}
});
}
var banner = document.getElementById("cookieBanner");
var acceptBtn = document.getElementById("cookieAcceptBtn");
var declineBtn = document.getElementById("cookieDeclineBtn");
+146 -11
View File
@@ -1,5 +1,8 @@
{% extends "base.html" %}
{# SEO: per-page description used in base.html meta tags #}
{% set meta_description = "Invario bündelt Lehrmittelverwaltung, Inventar und Ausleihe für Schulen sicher, transparent und einfach zu bedienen." %}
{% block title %}Digitale Schulverwaltung mit Sicherheitsfokus{% endblock %}
{% block head %}
@@ -550,12 +553,73 @@
{% endblock %}
{% block content %}
<section class="hero" id="top">
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@graph": [
{
"@type": "WebSite",
"@id": "{{ request.url_root.rstrip('/') }}#website",
"url": "{{ request.url_root.rstrip('/') }}",
"name": "Invario",
"description": "{{ meta_description }}",
"inLanguage": "de-DE",
"publisher": { "@id": "{{ request.url_root.rstrip('/') }}#organization" },
"potentialAction": {
"@type": "ContactAction",
"target": "{{ request.url_root.rstrip('/') }}/kontakt"
}
},
{
"@type": "Organization",
"@id": "{{ request.url_root.rstrip('/') }}#organization",
"name": "Invario",
"url": "{{ request.url_root.rstrip('/') }}",
"logo": {
"@type": "ImageObject",
"url": "{{ url_for('static', filename='images/logo-1.jpeg') }}"
},
"contactPoint": [
{
"@type": "ContactPoint",
"contactType": "customer support",
"url": "{{ request.url_root.rstrip('/') }}/kontakt",
"availableLanguage": ["de", "en"]
}
]
},
{
"@type": "SoftwareApplication",
"@id": "{{ request.url_root.rstrip('/') }}#software",
"name": "Invario",
"url": "{{ request.url_root.rstrip('/') }}",
"description": "{{ meta_description }}",
"operatingSystem": "Web",
"applicationCategory": "EducationApplication",
"softwareVersion": "1.0",
"image": "{{ url_for('static', filename='images/logo-1.jpeg') }}",
"author": { "@id": "{{ request.url_root.rstrip('/') }}#organization" },
"publisher": { "@id": "{{ request.url_root.rstrip('/') }}#organization" },
"inLanguage": "de-DE"
},
{
"@type": "WebPage",
"@id": "{{ request.url_root.rstrip('/') }}#webpage",
"url": "{{ request.url_root.rstrip('/') }}",
"name": "Invario Startseite",
"description": "{{ meta_description }}",
"isPartOf": { "@id": "{{ request.url_root.rstrip('/') }}#website" },
"mainEntity": { "@id": "{{ request.url_root.rstrip('/') }}#software" }
}
]
}
</script>
<header class="hero" id="top" aria-label="Seitenstart">
<div>
<span class="eyebrow">Lehrmittelverwaltungssoftware für Schulen</span>
<h1>Lehrmittel, Schul-IT und Ausleihen in einem sicheren System.</h1>
<h1>Invario ist die sichere Schulsoftware für Lehrmittelverwaltung, Inventar und Ausleihe.</h1>
<p>
Invario ist eine Lehrmittelverwaltungssoftware, die Schulbibliothek, Inventar und digitale Ausleihe zusammenführt. Prozesse bleiben bewusst schlank und schnell, während Sicherheitsfunktionen wie rollenbasierte Rechte, Sitzungsabsicherung und nachvollziehbare Protokolle im Hintergrund dauerhaft aktiv sind.
Invario hilft Schulen, Lehrmittel, Medien und Ausleihprozesse übersichtlich, digital und DSGVO-konform zu organisieren. Die Oberfläche bleibt für Lehrkräfte und Bibliothekspersonal verständlich, während Sicherheitsfunktionen im Hintergrund für Verlässlichkeit sorgen.
</p>
<div class="cta-row">
{% if 'username' in session %}
@@ -563,9 +627,7 @@
{% else %}
<a class="btn btn-primary" href="{{ url_for('login') }}">Buchungsoption nutzen</a>
{% endif %}
<a class="btn btn-secondary" href="{{ url_for('dienstleistungen') }}">Leistungen ansehen</a>
<a class="btn btn-secondary" href="{{ url_for('team') }}">Team ansehen</a>
<a class="btn btn-secondary" href="{{ url_for('kontakt') }}">Direkt kontaktieren</a>
<a class="btn btn-secondary" href="{{ url_for('kontakt') }}">Kontakt aufnehmen</a>
</div>
</div>
<div class="hero-right">
@@ -596,10 +658,75 @@
</div>
</aside>
</div>
</header>
<section class="primary-focus" id="zielgruppen" aria-label="Zielgruppen">
<div class="primary-focus-content">
<span class="eyebrow">Für wen ist das?</span>
<h2>Passend für jede Schulform</h2>
<div class="primary-points">
<span>Grundschule: Übersicht für Klassen 14</span>
<span>Weiterführende Schulen: Effizienz für Klassen 513</span>
<span>Oberschulen: Verwaltung für FOS/BOS</span>
</div>
</div>
</section>
<section class="section" id="ablauf" style="animation-delay: 100ms;">
<h2>So verläuft die Zusammenarbeit</h2>
<p>Ein klarer Prozess sorgt für Sicherheit, besonders bei begrenzten Zeitfenstern im Schulbetrieb.</p>
<div class="process" aria-label="Ablauf in fünf Schritten">
<div class="step"><strong>1. Kennenlernen</strong><small>Ziele klären</small></div>
<div class="step"><strong>2. Struktur</strong><small>Bestehende Abläufe erfassen</small></div>
<div class="step"><strong>3. Einrichtung</strong><small>System passgenau einstellen</small></div>
<div class="step"><strong>4. Schulung</strong><small>Praxisnah einführen</small></div>
<div class="step"><strong>5. Betrieb</strong><small>Persönlich begleiten</small></div>
</div>
</section>
<section class="section" id="nutzen" style="animation-delay: 180ms;">
<h2>Nutzen-Inseln aus dem Schulalltag</h2>
<div class="section-grid">
<article class="card">
<h3>Bücher sicher ausgeben und zurücknehmen</h3>
<p>Mit Barcode oder QR-Code werden Bücher und Materialien schnell und nachvollziehbar ausgegeben und zurückgenommen ohne Zettelwirtschaft.</p>
</article>
<article class="card">
<h3>Den Überblick über Laptops und iPads behalten</h3>
<p>IT-Geräte, Tablets und Medien werden gemeinsam verwaltet. Verantwortlichkeiten und Rückgaben sind jederzeit einsehbar.</p>
</article>
<article class="card">
<h3>Lehrmittel für die Unterrichtsplanung</h3>
<p>Lehrkräfte planen Materialbedarf im Voraus und vermeiden so Engpässe und Doppelbestellungen.</p>
</article>
</div>
</section>
<section class="section" id="sicherheit" style="animation-delay: 220ms;">
<h2>Datenschutz & Sicherheit</h2>
<div class="section-grid">
<article class="card">
<h3>Rollen- und Bereichstrennung</h3>
<p>Bibliothek, Verwaltung und IT erhalten abgestufte Rechte, sodass jede Rolle nur ihre benötigten Funktionen sieht.</p>
</article>
<article class="card">
<h3>Protokollierung und Nachweis</h3>
<p>Ausgaben, Rücknahmen und kritische Admin-Aktionen werden dokumentiert und sind jederzeit auditierbar.</p>
</article>
<article class="card">
<h3>Gesicherte Web-Session</h3>
<p>Sicherheits-Header, HttpOnly-Cookies und klare Session-Richtlinien schützen den laufenden Betrieb vor typischen Webangriffen.</p>
</article>
</div>
</section>
<section class="section" id="hilfe" style="animation-delay: 240ms;">
<h2>Wir lassen Sie nicht allein</h2>
<p>Sie erhalten persönlichen Support per E-Mail, über den Chat mit einem Administrator oder für die Einrichtung vor Ort. So bleibt keine Frage offen und die Hemmschwelle vor der Digitalisierung sinkt.</p>
</section>
<section class="testimonials">
<h2>Das sagen unsere Schulen</h2>
<h2>Das sagen andere Schulleitungen</h2>
<div class="testimonials-grid">
<div class="testimonial-card">
<div class="testimonial-stars">
@@ -609,7 +736,7 @@
<span class="star"></span>
<span class="star"></span>
</div>
<p class="testimonial-text">"Mit Invario haben wir unsere Lehrmittelausleihe um 70% beschleunigt. Die Schüler wissen sofort, welche Materialien verfügbar sind, ohne die Bibliothek zu stören."</p>
<p class="testimonial-text">"Mit Invario haben wir unsere Lehrmittelausleihe um 70% beschleunigt. Die Oberfläche ist intuitiv und unsere Bibliothekare sparen täglich Zeit."</p>
<div class="testimonial-author">
<div class="testimonial-avatar">SR</div>
<div class="testimonial-info">
@@ -667,7 +794,7 @@
<span>Kinderleichte Bedienung</span>
</div>
<div class="cta-row">
<a class="btn btn-primary" href="{{ url_for('inventarsystem') }}">Module ansehen</a>
<a class="btn btn-primary" href="{{ url_for('inventarsystem') }}">Grundschul-Modul ansehen</a>
<a class="btn btn-secondary" href="{{ url_for('kontakt') }}">Beratung für Grundschule</a>
</div>
</div>
@@ -685,7 +812,7 @@
<span>Kinderleichte Bedienung</span>
</div>
<div class="cta-row">
<a class="btn btn-primary" href="{{ url_for('inventarsystem') }}">Module ansehen</a>
<a class="btn btn-primary" href="{{ url_for('inventarsystem') }}">Modul ansehen</a>
<a class="btn btn-secondary" href="{{ url_for('kontakt') }}">Beratung für Weiterführende Schulen</a>
</div>
</div>
@@ -703,7 +830,7 @@
<span>Kinderleichte Bedienung</span>
</div>
<div class="cta-row">
<a class="btn btn-primary" href="{{ url_for('inventarsystem') }}">Module ansehen</a>
<a class="btn btn-primary" href="{{ url_for('inventarsystem') }}">Modul ansehen</a>
<a class="btn btn-secondary" href="{{ url_for('kontakt') }}">Beratung für Oberschulen</a>
</div>
</div>
@@ -798,4 +925,12 @@
<a class="btn btn-secondary" href="{{ url_for('login') }}">Buchungsoption nutzen</a>
{% endif %}
</section>
<section class="contact" id="kontakt">
<div>
<h2>Sie sind Schulträger?</h2>
<p>In einer Persönlichen Präsentation klären wir Ihre Anforderungen und prüfen in Zusammenarbeit wie Invario ihren Digitalisierungsvorgang in ihren Schulen unterstützen kann.</p>
</div>
<a class="btn btn-secondary" href="{{ url_for('kontakt') }}">Beratungstermin vereinbaren</a>
</section>
{% endblock %}
+33 -17
View File
@@ -81,12 +81,19 @@ configure_nginx_site() {
echo "Nutze HTTPS nginx Template fuer $domain."
if [ ! -f "$template_file" ]; then
echo "Fehler: nginx Template nicht gefunden: $template_file"
exit 1
echo "[FEHLER] nginx Template nicht gefunden: $template_file" >&2
echo "[HINWEIS] Prüfe, ob die Datei existiert und korrekt benannt ist." >&2
exit 11
fi
render_nginx_template "$template_file" "$site_file" "$domain" "$upstream_port" "$cert_file" "$key_file"
sudo ln -sf "$site_file" "$site_link"
if ! render_nginx_template "$template_file" "$site_file" "$domain" "$upstream_port" "$cert_file" "$key_file"; then
echo "[FEHLER] nginx Template konnte nicht gerendert werden: $template_file" >&2
exit 12
fi
if ! sudo ln -sf "$site_file" "$site_link"; then
echo "[FEHLER] Symlink für nginx Site konnte nicht erstellt werden: $site_link" >&2
exit 13
fi
}
ensure_ssl_certificate() {
@@ -95,25 +102,30 @@ ensure_ssl_certificate() {
local key_file="$3"
if ! command -v openssl >/dev/null 2>&1; then
echo "Fehler: openssl ist nicht installiert."
exit 1
echo "[FEHLER] openssl ist nicht installiert. Bitte installiere openssl." >&2
exit 21
fi
sudo mkdir -p "$CERT_DIR"
if [ -f "$cert_file" ] && [ -f "$key_file" ]; then
echo "SSL Zertifikat gefunden fuer $domain: $cert_file"
return
if sudo openssl x509 -in "$cert_file" -noout >/dev/null 2>&1; then
echo "SSL Zertifikat gefunden fuer $domain: $cert_file"
return
else
echo "[WARNUNG] Zertifikat $cert_file ist beschädigt oder ungültig. Erstelle neu..."
sudo rm -f "$cert_file" "$key_file"
fi
fi
echo "Kein SSL Zertifikat fuer $domain gefunden. Erzeuge self-signed Zertifikat..."
echo "Kein gültiges SSL Zertifikat für $domain gefunden. Erzeuge self-signed Zertifikat..."
if ! sudo openssl req -x509 -nodes -newkey rsa:2048 -sha256 \
-days "$SSL_CERT_DAYS" \
-keyout "$key_file" \
-out "$cert_file" \
-subj "/CN=$domain" \
-addext "subjectAltName=DNS:$domain"; then
# Fallback fuer aeltere openssl-Versionen ohne -addext.
# Fallback für ältere openssl-Versionen ohne -addext.
sudo openssl req -x509 -nodes -newkey rsa:2048 -sha256 \
-days "$SSL_CERT_DAYS" \
-keyout "$key_file" \
@@ -123,30 +135,34 @@ ensure_ssl_certificate() {
sudo chmod 600 "$key_file"
sudo chmod 644 "$cert_file"
echo "[INFO] Selbstsigniertes Zertifikat für $domain wurde erstellt: $cert_file"
}
reload_nginx() {
echo "Pruefe nginx Konfiguration..."
sudo nginx -t
if ! sudo nginx -t; then
echo "[FEHLER] nginx Konfiguration fehlerhaft! Bitte prüfe die Ausgaben oben." >&2
exit 31
fi
if sudo pgrep -x nginx >/dev/null 2>&1; then
echo "Nginx Prozess gefunden, lade Konfiguration neu..."
if ! sudo nginx -s reload; then
echo "Warnung: nginx konnte nicht automatisch neu geladen werden."
echo "[WARNUNG] nginx konnte nicht automatisch neu geladen werden."
echo "Die Konfiguration wurde geschrieben; bitte nginx bei Bedarf manuell neu laden."
fi
elif sudo systemctl is-active --quiet nginx; then
echo "Lade nginx neu..."
if ! sudo systemctl reload nginx; then
echo "Warnung: nginx reload via systemd fehlgeschlagen."
echo "Bitte pruefe: sudo systemctl status nginx.service"
echo "[WARNUNG] nginx reload via systemd fehlgeschlagen."
echo "Bitte prüfe: sudo systemctl status nginx.service"
fi
else
echo "Nginx ist nicht aktiv, starte nginx..."
if ! sudo systemctl start nginx; then
echo "Warnung: Nginx konnte nicht ueber systemd gestartet werden."
echo "Falls nginx bereits manuell laeuft, kann die Weiterleitung trotzdem funktionieren."
echo "Ansonsten pruefe: sudo systemctl status nginx.service"
echo "[WARNUNG] Nginx konnte nicht über systemd gestartet werden."
echo "Falls nginx bereits manuell läuft, kann die Weiterleitung trotzdem funktionieren."
echo "Ansonsten prüfe: sudo systemctl status nginx.service"
echo "Und Logs mit: sudo journalctl -xeu nginx.service"
fi
fi
-107
View File
@@ -1,107 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
RUN_START=1
RUN_NGINX=1
RUN_HOSTS_SYNC=1
RUN_AUTOSTART=1
usage() {
cat <<'EOF'
Usage: sudo ./setup-first-install.sh [options]
Options:
--skip-start Skip stack start via gitea.sh
--skip-nginx Skip nginx apply via nginx.sh
--skip-hosts-sync Skip installation of invario-hosts-sync.service
--skip-autostart Skip installation of invario-stack-autostart.service
-h, --help Show this help
Default behavior:
1) Start stack
2) Apply nginx
3) Install hosts-sync service
4) Install stack-autostart service
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--skip-start)
RUN_START=0
shift
;;
--skip-nginx)
RUN_NGINX=0
shift
;;
--skip-hosts-sync)
RUN_HOSTS_SYNC=0
shift
;;
--skip-autostart)
RUN_AUTOSTART=0
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown option: $1"
usage
exit 1
;;
esac
done
if [[ "${EUID}" -ne 0 ]]; then
echo "This script must be run as root. Use: sudo $0"
exit 1
fi
require_script() {
local file="$1"
if [[ ! -f "$file" ]]; then
echo "Missing required file: $file"
exit 1
fi
if [[ ! -x "$file" ]]; then
chmod +x "$file"
fi
}
require_script "$SCRIPT_DIR/gitea.sh"
require_script "$SCRIPT_DIR/nginx.sh"
require_script "$SCRIPT_DIR/setup-hosts-sync.sh"
require_script "$SCRIPT_DIR/setup-stack-autostart.sh"
echo "Running first installation setup in: $SCRIPT_DIR"
action_or_skip() {
local flag="$1"
local title="$2"
shift 2
if [[ "$flag" -eq 1 ]]; then
echo "[RUN ] $title"
"$@"
else
echo "[SKIP] $title"
fi
}
action_or_skip "$RUN_START" "Start stack (gitea.sh start)" "$SCRIPT_DIR/gitea.sh" start
action_or_skip "$RUN_NGINX" "Apply nginx (nginx.sh apply)" "$SCRIPT_DIR/nginx.sh" apply
action_or_skip "$RUN_HOSTS_SYNC" "Install hosts-sync service" "$SCRIPT_DIR/setup-hosts-sync.sh"
action_or_skip "$RUN_AUTOSTART" "Install stack-autostart service" "$SCRIPT_DIR/setup-stack-autostart.sh"
echo
echo "First installation setup completed."
echo "Service status checks:"
echo " systemctl status invario-hosts-sync"
echo " systemctl status invario-stack-autostart"
echo "Stack status:"
echo " $SCRIPT_DIR/gitea.sh status"