#!/bin/sh set -eu ENV_FILE="${1:-.env.release}" if [ ! -f "$ENV_FILE" ]; then echo "ERROR: env file not found: $ENV_FILE" >&2 exit 1 fi while IFS= read -r raw_line || [ -n "$raw_line" ]; do line=$(printf '%s' "$raw_line" | tr -d '\r') case "$line" in ""|\#*) continue ;; esac export "$line" done < "$ENV_FILE" errors=0 warnings=0 error() { errors=$((errors + 1)) echo "ERROR: $*" >&2 } warn() { warnings=$((warnings + 1)) echo "WARN: $*" >&2 } require_non_empty() { var_name="$1" eval "var_value=\${$var_name:-}" if [ -z "$var_value" ]; then error "$var_name is required" fi } reject_values() { var_name="$1" shift eval "var_value=\${$var_name:-}" if [ -z "$var_value" ]; then return 0 fi for bad in "$@"; do if [ "$var_value" = "$bad" ]; then error "$var_name still uses placeholder/default value: $bad" return 0 fi done } reject_patterns() { var_name="$1" shift eval "var_value=\${$var_name:-}" if [ -z "$var_value" ]; then return 0 fi for pattern in "$@"; do case "$var_value" in $pattern) error "$var_name still uses placeholder/default pattern: $var_value" return 0 ;; esac done } validate_url() { var_name="$1" eval "var_value=\${$var_name:-}" if [ -z "$var_value" ]; then return 0 fi case "$var_value" in http://*|https://*) ;; *) error "$var_name must start with http:// or https://" ;; esac } validate_no_trailing_slash() { var_name="$1" eval "var_value=\${$var_name:-}" case "$var_value" in */) error "$var_name must not have a trailing slash" ;; esac } validate_boolean() { var_name="$1" eval "var_value=\${$var_name:-}" case "$var_value" in ""|true|false) ;; *) error "$var_name must be true or false" ;; esac } validate_port() { var_name="$1" eval "var_value=\${$var_name:-}" if [ -z "$var_value" ]; then return 0 fi case "$var_value" in *[!0-9]*|"") error "$var_name must be numeric" ;; *) if [ "$var_value" -lt 1 ] || [ "$var_value" -gt 65535 ]; then error "$var_name must be between 1 and 65535" fi ;; esac } validate_min_length() { var_name="$1" min_length="$2" eval "var_value=\${$var_name:-}" if [ -z "$var_value" ]; then return 0 fi if [ "${#var_value}" -lt "$min_length" ]; then error "$var_name must be at least $min_length characters" fi } validate_non_negative_integer() { var_name="$1" eval "var_value=\${$var_name:-}" case "$var_value" in "") ;; *[!0-9]*) error "$var_name must be a non-negative integer" ;; esac } validate_redis_nodes() { var_name="$1" eval "nodes=\${$var_name:-}" if [ -z "$nodes" ]; then return 0 fi old_ifs="$IFS" IFS="," for node in $nodes; do host=${node%:*} port=${node##*:} if [ -z "$host" ] || [ "$host" = "$node" ]; then error "$var_name entries must use host:port" continue fi case "$host" in *[!A-Za-z0-9._-]*) error "$var_name contains an invalid host: $host" ;; esac case "$port" in *[!0-9]*|"") error "$var_name contains an invalid port: $node" ;; *) if [ "$port" -lt 1 ] || [ "$port" -gt 65535 ]; then error "$var_name port must be between 1 and 65535: $node" fi ;; esac done IFS="$old_ifs" } validate_redis_sentinel_configuration() { master="${SPRING_DATA_REDIS_SENTINEL_MASTER:-}" nodes="${SPRING_DATA_REDIS_SENTINEL_NODES:-}" if [ -n "$master" ] && [ -z "$nodes" ]; then error "SPRING_DATA_REDIS_SENTINEL_NODES is required when SPRING_DATA_REDIS_SENTINEL_MASTER is set" fi if [ -n "$nodes" ] && [ -z "$master" ]; then error "SPRING_DATA_REDIS_SENTINEL_MASTER is required when SPRING_DATA_REDIS_SENTINEL_NODES is set" fi validate_redis_nodes SPRING_DATA_REDIS_SENTINEL_NODES } validate_redis_cluster_database() { if [ -z "${SPRING_DATA_REDIS_CLUSTER_NODES:-}" ]; then return 0 fi case "${SPRING_DATA_REDIS_DATABASE:-0}" in 0) ;; *) error "SPRING_DATA_REDIS_DATABASE must be 0 when SPRING_DATA_REDIS_CLUSTER_NODES is set" ;; esac } require_non_empty SKILLHUB_PUBLIC_BASE_URL validate_url SKILLHUB_PUBLIC_BASE_URL validate_no_trailing_slash SKILLHUB_PUBLIC_BASE_URL require_non_empty SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET reject_values SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET "change-me-in-production" "replace-me" "replace-with-random-download-secret-32-bytes" reject_patterns SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET "TODO_*" "todo_*" "replace*" validate_min_length SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET 32 reject_values POSTGRES_PASSWORD "change-this-postgres-password" "skillhub_demo" "skillhub_dev" reject_patterns POSTGRES_PASSWORD "TODO_*" "todo_*" reject_values BOOTSTRAP_ADMIN_PASSWORD "replace-this-admin-password" "ChangeMe!2026" "Admin@2026" reject_patterns BOOTSTRAP_ADMIN_PASSWORD "TODO_*" "todo_*" "replace*" if [ "${BOOTSTRAP_ADMIN_ENABLED:-false}" = "true" ]; then require_non_empty BOOTSTRAP_ADMIN_PASSWORD fi reject_values SKILLHUB_STORAGE_S3_ACCESS_KEY "replace-me" reject_values SKILLHUB_STORAGE_S3_SECRET_KEY "replace-me" reject_patterns SKILLHUB_STORAGE_S3_ACCESS_KEY "TODO_*" "todo_*" "replace*" reject_patterns SKILLHUB_STORAGE_S3_SECRET_KEY "TODO_*" "todo_*" "replace*" reject_patterns SPRING_MAIL_USERNAME "TODO_*" "todo_*" "replace*" reject_patterns SPRING_MAIL_PASSWORD "TODO_*" "todo_*" "replace*" validate_boolean SESSION_COOKIE_SECURE validate_boolean BOOTSTRAP_ADMIN_ENABLED validate_boolean SKILLHUB_TRUST_FORWARDED_PROTO validate_boolean SKILLHUB_BUILTIN_SKILLS_ENABLED validate_boolean SKILLHUB_STORAGE_S3_FORCE_PATH_STYLE validate_boolean SKILLHUB_STORAGE_S3_AUTO_CREATE_BUCKET validate_boolean SKILLHUB_AUTH_LDAP_ENABLED validate_boolean SKILLHUB_AUTH_LDAP_START_TLS validate_boolean SKILLHUB_AUTH_LDAP_ALLOW_INSECURE_FOR_TESTING validate_boolean SKILLHUB_AUTH_LDAP_EMAIL_AUTHORITATIVE validate_boolean SKILLHUB_AUTH_CAS_ENABLED validate_boolean SKILLHUB_AUTH_CAS_ALLOW_INSECURE_FOR_TESTING validate_boolean SKILLHUB_AUTH_DINGTALK_ENABLED validate_boolean SPRING_DATA_REDIS_SSL_ENABLED validate_boolean SKILLHUB_REDIS_SENTINEL_CHECK_SENTINELS_LIST if [ "${SKILLHUB_AUTH_LDAP_ENABLED:-false}" = "true" ]; then require_non_empty SKILLHUB_AUTH_LDAP_PROVIDER_CODE require_non_empty SKILLHUB_AUTH_LDAP_DISPLAY_NAME require_non_empty SKILLHUB_AUTH_LDAP_AUTHORITY require_non_empty SKILLHUB_AUTH_LDAP_URL require_non_empty SKILLHUB_AUTH_LDAP_BASE_DN require_non_empty SKILLHUB_AUTH_LDAP_USER_SEARCH_FILTER require_non_empty SKILLHUB_AUTH_LDAP_BIND_DN require_non_empty SKILLHUB_AUTH_LDAP_BIND_PASSWORD case "${SKILLHUB_AUTH_LDAP_DIRECTORY_TYPE:-OPENLDAP}" in OPENLDAP|ACTIVE_DIRECTORY|CUSTOM) ;; *) error "SKILLHUB_AUTH_LDAP_DIRECTORY_TYPE must be OPENLDAP, ACTIVE_DIRECTORY, or CUSTOM" ;; esac if [ "${SKILLHUB_AUTH_LDAP_DIRECTORY_TYPE:-OPENLDAP}" = "CUSTOM" ]; then require_non_empty SKILLHUB_AUTH_LDAP_SUBJECT_ATTRIBUTE require_non_empty SKILLHUB_AUTH_LDAP_SUBJECT_TYPE fi case "${SKILLHUB_AUTH_LDAP_URL:-}" in ldaps://*) if [ "${SKILLHUB_AUTH_LDAP_START_TLS:-false}" = "true" ]; then error "SKILLHUB_AUTH_LDAP_START_TLS must be false for an ldaps URL" fi ;; ldap://*) if [ "${SKILLHUB_AUTH_LDAP_START_TLS:-false}" != "true" ]; then error "SKILLHUB_AUTH_LDAP_START_TLS must be true for an ldap URL in release deployments" fi ;; *) error "SKILLHUB_AUTH_LDAP_URL must start with ldap:// or ldaps://" ;; esac case "${SKILLHUB_AUTH_LDAP_URL:-}" in *\?*|*\#*) error "SKILLHUB_AUTH_LDAP_URL must not contain a query or fragment" ;; esac if [ "${SKILLHUB_AUTH_LDAP_ALLOW_INSECURE_FOR_TESTING:-false}" = "true" ]; then error "SKILLHUB_AUTH_LDAP_ALLOW_INSECURE_FOR_TESTING cannot be true in release deployments" fi fi if [ "${SKILLHUB_AUTH_CAS_ENABLED:-false}" = "true" ]; then require_non_empty SKILLHUB_AUTH_CAS_PROVIDER_CODE require_non_empty SKILLHUB_AUTH_CAS_DISPLAY_NAME require_non_empty SKILLHUB_AUTH_CAS_AUTHORITY require_non_empty SKILLHUB_AUTH_CAS_SERVER_URL require_non_empty SKILLHUB_AUTH_CAS_SERVICE_URL validate_url SKILLHUB_AUTH_CAS_SERVER_URL validate_url SKILLHUB_AUTH_CAS_SERVICE_URL case "${SKILLHUB_AUTH_CAS_SERVER_URL:-}" in *\?*|*\#*) error "SKILLHUB_AUTH_CAS_SERVER_URL must not contain a query or fragment" ;; esac case "${SKILLHUB_AUTH_CAS_SERVICE_URL:-}" in *\?*|*\#*) error "SKILLHUB_AUTH_CAS_SERVICE_URL must not contain a query or fragment" ;; esac case "${SKILLHUB_AUTH_CAS_SERVER_URL:-}" in https://*) ;; *) error "SKILLHUB_AUTH_CAS_SERVER_URL must use https in release deployments" ;; esac case "${SKILLHUB_AUTH_CAS_SERVICE_URL:-}" in https://*) ;; *) error "SKILLHUB_AUTH_CAS_SERVICE_URL must use https in release deployments" ;; esac case "${SKILLHUB_AUTH_CAS_PROTOCOL_VERSION:-3.0}" in 2.0|3.0) ;; *) error "SKILLHUB_AUTH_CAS_PROTOCOL_VERSION must be 2.0 or 3.0" ;; esac expected_cas_callback="/api/v1/auth/cas/${SKILLHUB_AUTH_CAS_PROVIDER_CODE}/callback" case "${SKILLHUB_AUTH_CAS_SERVICE_URL:-}" in *"$expected_cas_callback") ;; *) error "SKILLHUB_AUTH_CAS_SERVICE_URL must end with $expected_cas_callback" ;; esac if [ "${SKILLHUB_AUTH_CAS_ALLOW_INSECURE_FOR_TESTING:-false}" = "true" ]; then error "SKILLHUB_AUTH_CAS_ALLOW_INSECURE_FOR_TESTING cannot be true in release deployments" fi fi if [ "${SKILLHUB_AUTH_DINGTALK_ENABLED:-false}" = "true" ]; then require_non_empty SKILLHUB_AUTH_DINGTALK_AUTHORITY require_non_empty SKILLHUB_AUTH_DINGTALK_CONNECT_TIMEOUT require_non_empty SKILLHUB_AUTH_DINGTALK_READ_TIMEOUT require_non_empty SKILLHUB_AUTH_DINGTALK_MAX_RESPONSE_BYTES require_non_empty OAUTH2_DINGTALK_CLIENT_ID require_non_empty OAUTH2_DINGTALK_CLIENT_SECRET reject_values OAUTH2_DINGTALK_CLIENT_ID "placeholder" "local-placeholder" reject_values OAUTH2_DINGTALK_CLIENT_SECRET "placeholder" "local-placeholder" case "${SKILLHUB_AUTH_DINGTALK_AUTHORITY:-}" in [a-z0-9]*) ;; *) error "SKILLHUB_AUTH_DINGTALK_AUTHORITY must start with a lowercase letter or digit" ;; esac case "${SKILLHUB_AUTH_DINGTALK_AUTHORITY:-}" in *[!a-z0-9._:-]*) error "SKILLHUB_AUTH_DINGTALK_AUTHORITY contains invalid characters" ;; esac validate_non_negative_integer SKILLHUB_AUTH_DINGTALK_MAX_RESPONSE_BYTES case "${SKILLHUB_AUTH_DINGTALK_MAX_RESPONSE_BYTES:-}" in "") ;; *) if [ "$SKILLHUB_AUTH_DINGTALK_MAX_RESPONSE_BYTES" -lt 1024 ] \ || [ "$SKILLHUB_AUTH_DINGTALK_MAX_RESPONSE_BYTES" -gt 1048576 ]; then error "SKILLHUB_AUTH_DINGTALK_MAX_RESPONSE_BYTES must be between 1024 and 1048576" fi ;; esac fi dingtalk_id="${OAUTH2_DINGTALK_CLIENT_ID:-}" dingtalk_secret="${OAUTH2_DINGTALK_CLIENT_SECRET:-}" if [ -n "$dingtalk_id" ] && [ -z "$dingtalk_secret" ]; then error "OAUTH2_DINGTALK_CLIENT_SECRET is required when OAUTH2_DINGTALK_CLIENT_ID is set" fi if [ -n "$dingtalk_secret" ] && [ -z "$dingtalk_id" ]; then error "OAUTH2_DINGTALK_CLIENT_ID is required when OAUTH2_DINGTALK_CLIENT_SECRET is set" fi validate_port POSTGRES_PORT validate_port REDIS_PORT validate_port API_PORT validate_port WEB_PORT validate_non_negative_integer SPRING_DATA_REDIS_CLUSTER_MAX_REDIRECTS validate_redis_nodes SPRING_DATA_REDIS_CLUSTER_NODES validate_redis_cluster_database validate_redis_sentinel_configuration require_non_empty POSTGRES_DB require_non_empty POSTGRES_USER require_non_empty POSTGRES_PASSWORD storage_provider="${SKILLHUB_STORAGE_PROVIDER:-}" case "$storage_provider" in s3) require_non_empty SKILLHUB_STORAGE_S3_ENDPOINT require_non_empty SKILLHUB_STORAGE_S3_BUCKET require_non_empty SKILLHUB_STORAGE_S3_ACCESS_KEY require_non_empty SKILLHUB_STORAGE_S3_SECRET_KEY require_non_empty SKILLHUB_STORAGE_S3_REGION validate_url SKILLHUB_STORAGE_S3_ENDPOINT validate_url SKILLHUB_STORAGE_S3_PUBLIC_ENDPOINT ;; local) warn "SKILLHUB_STORAGE_PROVIDER=local is only suitable for non-production or temporary validation" ;; "") error "SKILLHUB_STORAGE_PROVIDER is required" ;; *) error "SKILLHUB_STORAGE_PROVIDER must be either local or s3" ;; esac if [ -n "${SKILLHUB_WEB_API_BASE_URL:-}" ]; then validate_url SKILLHUB_WEB_API_BASE_URL validate_no_trailing_slash SKILLHUB_WEB_API_BASE_URL fi if [ -n "${DEVICE_AUTH_VERIFICATION_URI:-}" ]; then validate_url DEVICE_AUTH_VERIFICATION_URI fi if [ "${SESSION_COOKIE_SECURE:-true}" != "true" ]; then warn "SESSION_COOKIE_SECURE is not true; only acceptable behind plain HTTP during temporary local verification" fi if [ "${POSTGRES_BIND_ADDRESS:-127.0.0.1}" != "127.0.0.1" ]; then warn "POSTGRES_BIND_ADDRESS is not 127.0.0.1; confirm database exposure is intended" fi if [ "${REDIS_BIND_ADDRESS:-127.0.0.1}" != "127.0.0.1" ]; then warn "REDIS_BIND_ADDRESS is not 127.0.0.1; confirm Redis exposure is intended" fi oauth_id="${OAUTH2_GITHUB_CLIENT_ID:-}" oauth_secret="${OAUTH2_GITHUB_CLIENT_SECRET:-}" if [ -n "$oauth_id" ] && [ -z "$oauth_secret" ]; then error "OAUTH2_GITHUB_CLIENT_SECRET is required when OAUTH2_GITHUB_CLIENT_ID is set" fi if [ -n "$oauth_secret" ] && [ -z "$oauth_id" ]; then error "OAUTH2_GITHUB_CLIENT_ID is required when OAUTH2_GITHUB_CLIENT_SECRET is set" fi if [ "$errors" -gt 0 ]; then echo "Release config validation failed: $errors error(s), $warnings warning(s)." >&2 exit 1 fi echo "Release config validation passed with $warnings warning(s)."