Merge remote-tracking branch 'refs/remotes/origin/main'
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ services:
|
||||
mem_reservation: 192m
|
||||
environment:
|
||||
MONGO_INITDB_DATABASE: Invario_Website
|
||||
ports:
|
||||
- "27017:27017"
|
||||
volumes:
|
||||
- mongo_data:/data/db
|
||||
healthcheck:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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
@@ -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
@@ -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 1–4</span>
|
||||
<span>Weiterführende Schulen: Effizienz für Klassen 5–13</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 %}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user