#!/usr/bin/env bash set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ENV_FILE="${ROOT_DIR}/.env" ENV_EXAMPLE="${ROOT_DIR}/.env.example" # ── Colors ────────────────────────────────────────────── BOLD='\033[1m' DIM='\033[2m' GREEN='\033[32m' BLUE='\033[34m' YELLOW='\033[33m' RED='\033[31m' NC='\033[0m' section() { echo -e "\n${BOLD}${BLUE}${*}${NC}"; } success() { echo -e "${GREEN}✓${NC} ${*}"; } info() { echo -e " ${DIM}${*}${NC}"; } warn() { echo -e "${YELLOW}⚠${NC} ${*}"; } error() { echo -e "${RED}✗${NC} ${*}" >&2; } # ── Helpers ────────────────────────────────────────────── random() { openssl rand -base64 48 | tr -dc 'A-Za-z0-9' | head -c "${1:-32}" } random_password() { openssl rand -base64 48 | tr -dc 'A-Za-z0-9@%+=_.-' | head -c "${1:-20}" } ask() { local label="$1"; local default="${2:-}"; local answer if [[ -n "${default}" ]]; then read -r -p " ${label} [${default}]: " answer else read -r -p " ${label}: " answer fi echo "${answer:-${default}}" } ask_yn() { local label="$1"; local default_yes="${2:-true}"; local hint answer [[ "${default_yes}" == "true" ]] && hint="J/n" || hint="j/N" while true; do read -r -p " ${label} [${hint}]: " answer answer="$(echo "${answer}" | tr '[:upper:]' '[:lower:]')" [[ -z "${answer}" && "${default_yes}" == "true" ]] && return 0 [[ -z "${answer}" && "${default_yes}" == "false" ]] && return 1 [[ "${answer}" =~ ^(j|ja|y|yes)$ ]] && return 0 [[ "${answer}" =~ ^(n|nein|no)$ ]] && return 1 echo " → Bitte mit j oder n antworten." done } ask_choice() { local label="$1"; local default="$2"; shift 2; local options=("$@"); local value while true; do value="$(ask "${label} (${options[*]})" "${default}")" value="$(echo "${value}" | tr '[:upper:]' '[:lower:]')" for opt in "${options[@]}"; do [[ "${value}" == "${opt}" ]] && { echo "${value}"; return; } done echo " → Erlaubt: ${options[*]}" done } get_env() { local key="$1" if [[ ! -f "${ENV_FILE}" ]]; then echo ""; return; fi local line val line="$(grep -E "^${key}=" "${ENV_FILE}" | head -n1 || true)" [[ -z "${line}" ]] && { echo ""; return; } val="${line#*=}" val="${val#\"}"; val="${val%\"}" val="${val#\'}"; val="${val%\'}" echo "${val}" } set_env() { local key="$1"; local value="$2" if grep -qE "^${key}=" "${ENV_FILE}" 2>/dev/null; then sed -i "s|^${key}=.*|${key}=${value}|" "${ENV_FILE}" else echo "${key}=${value}" >> "${ENV_FILE}" fi } is_placeholder() { local val; val="$(echo "${1:-}" | tr '[:upper:]' '[:lower:]')" [[ -z "${val}" ]] && return 0 [[ "${val}" == change_me* ]] && return 0 [[ "${val}" == *random-secret* ]] && return 0 [[ "${val}" == *random-key* ]] && return 0 [[ "${val}" == *random-salt* ]] && return 0 [[ "${val}" == bitte-* ]] && return 0 return 1 } mask() { local val="${1:-}" [[ ${#val} -le 8 ]] && { echo "********"; return; } echo "${val:0:4}...${val: -4}" } urlencode() { local s="${1:-}"; local len="${#s}"; local out="" char c for ((i=0; i/dev/null 2>&1; } # ── Prerequisites ──────────────────────────────────────── section "[1/5] Voraussetzungen prüfen" if ! check_cmd docker; then error "Docker ist nicht installiert. Bitte installiere Docker zuerst." info "→ https://docs.docker.com/engine/install/" exit 1 fi if ! docker info >/dev/null 2>&1; then error "Docker-Daemon läuft nicht oder du hast keine Zugriffsrechte." exit 1 fi success "Docker läuft" if ! check_cmd openssl; then error "openssl wird benötigt (für Secret-Generierung)." exit 1 fi COMPOSE_CMD="docker compose" if ! docker compose version >/dev/null 2>&1; then if check_cmd docker-compose; then COMPOSE_CMD="docker-compose" else error "Weder 'docker compose' noch 'docker-compose' gefunden." exit 1 fi fi success "Compose verfügbar (${COMPOSE_CMD})" # ── Initialise .env ────────────────────────────────────── ENV_EXISTS=false [[ -f "${ENV_FILE}" ]] && ENV_EXISTS=true if ${ENV_EXISTS}; then if ask_yn ".env existiert bereits. Werte übernehmen?" "true"; then info "Bestehende .env wird erweitert/aktualisiert." else cp "${ENV_EXAMPLE}" "${ENV_FILE}" info "Neue .env aus Vorlage erstellt." NEW_ENV=true fi else cp "${ENV_EXAMPLE}" "${ENV_FILE}" info ".env aus Vorlage erstellt." NEW_ENV=true fi # ── [2/5] Base config ──────────────────────────────────── section "[2/5] Basis-Konfiguration" if ${ENV_EXISTS} && ! is_placeholder "$(get_env PUBLIC_URL)"; then _existing_url="$(get_env PUBLIC_URL)" else _existing_url="http://localhost:3000" fi while true; do PUBLIC_URL="$(ask "Öffentliche URL der App" "${_existing_url}")" if [[ "${PUBLIC_URL}" =~ ^https?:// ]]; then break fi echo " → Bitte mit http:// oder https:// beginnen." done STACK_NAME="$(ask "Container-Präfix (für docker ps)" "$(get_env STACK_NAME)")" [[ -z "${STACK_NAME}" ]] && STACK_NAME="calbook" TIMEZONE="$(ask "Zeitzone" "$(get_env DEFAULT_TIMEZONE)")" [[ -z "${TIMEZONE}" ]] && TIMEZONE="Europe/Berlin" DEPLOYMENT_MODE="$(ask_choice "Deployment-Modus" \ "$([[ "$(get_env DEPLOYMENT_MODE)" == "proxy" ]] && echo "proxy" || echo "direct")" \ "direct" "proxy")" if [[ "${DEPLOYMENT_MODE}" == "direct" ]]; then COMPOSE_FILE="${ROOT_DIR}/docker-compose.direct.yml" TRUST_PROXY="false" else COMPOSE_FILE="${ROOT_DIR}/docker-compose.proxy.yml" TRUST_PROXY="true" TRAEFIK_HOST="$(echo "${PUBLIC_URL}" | sed -E 's#^[a-zA-Z]+://##' | cut -d/ -f1 | cut -d: -f1)" if [[ -z "${TRAEFIK_HOST}" || "${TRAEFIK_HOST}" == "localhost" || "${TRAEFIK_HOST}" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then TRAEFIK_HOST="calbook.local" fi TRAEFIK_HOST="$(ask "Domain für Traefik (aus URL abgeleitet)" "${TRAEFIK_HOST}")" [[ -z "${TRAEFIK_HOST}" ]] && TRAEFIK_HOST="calbook.local" TRAEFIK_ENTRYPOINTS="$(ask "Traefik Entrypoints" "$(get_env TRAEFIK_ENTRYPOINTS)")" [[ -z "${TRAEFIK_ENTRYPOINTS}" ]] && TRAEFIK_ENTRYPOINTS="websecure" if [[ "${PUBLIC_URL}" == https://* ]]; then TRAEFIK_TLS="true" else TRAEFIK_TLS="false" fi TRAEFIK_TLS="$(ask_yn "TLS aktivieren?" "${TRAEFIK_TLS}" && echo "true" || echo "false")" if [[ "${TRAEFIK_TLS}" == "true" ]]; then TRAEFIK_CERTRESOLVER="$(ask "Certresolver" "$(get_env TRAEFIK_CERTRESOLVER)")" [[ -z "${TRAEFIK_CERTRESOLVER}" ]] && TRAEFIK_CERTRESOLVER="tls_resolver" else TRAEFIK_CERTRESOLVER="$(get_env TRAEFIK_CERTRESOLVER)" [[ -z "${TRAEFIK_CERTRESOLVER}" ]] && TRAEFIK_CERTRESOLVER="tls_resolver" fi TRAEFIK_NETWORK="$(ask "Traefik Docker-Netzwerk" "$(get_env TRAEFIK_DOCKER_NETWORK)")" [[ -z "${TRAEFIK_NETWORK}" ]] && TRAEFIK_NETWORK="proxy" TRAEFIK_ROUTER="$(ask "Traefik Router-Name" "$(get_env TRAEFIK_ROUTER_NAME)")" [[ -z "${TRAEFIK_ROUTER}" ]] && TRAEFIK_ROUTER="calbook" TRAEFIK_SERVICE="$(ask "Traefik Service-Name" "$(get_env TRAEFIK_SERVICE_NAME)")" [[ -z "${TRAEFIK_SERVICE}" ]] && TRAEFIK_SERVICE="calbook" fi success "URL: ${PUBLIC_URL} | Stack: ${STACK_NAME} | Modus: ${DEPLOYMENT_MODE}" # ── [3/5] Admin ────────────────────────────────────────── section "[3/5] Admin-Zugang" ADMIN_EMAIL="$(ask "Admin E-Mail" "$(get_env ADMIN_EMAIL)")" [[ -z "${ADMIN_EMAIL}" ]] && ADMIN_EMAIL="admin@calbook.local" ADMIN_NAME="$(ask "Admin Name" "$(get_env ADMIN_NAME)")" [[ -z "${ADMIN_NAME}" ]] && ADMIN_NAME="CalBook Admin" existing_pw="$(get_env ADMIN_PASSWORD)" if ${ENV_EXISTS} && ! is_placeholder "${existing_pw}"; then if ask_yn "Admin-Passwort beibehalten?" "true"; then ADMIN_PASSWORD="${existing_pw}" else ADMIN_PASSWORD="$(ask "Neues Admin-Passwort (min. 12 Zeichen)" "")" [[ -z "${ADMIN_PASSWORD}" ]] && ADMIN_PASSWORD="$(random_password 20)" fi else ADMIN_PASSWORD="$(ask "Admin-Passwort (leer = automatisch generieren)" "")" [[ -z "${ADMIN_PASSWORD}" ]] && ADMIN_PASSWORD="$(random_password 20)" fi success "Admin: ${ADMIN_EMAIL}" # ── [4/5] SMTP & Jitsi ─────────────────────────────────── section "[4/5] E-Mail & Videokonferenz" SMTP_MODE="$(ask_choice "SMTP-Modus" \ "$([[ "$(get_env SMTP_HOST)" == "mailhog" || -z "$(get_env SMTP_HOST)" ]] && echo "mailhog" || echo "custom")" \ "mailhog" "custom" "off")" if [[ "${SMTP_MODE}" == "mailhog" ]]; then SMTP_HOST="mailhog" SMTP_PORT="1025" SMTP_USER="" SMTP_PASS="" elif [[ "${SMTP_MODE}" == "custom" ]]; then SMTP_HOST="$(ask "SMTP-Host" "$(get_env SMTP_HOST)")" SMTP_PORT="$(ask "SMTP-Port" "$(get_env SMTP_PORT)")" [[ -z "${SMTP_PORT}" ]] && SMTP_PORT="587" SMTP_USER="$(ask "SMTP-Benutzer (optional)" "$(get_env SMTP_USER)")" SMTP_PASS="$(ask "SMTP-Passwort (optional)" "$(get_env SMTP_PASS)")" else SMTP_HOST="" SMTP_PORT="587" SMTP_USER="" SMTP_PASS="" fi SMTP_FROM_NAME="$(ask "Absendername" "$(get_env SMTP_FROM_NAME)")" [[ -z "${SMTP_FROM_NAME}" ]] && SMTP_FROM_NAME="${ADMIN_NAME}" SMTP_FROM="$(ask "Absender-E-Mail" "$(get_env SMTP_FROM)")" [[ -z "${SMTP_FROM}" ]] && SMTP_FROM="no-reply@calbook.local" JITSI_MODE="$(ask_choice "Jitsi-Modus" "$(get_env JITSI_MEETING_MODE)" "public" "custom")" if [[ "${JITSI_MODE}" == "custom" ]]; then JITSI_URL="$(ask "Jitsi-Basis-URL" "$(get_env JITSI_BASE_URL)")" else JITSI_URL="https://meet.jit.si" fi JITSI_PREFIX="$(ask "Jitsi-Raum-Präfix" "$(get_env JITSI_ROOM_PREFIX)")" [[ -z "${JITSI_PREFIX}" ]] && JITSI_PREFIX="calbook" success "SMTP: ${SMTP_MODE} | Jitsi: ${JITSI_MODE}" # ── Generate secrets ───────────────────────────────────── NEXTAUTH_SECRET="$(random 64)" CRON_SECRET="$(random 48)" CALDAV_KEY="$(random 64)" JITSI_SALT="$(random 48)" POSTGRES_PASSWORD="$(random_password 24)" # Keep existing strong secrets if present _secret_keys=(NEXTAUTH_SECRET CRON_SECRET CALDAV_ENCRYPTION_KEY JITSI_ROOM_SALT POSTGRES_PASSWORD) for key in "${_secret_keys[@]}"; do existing="$(get_env "${key}")" if [[ -n "${existing}" ]] && ! is_placeholder "${existing}"; then declare "${key}=${existing}" fi done POSTGRES_DB="$(get_env POSTGRES_DB)" [[ -z "${POSTGRES_DB}" ]] && POSTGRES_DB="calbook" POSTGRES_USER="$(get_env POSTGRES_USER)" [[ -z "${POSTGRES_USER}" ]] && POSTGRES_USER="calbook" DATABASE_URL="postgresql://$(urlencode "${POSTGRES_USER}"):$(urlencode "${POSTGRES_PASSWORD}")@db:5432/$(urlencode "${POSTGRES_DB}")?schema=public" # ── Write .env ─────────────────────────────────────────── set_env "STACK_NAME" "${STACK_NAME}" set_env "DEPLOYMENT_MODE" "${DEPLOYMENT_MODE}" set_env "PUBLIC_URL" "${PUBLIC_URL}" set_env "NEXTAUTH_URL" "${PUBLIC_URL}" set_env "APP_BASE_URL" "${PUBLIC_URL}" set_env "NEXTAUTH_SECRET" "${NEXTAUTH_SECRET}" set_env "CRON_SECRET" "${CRON_SECRET}" set_env "TRUST_PROXY_HEADERS" "${TRUST_PROXY}" set_env "DEFAULT_TIMEZONE" "${TIMEZONE}" set_env "ADMIN_NAME" "${ADMIN_NAME}" set_env "ADMIN_EMAIL" "${ADMIN_EMAIL}" set_env "ADMIN_PASSWORD" "${ADMIN_PASSWORD}" set_env "POSTGRES_DB" "${POSTGRES_DB}" set_env "POSTGRES_USER" "${POSTGRES_USER}" set_env "POSTGRES_PASSWORD" "${POSTGRES_PASSWORD}" set_env "DATABASE_URL" "${DATABASE_URL}" set_env "CALDAV_ENCRYPTION_KEY" "${CALDAV_KEY}" set_env "JITSI_ROOM_SALT" "${JITSI_SALT}" set_env "SMTP_HOST" "${SMTP_HOST}" set_env "SMTP_PORT" "${SMTP_PORT}" set_env "SMTP_USER" "${SMTP_USER}" set_env "SMTP_PASS" "${SMTP_PASS}" set_env "SMTP_FROM_NAME" "${SMTP_FROM_NAME}" set_env "SMTP_FROM" "${SMTP_FROM}" set_env "JITSI_MEETING_MODE" "${JITSI_MODE}" set_env "JITSI_BASE_URL" "${JITSI_URL}" set_env "JITSI_ROOM_PREFIX" "${JITSI_PREFIX}" if [[ "${DEPLOYMENT_MODE}" == "proxy" ]]; then set_env "ENABLE_TRAEFIK" "true" set_env "TRAEFIK_HOST" "${TRAEFIK_HOST}" set_env "TRAEFIK_ENTRYPOINTS" "${TRAEFIK_ENTRYPOINTS}" set_env "TRAEFIK_TLS" "${TRAEFIK_TLS}" set_env "TRAEFIK_CERTRESOLVER" "${TRAEFIK_CERTRESOLVER}" set_env "TRAEFIK_ROUTER_NAME" "${TRAEFIK_ROUTER}" set_env "TRAEFIK_SERVICE_NAME" "${TRAEFIK_SERVICE}" set_env "TRAEFIK_DOCKER_NETWORK" "${TRAEFIK_NETWORK}" else set_env "ENABLE_TRAEFIK" "false" fi info ".env geschrieben." # ── [5/5] Build & Start ────────────────────────────────── section "[5/5] Container starten & Datenbank einrichten" if [[ "${DEPLOYMENT_MODE}" == "proxy" ]]; then docker network inspect "${TRAEFIK_NETWORK}" >/dev/null 2>&1 || { info "Erstelle Traefik-Netzwerk: ${TRAEFIK_NETWORK}" docker network create "${TRAEFIK_NETWORK}" } fi if [[ "${NEW_ENV:-false}" == "true" ]]; then VOLUME_DIR="${ROOT_DIR}/volumes/postgres-${STACK_NAME}" if [[ -d "${VOLUME_DIR}" ]]; then if ask_yn "Alte DB-Daten für Stack '${STACK_NAME}' gefunden. Löschen für Neuaufsetzung?" "true"; then info "Lösche alte DB-Daten: ${VOLUME_DIR}" rm -rf "${VOLUME_DIR}" fi fi fi SERVICES=(db calbook-app) if [[ "${SMTP_HOST}" == "mailhog" || -z "${SMTP_HOST}" ]]; then SERVICES+=(mailhog) fi info "Starte: ${SERVICES[*]}" ${COMPOSE_CMD} -f "${COMPOSE_FILE}" up -d --build "${SERVICES[@]}" info "DB einrichten (Prisma + Seed)..." ${COMPOSE_CMD} -f "${COMPOSE_FILE}" build calbook-tools ${COMPOSE_CMD} -f "${COMPOSE_FILE}" run --rm calbook-tools npm run prisma:generate if ! ${COMPOSE_CMD} -f "${COMPOSE_FILE}" run --rm calbook-tools npm run prisma:migrate; then info "Migration fehlgeschlagen → prisma:push (Legacy-Fallback)..." ${COMPOSE_CMD} -f "${COMPOSE_FILE}" run --rm calbook-tools npm run prisma:push fi if ${COMPOSE_CMD} -f "${COMPOSE_FILE}" run --rm calbook-tools npm run db:seed; then success "Seed abgeschlossen" else warn "Seed fehlgeschlagen – Admin existiert möglicherweise bereits." fi # ── Summary ────────────────────────────────────────────── echo echo -e "${BOLD}${GREEN}══════════════════════════════════════════════${NC}" echo -e "${BOLD}${GREEN} CalBook läuft!${NC}" echo echo -e " URL: ${BOLD}${PUBLIC_URL}${NC}" echo -e " Login: ${BOLD}/anmelden${NC}" echo -e " Admin: ${BOLD}${ADMIN_EMAIL}${NC}" echo -e " Passwort: ${BOLD}${ADMIN_PASSWORD}${NC}" echo -e " Compose: ${DIM}${COMPOSE_FILE}${NC}" echo -e " Modus: ${DEPLOYMENT_MODE}" if [[ "${SMTP_HOST}" == "mailhog" ]]; then if [[ "${DEPLOYMENT_MODE}" == "direct" ]]; then echo -e " Mailhog: ${BOLD}http://localhost:8025${NC}" else echo -e " Mailhog: ${DIM}intern (kein Host-Port)${NC}" fi fi echo -e "${BOLD}${GREEN}══════════════════════════════════════════════${NC}" echo echo "Logs: ${COMPOSE_CMD} -f ${COMPOSE_FILE} logs -f calbook-app db" echo