#!/usr/bin/env bash set -euo pipefail script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" repo_root="$(cd "${script_dir}/.." && pwd)" source "${repo_root}/scripts/lib-compose-project.sh" mode="nossl" env_file="${repo_root}/.env.devprod" force_register="0" skip_register="0" force_api_key="0" skip_api_key="0" skip_occ="0" usage() { cat < Env file to use (default: .env.devprod) --force-register Always re-register app in external-auth --skip-register Skip external-auth app registration --force-api-key Always generate and set node API key --skip-api-key Skip node API key generation --skip-occ Skip Nextcloud OCC app enable/repair steps -h, --help Show this help USAGE } read_kv() { local key="$1" local line line="$(grep -m1 -E "^${key}=" "${env_file}" || true)" if [[ -z "${line}" ]]; then return 1 fi echo "${line#*=}" } read_trimmed_file_value() { local file="$1" if [[ -z "${file}" || ! -f "${file}" ]]; then return 1 fi tr -d '\r\n' < "${file}" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//' } resolve_path_from_env() { local key="$1" local default_path="$2" local value value="$(read_kv "${key}" || true)" value="${value:-${default_path}}" if [[ "${value}" == /* ]]; then echo "${value}" else echo "${repo_root}/${value}" fi } set_kv() { local key="$1" local value="$2" local esc esc="${value//\\/\\\\}" esc="${esc//&/\\&}" esc="${esc//|/\\|}" if grep -q -E "^${key}=" "${env_file}"; then sed -i -E "s|^${key}=.*|${key}=${esc}|" "${env_file}" else echo "${key}=${value}" >> "${env_file}" fi } extract_json_value() { local json="$1" local key="$2" if command -v jq >/dev/null 2>&1; then echo "${json}" | jq -r --arg key "${key}" '.[$key] // empty' 2>/dev/null || true return 0 fi echo "${json}" | sed -n "s/.*\"${key}\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p" | head -n1 } json_escape() { local input="$1" input="${input//\\/\\\\}" input="${input//\"/\\\"}" input="${input//$'\n'/ }" input="${input//$'\r'/ }" echo "${input}" } http_retry_count() { local raw="${QNI_HTTP_RETRY_ATTEMPTS:-40}" if [[ "${raw}" =~ ^[0-9]+$ ]] && (( raw > 0 )); then echo "${raw}" return 0 fi echo "40" } http_retry_sleep() { local raw="${QNI_HTTP_RETRY_SLEEP_SECONDS:-15}" if [[ "${raw}" =~ ^[0-9]+$ ]] && (( raw > 0 )); then echo "${raw}" return 0 fi echo "15" } wait_for_local_http() { local url="$1" local label="$2" local attempts sleep_seconds attempt attempts="$(http_retry_count)" sleep_seconds="$(http_retry_sleep)" for attempt in $(seq 1 "${attempts}"); do if curl -fsS --max-time 10 "${url}" >/dev/null 2>&1; then return 0 fi if [[ "${attempt}" -lt "${attempts}" ]]; then sleep "${sleep_seconds}" fi done echo "Timed out waiting for ${label} at ${url}" return 1 } register_external_auth_app_with_retry() { local register_url="$1" local payload="$2" local attempts sleep_seconds attempt register_resp new_app_id new_app_secret attempts="$(http_retry_count)" sleep_seconds="$(http_retry_sleep)" for attempt in $(seq 1 "${attempts}"); do register_resp="$(curl -fsS --max-time 15 -X POST "${register_url}" -H 'content-type: application/json' -d "${payload}" || true)" new_app_id="$(extract_json_value "${register_resp}" "appId")" new_app_secret="$(extract_json_value "${register_resp}" "appSecret")" if [[ -n "${new_app_id}" && -n "${new_app_secret}" ]]; then printf '%s\n%s\n' "${new_app_id}" "${new_app_secret}" return 0 fi if [[ "${attempt}" -lt "${attempts}" ]]; then sleep "${sleep_seconds}" fi done echo "Failed to register app via external-auth after ${attempts} attempts." echo "Last response: ${register_resp:-}" return 1 } test_external_auth_session() { local app_id="$1" local app_secret="$2" local response token if [[ -z "${app_id}" || -z "${app_secret}" ]]; then return 1 fi response="$( curl -sS --max-time 15 -X POST "http://127.0.0.1:${external_auth_host_port}/sessions" \ -H 'content-type: application/json' \ -d "{\"appId\":\"$(json_escape "${app_id}")\",\"appSecret\":\"$(json_escape "${app_secret}")\"}" || true )" token="$(extract_json_value "${response}" "token")" [[ -n "${token}" ]] } generate_qortal_api_key_with_retry() { local generate_url="$1" local attempts sleep_seconds attempt key_resp generated_key attempts="$(http_retry_count)" sleep_seconds="$(http_retry_sleep)" for attempt in $(seq 1 "${attempts}"); do key_resp="$(curl -fsS --max-time 20 -X POST "${generate_url}" || true)" generated_key="$(extract_json_value "${key_resp}" "apiKey")" if [[ -z "${generated_key}" ]]; then generated_key="$(echo "${key_resp}" | tr -d '\r\n')" fi if [[ -n "${generated_key}" && "${generated_key}" != "null" ]]; then printf '%s\n' "${generated_key}" return 0 fi if [[ "${attempt}" -lt "${attempts}" ]]; then sleep "${sleep_seconds}" fi done echo "Failed to generate node API key after ${attempts} attempts." echo "Last response: ${key_resp:-}" return 1 } ensure_docker_access() { local docker_path="" docker_path="$(type -P docker || true)" if [[ -z "${docker_path}" ]]; then echo "docker is required" exit 1 fi if "${docker_path}" info >/dev/null 2>&1; then DOCKER_CMD=("${docker_path}") DOCKER_REQUIRES_SUDO="0" return 0 fi if ! command -v sudo >/dev/null 2>&1; then echo "docker is installed but this user cannot access it, and sudo is unavailable." exit 1 fi if sudo -n "${docker_path}" info >/dev/null 2>&1; then DOCKER_CMD=(sudo "${docker_path}") DOCKER_REQUIRES_SUDO="1" echo "Using sudo for docker commands." return 0 fi echo "docker requires elevated privileges on this machine." echo "You may be prompted for your sudo password." if sudo "${docker_path}" info >/dev/null 2>&1; then DOCKER_CMD=(sudo "${docker_path}") DOCKER_REQUIRES_SUDO="1" echo "Using sudo for docker commands." return 0 fi echo "Unable to access docker, even with sudo." exit 1 } docker() { "${DOCKER_CMD[@]}" "$@" } compose_cmd() { docker compose -f "${compose_file}" --env-file "${env_file}" "$@" } read_kv_first() { local value="" local key for key in "$@"; do value="$(read_kv "${key}" || true)" if [[ -n "${value}" ]]; then echo "${value}" return 0 fi done return 1 } normalize_bool_01() { local raw="${1:-}" case "$(echo "${raw}" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')" in 1|true|yes|y|on) echo "1" ;; 0|false|no|n|off) echo "0" ;; *) echo "" ;; esac } occ_app_set_qortal() { local key="$1" local value="$2" compose_cmd exec -T app php occ config:app:set qortal_integration "${key}" --value="${value}" >/dev/null 2>&1 } occ_app_set_custom_pwa() { local key="$1" local value="$2" compose_cmd exec -T app php occ config:app:set custom_pwa "${key}" --value="${value}" >/dev/null 2>&1 } normalize_vapid_subject() { local raw="${1:-}" raw="$(echo "${raw}" | tr -d '\r' | xargs)" if [[ -z "${raw}" ]]; then echo "" return 0 fi if [[ "${raw}" == mailto:* || "${raw}" == http://* || "${raw}" == https://* ]]; then echo "${raw}" return 0 fi if [[ "${raw}" == *"@"* ]]; then echo "mailto:${raw}" return 0 fi echo "${raw}" } derive_email_domain_from_host() { local raw_host="${1:-}" local host parts_count last_part second_last third_last raw_host="$(echo "${raw_host}" | tr -d '\r' | xargs)" host="${raw_host#http://}" host="${host#https://}" host="${host%%/*}" host="${host%%:*}" host="${host#[}" host="${host%]}" host="${host,,}" if [[ -z "${host}" ]]; then echo "" return 0 fi if [[ "${host}" == "localhost" ]]; then echo "localhost" return 0 fi if [[ "${host}" =~ ^[0-9.]+$ ]]; then echo "${host}" return 0 fi if [[ "${host}" == *:* ]]; then echo "${host}" return 0 fi parts_count="$(awk -F'.' '{print NF}' <<< "${host}")" if [[ "${parts_count}" -le 2 ]]; then echo "${host}" return 0 fi last_part="${host##*.}" second_last="${host%.*}" second_last="${second_last##*.}" third_last="${host%.*.*}" third_last="${third_last##*.}" if [[ ${#last_part} -eq 2 && ${#second_last} -le 3 ]]; then echo "${third_last}.${second_last}.${last_part}" return 0 fi echo "${second_last}.${last_part}" } custom_pwa_should_install() { local raw normalized raw="$(read_kv_first "INSTALL_CUSTOM_PWA" || true)" normalized="$(normalize_bool_01 "${raw}")" if [[ -z "${normalized}" ]]; then normalized="1" fi [[ "${normalized}" == "1" ]] } generate_custom_pwa_vapid_keypair() { local output public_key private_key php_code php_code='$cfg=["private_key_type"=>OPENSSL_KEYTYPE_EC,"curve_name"=>"prime256v1"];$res=openssl_pkey_new($cfg);if($res===false){fwrite(STDERR,"generate_failed\n");exit(1);}$details=openssl_pkey_get_details($res);if(!is_array($details)||!isset($details["ec"])||!is_array($details["ec"])||!isset($details["ec"]["x"],$details["ec"]["y"],$details["ec"]["d"])){fwrite(STDERR,"details_failed\n");exit(1);}$rawPublic="\x04".$details["ec"]["x"].$details["ec"]["y"];$b64=function($v){return rtrim(strtr(base64_encode($v),"+/","-_"),"=");};echo "PUBLIC=".$b64($rawPublic).PHP_EOL;echo "PRIVATE=".$b64($details["ec"]["d"]).PHP_EOL;' output="" if command -v php >/dev/null 2>&1; then output="$(php -r "${php_code}" 2>/dev/null || true)" fi if [[ -z "${output}" ]]; then output="$(compose_cmd exec -T app php -r "${php_code}" 2>/dev/null || true)" fi if [[ -z "${output}" && $(command -v openssl >/dev/null 2>&1; echo $?) -eq 0 ]]; then output="$(generate_custom_pwa_vapid_keypair_openssl || true)" fi public_key="$(echo "${output}" | sed -n 's/^PUBLIC=//p' | head -n1)" private_key="$(echo "${output}" | sed -n 's/^PRIVATE=//p' | head -n1)" if [[ -z "${public_key}" || -z "${private_key}" ]]; then return 1 fi echo "${public_key}|${private_key}" return 0 } generate_custom_pwa_vapid_keypair_openssl() { local tmp_key key_text priv_hex pub_hex public_key private_key tmp_key="$(mktemp)" if ! openssl ecparam -name prime256v1 -genkey -noout -out "${tmp_key}" >/dev/null 2>&1; then rm -f "${tmp_key}" >/dev/null 2>&1 || true return 1 fi key_text="$(openssl ec -in "${tmp_key}" -text -noout 2>/dev/null || true)" rm -f "${tmp_key}" >/dev/null 2>&1 || true if [[ -z "${key_text}" ]]; then return 1 fi priv_hex="$(echo "${key_text}" | awk '/priv:/{flag=1;next}/pub:/{flag=0}flag' | tr -d '[:space:]:')" pub_hex="$(echo "${key_text}" | awk '/pub:/{flag=1;next}/ASN1 OID:/{flag=0}flag' | tr -d '[:space:]:')" if [[ -z "${priv_hex}" || -z "${pub_hex}" ]]; then return 1 fi public_key="$(echo "${pub_hex}" | xxd -r -p | base64 | tr '+/' '-_' | tr -d '=' | tr -d '\n' || true)" private_key="$(echo "${priv_hex}" | xxd -r -p | base64 | tr '+/' '-_' | tr -d '=' | tr -d '\n' || true)" if [[ -z "${public_key}" || -z "${private_key}" ]]; then return 1 fi echo "PUBLIC=${public_key}" echo "PRIVATE=${private_key}" return 0 } ensure_custom_pwa_vapid_env() { local subject public_key private_key admin_email nextcloud_domain nextcloud_root_domain default_subject generated generated_public generated_private status status=0 subject="$(read_kv_first "CUSTOM_PWA_VAPID_SUBJECT" || true)" public_key="$(read_kv_first "CUSTOM_PWA_VAPID_PUBLIC_KEY" || true)" private_key="$(read_kv_first "CUSTOM_PWA_VAPID_PRIVATE_KEY" || true)" if [[ -z "${subject}" ]]; then admin_email="$(read_kv "NEXTCLOUD_ADMIN_EMAIL" || true)" nextcloud_domain="$(read_kv "NEXTCLOUD_DOMAIN" || true)" nextcloud_root_domain="$(derive_email_domain_from_host "${nextcloud_domain}")" if [[ -n "${admin_email}" ]]; then default_subject="mailto:${admin_email}" elif [[ -n "${nextcloud_root_domain}" ]]; then default_subject="mailto:admin@${nextcloud_root_domain}" else default_subject="mailto:admin@localhost" fi subject="${default_subject}" set_kv "CUSTOM_PWA_VAPID_SUBJECT" "${subject}" echo "Set CUSTOM_PWA_VAPID_SUBJECT in ${env_file}." else subject="$(normalize_vapid_subject "${subject}")" set_kv "CUSTOM_PWA_VAPID_SUBJECT" "${subject}" fi if [[ -z "${public_key}" || -z "${private_key}" ]]; then generated="$(generate_custom_pwa_vapid_keypair || true)" generated_public="${generated%%|*}" generated_private="${generated#*|}" if [[ -z "${generated_public}" || -z "${generated_private}" || "${generated_public}" == "${generated_private}" ]]; then echo "Warning: failed to auto-generate CustomPWA VAPID keypair; set CUSTOM_PWA_VAPID_PUBLIC_KEY and CUSTOM_PWA_VAPID_PRIVATE_KEY manually." status=1 else set_kv "CUSTOM_PWA_VAPID_PUBLIC_KEY" "${generated_public}" set_kv "CUSTOM_PWA_VAPID_PRIVATE_KEY" "${generated_private}" echo "Generated CustomPWA VAPID keypair and saved to ${env_file}." fi fi return "${status}" } sync_custom_pwa_settings() { local status subject public_key private_key email_fallback sms_fallback normalized_bool status=0 subject="$(read_kv_first "CUSTOM_PWA_VAPID_SUBJECT" || true)" public_key="$(read_kv_first "CUSTOM_PWA_VAPID_PUBLIC_KEY" || true)" private_key="$(read_kv_first "CUSTOM_PWA_VAPID_PRIVATE_KEY" || true)" if [[ -n "${subject}" ]]; then occ_app_set_custom_pwa "vapid_subject" "${subject}" || status=1 fi if [[ -n "${public_key}" ]]; then occ_app_set_custom_pwa "vapid_public_key" "${public_key}" || status=1 fi if [[ -n "${private_key}" ]]; then occ_app_set_custom_pwa "vapid_private_key" "${private_key}" || status=1 fi email_fallback="$(read_kv_first "CUSTOM_PWA_EMAIL_FALLBACK_ENABLED" || true)" normalized_bool="$(normalize_bool_01 "${email_fallback}")" if [[ -n "${normalized_bool}" ]]; then occ_app_set_custom_pwa "email_fallback_enabled" "${normalized_bool}" || status=1 else occ_app_set_custom_pwa "email_fallback_enabled" "1" || status=1 fi sms_fallback="$(read_kv_first "CUSTOM_PWA_SMS_CRITICAL_FALLBACK_ENABLED" || true)" normalized_bool="$(normalize_bool_01 "${sms_fallback}")" if [[ -n "${normalized_bool}" ]]; then occ_app_set_custom_pwa "sms_critical_fallback_enabled" "${normalized_bool}" || status=1 else occ_app_set_custom_pwa "sms_critical_fallback_enabled" "0" || status=1 fi return "${status}" } sync_qortal_integration_billing_settings() { local sc_mode env_mode normalized_bool value nextcloud_public_url nextcloud_domain local chd_base_url chd_sync_path chd_register_path chd_checkout_path chd_catalog_path chd_catalog_category_id chd_registration_token local billing_provider billing_sync_mode billing_enabled billing_managed local billing_contact_name billing_contact_email billing_company_name local qortal_node_url qortal_gateway_url qortal_node_api_key local msp_mode_enabled msp_admin_group local status=0 sc_mode="$(compose_cmd exec -T app php occ config:app:get qortal_integration sc_mode 2>/dev/null | tr -d '\r' || true)" env_mode="$(read_kv_first "NUQLOUD_MODE" "SOVEREIGN_MODE" "SC_MODE" || true)" if [[ -n "${env_mode}" ]]; then sc_mode="${env_mode}" occ_app_set_qortal "sc_mode" "${env_mode}" || status=1 fi if [[ -z "${sc_mode}" ]]; then sc_mode="powered" fi if [[ "${sc_mode}" != "full_qortal" ]]; then billing_enabled="$(read_kv_first "NUQLOUD_BILLING_ENABLED" "SOVEREIGN_BILLING_ENABLED" "SC_BILLING_ENABLED" || true)" normalized_bool="$(normalize_bool_01 "${billing_enabled}")" if [[ -n "${normalized_bool}" ]]; then occ_app_set_qortal "sc_billing_enabled" "${normalized_bool}" || status=1 else occ_app_set_qortal "sc_billing_enabled" "1" || status=1 fi billing_managed="$(read_kv_first "NUQLOUD_BILLING_MANAGED_BY_CHD" "SOVEREIGN_BILLING_MANAGED_BY_CHD" "SC_BILLING_MANAGED_BY_CHD" || true)" normalized_bool="$(normalize_bool_01 "${billing_managed}")" if [[ -n "${normalized_bool}" ]]; then occ_app_set_qortal "sc_billing_managed_by_chd" "${normalized_bool}" || status=1 else occ_app_set_qortal "sc_billing_managed_by_chd" "1" || status=1 fi billing_provider="$(read_kv_first "NUQLOUD_BILLING_PROVIDER" "SOVEREIGN_BILLING_PROVIDER" "SC_BILLING_PROVIDER" || true)" if [[ -z "${billing_provider}" ]]; then billing_provider="paymenter" fi occ_app_set_qortal "sc_billing_provider" "${billing_provider}" || status=1 billing_sync_mode="$(read_kv_first "NUQLOUD_BILLING_SYNC_MODE" "SOVEREIGN_BILLING_SYNC_MODE" "SC_BILLING_SYNC_MODE" || true)" if [[ -n "${billing_sync_mode}" ]]; then occ_app_set_qortal "sc_billing_sync_mode" "${billing_sync_mode}" || status=1 fi fi nextcloud_public_url="$(read_kv "NEXTCLOUD_PUBLIC_URL" || true)" if [[ -n "${nextcloud_public_url}" ]]; then occ_app_set_qortal "nextcloud_public_url" "${nextcloud_public_url}" || status=1 fi qortal_node_url="$(read_kv_first "QORTAL_NODE_URL" "QORTAL_AUTH_NODE_URL" || true)" if [[ -n "${qortal_node_url}" ]]; then occ_app_set_qortal "qortal_node_url" "${qortal_node_url}" || status=1 fi qortal_gateway_url="$(read_kv_first "QORTAL_PUBLIC_GATEWAY_URL" "QORTAL_GATEWAY_PUBLIC_URL" "QORTAL_GATEWAY_URL" || true)" if [[ -n "${qortal_gateway_url}" ]]; then occ_app_set_qortal "qortal_gateway_url" "${qortal_gateway_url}" || status=1 fi qortal_node_api_key="$(read_kv_first "QORTAL_NODE_API_KEY" "QORTAL_AUTH_NODE_API_KEY" || true)" if [[ -n "${qortal_node_api_key}" ]]; then occ_app_set_qortal "qortal_node_api_key" "${qortal_node_api_key}" || status=1 fi chd_base_url="$(read_kv_first "NUQLOUD_CHD_BASE_URL" "SOVEREIGN_CHD_BASE_URL" "SC_CHD_BASE_URL" || true)" chd_sync_path="$(read_kv_first "NUQLOUD_CHD_SYNC_PATH" "SOVEREIGN_CHD_SYNC_PATH" "SC_CHD_SYNC_PATH" || true)" chd_register_path="$(read_kv_first "NUQLOUD_CHD_REGISTER_PATH" "SOVEREIGN_CHD_REGISTER_PATH" "SC_CHD_REGISTER_PATH" || true)" chd_checkout_path="$(read_kv_first "NUQLOUD_CHD_CHECKOUT_PATH" "SOVEREIGN_CHD_CHECKOUT_PATH" "SC_CHD_CHECKOUT_PATH" || true)" chd_catalog_path="$(read_kv_first "NUQLOUD_CHD_CATALOG_PATH" "SOVEREIGN_CHD_CATALOG_PATH" "SC_CHD_CATALOG_PATH" || true)" chd_catalog_category_id="$(read_kv_first "NUQLOUD_CHD_CATALOG_CATEGORY_ID" "SOVEREIGN_CHD_CATALOG_CATEGORY_ID" "SC_CHD_CATALOG_CATEGORY_ID" || true)" chd_registration_token="$(read_kv_first "NUQLOUD_CHD_REGISTRATION_TOKEN" "SOVEREIGN_CHD_REGISTRATION_TOKEN" "SC_CHD_REGISTRATION_TOKEN" || true)" if [[ -n "${chd_base_url}" ]]; then occ_app_set_qortal "sc_chd_base_url" "${chd_base_url}" || status=1 fi if [[ -n "${chd_sync_path}" ]]; then occ_app_set_qortal "sc_chd_sync_path" "${chd_sync_path}" || status=1 fi if [[ -n "${chd_register_path}" ]]; then occ_app_set_qortal "sc_chd_register_path" "${chd_register_path}" || status=1 fi if [[ -n "${chd_checkout_path}" ]]; then occ_app_set_qortal "sc_chd_checkout_path" "${chd_checkout_path}" || status=1 fi if [[ -n "${chd_catalog_path}" ]]; then occ_app_set_qortal "sc_chd_catalog_path" "${chd_catalog_path}" || status=1 fi if [[ -n "${chd_catalog_category_id}" ]]; then occ_app_set_qortal "sc_chd_catalog_category_id" "${chd_catalog_category_id}" || status=1 fi if [[ -n "${chd_registration_token}" ]]; then occ_app_set_qortal "sc_chd_registration_token" "${chd_registration_token}" || status=1 fi billing_contact_name="$(read_kv_first "NUQLOUD_BILLING_CONTACT_NAME" "SOVEREIGN_BILLING_CONTACT_NAME" "SC_BILLING_CONTACT_NAME" "NEXTCLOUD_ADMIN_USER" || true)" billing_contact_email="$(read_kv_first "NUQLOUD_BILLING_CONTACT_EMAIL" "SOVEREIGN_BILLING_CONTACT_EMAIL" "SC_BILLING_CONTACT_EMAIL" "NEXTCLOUD_ADMIN_EMAIL" || true)" nextcloud_domain="$(read_kv "NEXTCLOUD_DOMAIN" || true)" billing_company_name="$(read_kv_first "NUQLOUD_BILLING_COMPANY_NAME" "SOVEREIGN_BILLING_COMPANY_NAME" "SC_BILLING_COMPANY_NAME" || true)" if [[ -z "${billing_company_name}" && -n "${nextcloud_domain}" ]]; then billing_company_name="${nextcloud_domain}" fi if [[ -n "${billing_contact_name}" ]]; then occ_app_set_qortal "sc_billing_contact_name" "${billing_contact_name}" || status=1 fi if [[ -n "${billing_contact_email}" ]]; then occ_app_set_qortal "sc_billing_contact_email" "${billing_contact_email}" || status=1 fi if [[ -n "${billing_company_name}" ]]; then occ_app_set_qortal "sc_billing_company_name" "${billing_company_name}" || status=1 fi msp_mode_enabled="$(read_kv_first "NUQLOUD_MSP_MODE_ENABLED" "MSP_MODE_ENABLED" "SC_MSP_MODE_ENABLED" || true)" normalized_bool="$(normalize_bool_01 "${msp_mode_enabled}")" if [[ -n "${normalized_bool}" ]]; then occ_app_set_qortal "msp_mode_enabled" "${normalized_bool}" || status=1 fi msp_admin_group="$(read_kv_first "NUQLOUD_MSP_ADMIN_GROUP" "MSP_ADMIN_GROUP" "SC_MSP_ADMIN_GROUP" || true)" if [[ -z "${msp_admin_group}" ]]; then msp_admin_group="MSP_Admin" fi occ_app_set_qortal "msp_admin_group" "${msp_admin_group}" || status=1 if [[ "${sc_mode}" != "full_qortal" ]]; then if [[ -z "${chd_registration_token}" ]]; then echo "Warning: CHD registration token is not set in ${env_file} (expected NUQLOUD_CHD_REGISTRATION_TOKEN, SOVEREIGN_CHD_REGISTRATION_TOKEN, or SC_CHD_REGISTRATION_TOKEN)." echo "If your CHD connector requires a registration token, first-time CHD connect/checkout bootstrap will fail until it is configured." echo "If your CHD connector allows open registration, this can be left empty." fi fi return "${status}" } while [[ $# -gt 0 ]]; do case "$1" in --mode) mode="${2:-}" shift 2 ;; --mode=*) mode="${1#*=}" shift ;; --ssl) mode="ssl" shift ;; --nossl) mode="nossl" shift ;; --env-file) env_file="${2:-}" shift 2 ;; --env-file=*) env_file="${1#*=}" shift ;; --force-register) force_register="1" shift ;; --skip-register) skip_register="1" shift ;; --force-api-key) force_api_key="1" shift ;; --skip-api-key) skip_api_key="1" shift ;; --skip-occ) skip_occ="1" shift ;; -h|--help) usage exit 0 ;; *) echo "Unknown option: $1" usage exit 1 ;; esac done if [[ "${mode}" != "ssl" && "${mode}" != "nossl" ]]; then echo "Invalid mode: ${mode}" usage exit 1 fi if [[ "${env_file}" != /* ]]; then env_file="${repo_root}/${env_file}" fi if [[ ! -f "${env_file}" ]]; then echo "Missing env file: ${env_file}" exit 1 fi if [[ -z "$(type -P docker || true)" ]]; then echo "docker is required" exit 1 fi ensure_docker_access if ! docker compose version >/dev/null 2>&1; then echo "docker compose plugin is required" exit 1 fi if ! export_compose_project_name_from_env "${env_file}"; then warn_missing_compose_project_name "${env_file}" fi compose_file="${repo_root}/docker-compose.devprod.nossl.yml" if [[ "${mode}" == "ssl" ]]; then compose_file="${repo_root}/docker-compose.devprod.yml" fi current_profiles="" if command -v rg >/dev/null 2>&1; then profiles_line="$(rg -m1 '^COMPOSE_PROFILES=' "${env_file}" || true)" else profiles_line="$(grep -m1 -E '^COMPOSE_PROFILES=' "${env_file}" || true)" fi if [[ -n "${profiles_line}" ]]; then current_profiles="${profiles_line#COMPOSE_PROFILES=}" fi external_auth_base_url="$(read_kv "QORTAL_EXTERNAL_AUTH_BASE_URL" || true)" if [[ "${external_auth_base_url}" == *"external_auth"* ]]; then if [[ "${external_auth_base_url}" != "http://external_auth:3191" ]]; then echo "Normalizing QORTAL_EXTERNAL_AUTH_BASE_URL to bundled internal URL http://external_auth:3191" set_kv "QORTAL_EXTERNAL_AUTH_BASE_URL" "http://external_auth:3191" external_auth_base_url="http://external_auth:3191" fi if [[ -z "${current_profiles}" ]]; then current_profiles="external-auth" elif [[ ",${current_profiles}," != *",external-auth,"* ]]; then current_profiles="${current_profiles},external-auth" fi fi if [[ -n "${current_profiles}" ]]; then export COMPOSE_PROFILES="${current_profiles}" else unset COMPOSE_PROFILES || true fi if [[ "${external_auth_base_url}" != *"external_auth"* ]]; then echo "QORTAL_EXTERNAL_AUTH_BASE_URL does not reference bundled external_auth." echo "Current value: ${external_auth_base_url:-}" echo "This script only handles bundled external-auth finishing tasks." exit 1 fi external_auth_host_port="$(read_kv "EXTERNAL_AUTH_PORT" || true)" external_auth_host_port="${external_auth_host_port:-3191}" node_api_host_port="$(read_kv "QORTAL_NODE_API_HOST_PORT" || true)" node_api_host_port="${node_api_host_port:-12391}" echo "Using compose file: ${compose_file}" echo "Using env file: ${env_file}" echo "Using profiles: ${COMPOSE_PROFILES:-}" nextcloud_custom_apps_path="$(resolve_path_from_env "NEXTCLOUD_CUSTOM_APPS_PATH" "./nextcloud/custom_apps")" mkdir -p "${nextcloud_custom_apps_path}" if [[ -x "${repo_root}/scripts/ensure-user-oidc-app.sh" ]]; then NEXTCLOUD_CUSTOM_APPS_PATH="${nextcloud_custom_apps_path}" "${repo_root}/scripts/ensure-user-oidc-app.sh" fi if [[ -x "${repo_root}/scripts/ensure-qortal-integration-app.sh" ]]; then NEXTCLOUD_CUSTOM_APPS_PATH="${nextcloud_custom_apps_path}" "${repo_root}/scripts/ensure-qortal-integration-app.sh" fi if [[ -x "${repo_root}/scripts/ensure-oidc-signing-key.sh" ]]; then "${repo_root}/scripts/ensure-oidc-signing-key.sh" "${env_file}" fi app_id="$(read_kv "QORTAL_EXTERNAL_AUTH_APP_ID" || true)" app_secret="$(read_kv "QORTAL_EXTERNAL_AUTH_APP_SECRET" || true)" needs_register="0" register_name_default="qortal-nextcloud-integration" nextcloud_domain="$(read_kv "NEXTCLOUD_DOMAIN" || true)" if [[ -n "${nextcloud_domain}" ]]; then register_name_default="qortal-nextcloud-${nextcloud_domain}" fi if [[ "${skip_register}" != "1" ]]; then if [[ "${force_register}" == "1" || -z "${app_id}" || -z "${app_secret}" ]]; then needs_register="1" fi fi node_api_key="$(read_kv "QORTAL_AUTH_NODE_API_KEY" || true)" needs_api_key="0" if [[ "${skip_api_key}" != "1" ]]; then if [[ "${force_api_key}" == "1" || -z "${node_api_key}" ]]; then needs_api_key="1" fi fi echo "Ensuring broker + external_auth are up..." compose_cmd up -d --build broker if [[ "${needs_api_key}" == "1" ]]; then echo "Qortal node API key is missing or forced; allowing external_auth to pull qortal_node dependencies." compose_cmd up -d --build external_auth else echo "Keeping qortal_node untouched during bundled external-auth verification." compose_cmd up -d --build --no-deps external_auth fi if [[ "${skip_register}" != "1" && "${needs_register}" != "1" ]]; then if ! wait_for_local_http "http://127.0.0.1:${external_auth_host_port}/health" "bundled external-auth"; then exit 1 fi if ! test_external_auth_session "${app_id}" "${app_secret}"; then echo "Existing external-auth app credentials were rejected by /sessions; re-registering." needs_register="1" fi fi if [[ -x "${repo_root}/scripts/ensure-nextcloud-apps-writable.sh" ]]; then if ! "${repo_root}/scripts/ensure-nextcloud-apps-writable.sh" --mode "${mode}" --env-file "${env_file}"; then echo "Warning: could not guarantee Nextcloud apps directory writeability." echo "Apps page and app install/update operations may fail until fixed." fi fi if [[ "${needs_register}" == "1" ]]; then echo "Registering app with external-auth..." if ! wait_for_local_http "http://127.0.0.1:${external_auth_host_port}/health" "bundled external-auth"; then exit 1 fi register_name_escaped="$(json_escape "${register_name_default}")" register_payload="{\"name\":\"${register_name_escaped}\"}" register_output="$( register_external_auth_app_with_retry \ "http://127.0.0.1:${external_auth_host_port}/apps/register" \ "${register_payload}" )" || exit 1 new_app_id="$(echo "${register_output}" | sed -n '1p')" new_app_secret="$(echo "${register_output}" | sed -n '2p')" if [[ -z "${new_app_id}" || -z "${new_app_secret}" ]]; then echo "Failed to register app via external-auth." exit 1 fi set_kv "QORTAL_EXTERNAL_AUTH_APP_ID" "${new_app_id}" set_kv "QORTAL_EXTERNAL_AUTH_APP_SECRET" "${new_app_secret}" echo "Saved external-auth app credentials into ${env_file}." compose_cmd up -d --build --force-recreate broker else echo "Skipping app registration (existing credentials present or --skip-register used)." fi if [[ "${needs_api_key}" == "1" ]]; then qortal_api_key_file="${repo_root}/qortal/data/apikey.txt" generated_key="$(read_trimmed_file_value "${qortal_api_key_file}" || true)" if [[ -n "${generated_key}" ]]; then echo "Using existing Qortal node API key from ${qortal_api_key_file}." fi if [[ "${generated_key}" == "null" ]]; then generated_key="" fi if [[ -z "${generated_key}" ]]; then echo "Generating Qortal node API key..." generated_key="$( generate_qortal_api_key_with_retry \ "http://127.0.0.1:${node_api_host_port}/admin/apikey/generate" )" || exit 1 fi if [[ -z "${generated_key}" || "${generated_key}" == "null" ]]; then echo "Failed to resolve node API key." exit 1 fi set_kv "QORTAL_AUTH_NODE_API_KEY" "${generated_key}" set_kv "QORTAL_AUTH_NODE_API_KEY_MODE" "paths" set_kv "QORTAL_AUTH_NODE_API_KEY_PATHS" "/" mkdir -p "$(dirname "${qortal_api_key_file}")" printf '%s\n' "${generated_key}" > "${qortal_api_key_file}" echo "Saved QORTAL_AUTH_NODE_API_KEY into ${env_file}." compose_cmd up -d --build --force-recreate external_auth else echo "Skipping node API key generation (existing key present or --skip-api-key used)." fi custom_pwa_vapid_env_status=0 if custom_pwa_should_install; then ensure_custom_pwa_vapid_env || custom_pwa_vapid_env_status=$? if [[ "${custom_pwa_vapid_env_status}" -ne 0 ]]; then echo "Warning: CustomPWA VAPID env values are incomplete. Configure CUSTOM_PWA_VAPID_SUBJECT/CUSTOM_PWA_VAPID_PUBLIC_KEY/CUSTOM_PWA_VAPID_PRIVATE_KEY in ${env_file}." fi fi if [[ "${skip_occ}" != "1" ]]; then echo "Running Nextcloud OCC app enable/repair..." oidc_issuer="$(read_kv "OIDC_ISSUER" || true)" oidc_client_id="$(read_kv "OIDC_CLIENT_ID" || true)" oidc_client_secret="$(read_kv "OIDC_CLIENT_SECRET" || true)" oidc_client_id="${oidc_client_id:-nextcloud-local}" oidc_provider_config_status=0 oidc_provider_skipped=0 oidc_discovery_url="" if [[ -n "${oidc_issuer}" ]]; then oidc_discovery_url="${oidc_issuer%/}/.well-known/openid-configuration" fi occ_ready="0" occ_ready_retries="${OCC_READY_RETRIES:-36}" occ_ready_sleep_seconds="${OCC_READY_SLEEP_SECONDS:-5}" for _ in $(seq 1 "${occ_ready_retries}"); do if compose_cmd exec -T app php occ status >/dev/null 2>&1; then occ_ready="1" break fi sleep "${occ_ready_sleep_seconds}" done if [[ "${occ_ready}" == "1" ]]; then set +e msp_mode_enabled="$(read_kv_first "NUQLOUD_MSP_MODE_ENABLED" "MSP_MODE_ENABLED" "SC_MSP_MODE_ENABLED" || true)" msp_admin_group="$(read_kv_first "NUQLOUD_MSP_ADMIN_GROUP" "MSP_ADMIN_GROUP" "SC_MSP_ADMIN_GROUP" || true)" if [[ -z "${msp_admin_group}" ]]; then msp_admin_group="MSP_Admin" fi msp_mode_enabled="$(normalize_bool_01 "${msp_mode_enabled}")" nextcloud_url_sync_status=0 if [[ -x "${repo_root}/scripts/ensure-nextcloud-url-config.sh" ]]; then "${repo_root}/scripts/ensure-nextcloud-url-config.sh" \ --compose-file "${compose_file}" \ --env-file "${env_file}" >/dev/null 2>&1 nextcloud_url_sync_status=$? fi nextcloud_service_auth_status=0 if [[ -x "${repo_root}/scripts/ensure-nextcloud-service-auth.sh" ]]; then "${repo_root}/scripts/ensure-nextcloud-service-auth.sh" \ --compose-file "${compose_file}" \ --env-file "${env_file}" \ --repair >/dev/null 2>&1 nextcloud_service_auth_status=$? fi compose_cmd exec -T app php occ app:enable --force user_oidc >/dev/null 2>&1 enable_oidc_status=$? install_oidc_status=0 if [[ "${enable_oidc_status}" -ne 0 ]]; then compose_cmd exec -T app php occ app:install user_oidc >/dev/null 2>&1 install_oidc_status=$? compose_cmd exec -T app php occ app:enable --force user_oidc >/dev/null 2>&1 enable_oidc_status=$? fi notify_push_enable_status=0 notify_push_install_status=0 notify_push_verify_status=0 compose_cmd exec -T app php occ app:enable --force notify_push >/dev/null 2>&1 notify_push_enable_status=$? if [[ "${notify_push_enable_status}" -ne 0 ]]; then compose_cmd exec -T app php occ app:install notify_push >/dev/null 2>&1 notify_push_install_status=$? compose_cmd exec -T app php occ app:enable --force notify_push >/dev/null 2>&1 notify_push_enable_status=$? fi compose_cmd exec -T app php occ app:list >/tmp/finish_occ_app_list_notify_push.txt 2>/dev/null if grep -qE '^[[:space:]]+- notify_push([[:space:]:]|$)' /tmp/finish_occ_app_list_notify_push.txt 2>/dev/null; then notify_push_verify_status=0 else notify_push_verify_status=1 fi rm -f /tmp/finish_occ_app_list_notify_push.txt >/dev/null 2>&1 || true compose_cmd exec -T app php occ user_oidc:provider --help >/dev/null 2>&1 verify_oidc_status=$? if [[ -n "${oidc_discovery_url}" && -n "${oidc_client_secret}" ]]; then compose_cmd exec -T app php occ user_oidc:provider qortal \ -c "${oidc_client_id}" \ -s "${oidc_client_secret}" \ -d "${oidc_discovery_url}" \ --scope="openid profile email" \ --mapping-uid=sub \ --mapping-display-name=name \ --mapping-email=email >/dev/null 2>&1 oidc_provider_config_status=$? else oidc_provider_skipped=1 fi oidc_login_label_status=0 sovereign_mode="$(compose_cmd exec -T app php occ config:app:get qortal_integration sc_mode 2>/dev/null | tr -d '\r' || true)" if [[ "${sovereign_mode}" == "full_qortal" ]]; then compose_cmd exec -T app php occ config:system:delete user_oidc login_label >/dev/null 2>&1 oidc_login_label_status=$? else compose_cmd exec -T app php occ config:system:set user_oidc login_label --value="Create Account / Login" >/dev/null 2>&1 oidc_login_label_status=$? fi compose_cmd exec -T app php occ app:enable qortal_integration >/dev/null 2>&1 enable_qortal_status=$? msp_group_status=0 if [[ "${msp_mode_enabled}" == "1" ]]; then compose_cmd exec -T app php occ group:add "${msp_admin_group}" >/dev/null 2>&1 msp_group_status=$? fi custom_apps_to_enable=(chd_admin qortal_files_bridge qortal_talk_bridge nuqloud_scrum) if custom_pwa_should_install; then custom_apps_to_enable=(custom_pwa "${custom_apps_to_enable[@]}") fi for custom_app in "${custom_apps_to_enable[@]}"; do compose_cmd exec -T app php occ app:enable "${custom_app}" >/dev/null 2>&1 || true done custom_pwa_settings_sync_status=0 if custom_pwa_should_install; then sync_custom_pwa_settings || custom_pwa_settings_sync_status=$? fi qortal_settings_sync_status=0 if [[ "${enable_qortal_status}" -eq 0 ]]; then sync_qortal_integration_billing_settings || qortal_settings_sync_status=$? fi qortal_runtime_sync_status=0 if [[ -x "${repo_root}/scripts/ensure-qortal-integration-runtime-config.sh" ]]; then "${repo_root}/scripts/ensure-qortal-integration-runtime-config.sh" \ --compose-file "${compose_file}" \ --env-file "${env_file}" >/dev/null 2>&1 qortal_runtime_sync_status=$? fi default_app_bundle_status=0 if [[ -x "${repo_root}/scripts/ensure-nextcloud-default-app-bundle.sh" ]]; then "${repo_root}/scripts/ensure-nextcloud-default-app-bundle.sh" \ --compose-file "${compose_file}" \ --env-file "${env_file}" >/dev/null 2>&1 default_app_bundle_status=$? fi compose_cmd exec -T app php occ maintenance:repair >/dev/null 2>&1 repair_status=$? compose_cmd exec -T app php occ maintenance:repair --include-expensive >/dev/null 2>&1 repair_expensive_status=$? set -e if [[ "${nextcloud_url_sync_status}" -ne 0 || "${nextcloud_service_auth_status}" -ne 0 || "${enable_oidc_status}" -ne 0 || "${notify_push_enable_status}" -ne 0 || "${notify_push_verify_status}" -ne 0 || "${verify_oidc_status}" -ne 0 || "${oidc_provider_config_status}" -ne 0 || "${oidc_login_label_status}" -ne 0 || "${enable_qortal_status}" -ne 0 || "${msp_group_status}" -ne 0 || "${custom_pwa_settings_sync_status}" -ne 0 || "${qortal_settings_sync_status}" -ne 0 || "${qortal_runtime_sync_status}" -ne 0 || "${default_app_bundle_status}" -ne 0 || "${repair_status}" -ne 0 || "${repair_expensive_status}" -ne 0 ]]; then echo "Warning: OCC commands did not fully complete. Re-run manually:" echo " ./scripts/ensure-nextcloud-url-config.sh --compose-file ${compose_file} --env-file ${env_file}" echo " ./scripts/ensure-nextcloud-service-auth.sh --compose-file ${compose_file} --env-file ${env_file} --repair" echo " ./scripts/ensure-qortal-integration-runtime-config.sh --compose-file ${compose_file} --env-file ${env_file}" echo " ./scripts/ensure-nextcloud-default-app-bundle.sh --compose-file ${compose_file} --env-file ${env_file}" echo " docker compose -f ${compose_file} --env-file ${env_file} exec -T app php occ app:install user_oidc" echo " docker compose -f ${compose_file} --env-file ${env_file} exec -T app php occ app:enable --force user_oidc" echo " docker compose -f ${compose_file} --env-file ${env_file} exec -T app php occ app:install notify_push" echo " docker compose -f ${compose_file} --env-file ${env_file} exec -T app php occ app:enable --force notify_push" echo " docker compose -f ${compose_file} --env-file ${env_file} exec -T app php occ user_oidc:provider --help" if [[ -n "${oidc_discovery_url}" ]]; then echo " docker compose -f ${compose_file} --env-file ${env_file} exec -T app php occ user_oidc:provider qortal -c ${oidc_client_id} -s '' -d ${oidc_discovery_url} --scope='openid profile email' --mapping-uid=sub --mapping-display-name=name --mapping-email=email" fi echo " docker compose -f ${compose_file} --env-file ${env_file} exec -T app php occ config:system:set user_oidc login_label --value='Create Account / Login'" echo " docker compose -f ${compose_file} --env-file ${env_file} exec -T app php occ app:enable qortal_integration" if [[ "${msp_mode_enabled}" == "1" ]]; then echo " docker compose -f ${compose_file} --env-file ${env_file} exec -T app php occ group:add '${msp_admin_group}'" fi if custom_pwa_should_install; then echo " docker compose -f ${compose_file} --env-file ${env_file} exec -T app php occ config:app:set custom_pwa vapid_subject --value='$(read_kv_first "CUSTOM_PWA_VAPID_SUBJECT" || true)'" echo " docker compose -f ${compose_file} --env-file ${env_file} exec -T app php occ config:app:set custom_pwa vapid_public_key --value='$(read_kv_first "CUSTOM_PWA_VAPID_PUBLIC_KEY" || true)'" echo " docker compose -f ${compose_file} --env-file ${env_file} exec -T app php occ config:app:set custom_pwa vapid_private_key --value=''" fi echo " # Re-run finish script to sync qortal_integration billing settings from env" echo " ./scripts/finish-initial-setup.sh --mode ${mode} --env-file ${env_file}" echo " docker compose -f ${compose_file} --env-file ${env_file} exec -T app php occ db:add-missing-indices" echo " docker compose -f ${compose_file} --env-file ${env_file} exec -T app php occ maintenance:repair" echo " docker compose -f ${compose_file} --env-file ${env_file} exec -T app php occ maintenance:repair --include-expensive" if [[ "${install_oidc_status}" -ne 0 && "${enable_oidc_status}" -ne 0 ]]; then echo " Note: app:install user_oidc fallback failed during setup; check network/appstore access or custom_apps mount." fi if [[ "${oidc_provider_skipped}" -eq 1 ]]; then echo " Note: provider auto-config skipped because OIDC_ISSUER or OIDC_CLIENT_SECRET is empty in ${env_file}." fi if [[ "${notify_push_verify_status}" -ne 0 ]]; then echo " Note: notify_push is not enabled yet; install/enable it after confirming appstore connectivity." fi fi else echo "Warning: Nextcloud OCC did not become ready within timeout ($((occ_ready_retries * occ_ready_sleep_seconds))s)." if [[ -x "${repo_root}/scripts/diagnose-nextcloud-occ-readiness.sh" ]]; then echo "Collecting OCC readiness diagnostics..." "${repo_root}/scripts/diagnose-nextcloud-occ-readiness.sh" \ --compose-file "${compose_file}" \ --env-file "${env_file}" || true fi echo "Run later:" echo " ./scripts/diagnose-nextcloud-occ-readiness.sh --compose-file ${compose_file} --env-file ${env_file}" echo " ./scripts/ensure-nextcloud-default-app-bundle.sh --compose-file ${compose_file} --env-file ${env_file}" echo " docker compose -f ${compose_file} --env-file ${env_file} exec -T app php occ app:install user_oidc" echo " docker compose -f ${compose_file} --env-file ${env_file} exec -T app php occ app:enable --force user_oidc" echo " docker compose -f ${compose_file} --env-file ${env_file} exec -T app php occ app:install notify_push" echo " docker compose -f ${compose_file} --env-file ${env_file} exec -T app php occ app:enable --force notify_push" echo " docker compose -f ${compose_file} --env-file ${env_file} exec -T app php occ app:enable qortal_integration" echo " docker compose -f ${compose_file} --env-file ${env_file} exec -T app php occ db:add-missing-indices" echo " docker compose -f ${compose_file} --env-file ${env_file} exec -T app php occ maintenance:repair" echo " docker compose -f ${compose_file} --env-file ${env_file} exec -T app php occ maintenance:repair --include-expensive" fi fi echo echo "Initial setup finishing complete." echo "Verify:" echo " curl -sS http://127.0.0.1:${external_auth_host_port}/health" echo " docker compose -f ${compose_file} --env-file ${env_file} ps"