#!/usr/bin/env bash set -euo pipefail script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" repo_root="$(cd "${script_dir}/.." && pwd)" default_qortal_repo_url="https://github.com/Qortal/qortal.git" default_external_auth_repo_url="https://gitea.qortal.link/crowetic/Qortal-External-Auth.git" source "${script_dir}/lib-compose-project.sh" mode="nossl" with_external_auth="auto" with_external_auth_explicit="0" with_qortal="1" with_qortal_explicit="0" with_office="auto" with_office_explicit="0" with_signaling="auto" with_signaling_explicit="0" with_talk="auto" with_talk_explicit="0" with_turn="auto" with_turn_explicit="0" with_janus="auto" with_janus_explicit="0" with_recording="auto" with_recording_explicit="0" qortal_branch="" env_file="${repo_root}/.env.devprod" accept_defaults="0" skip_occ="0" guided_mode="auto" configure_only="0" auto_finish="auto" post_finish_recreate="auto" one_click="0" auto_generate_secrets="0" nextcloud_domain_input="" broker_domain_input="" collabora_domain_input="" signaling_domain_input="" install_custom_pwa_override="" install_default_app_bundle_override="" log_file="" log_enabled="1" default_nextcloud_image="nextcloud:34-apache" tested_nextcloud_image_note="Tested image majors are 33 and 34." original_args=("$@") usage() { cat < Build bundled Qortal Core from a specific branch --with-office Enable Office/Collabora profile + auto-config --without-office Disable Office/Collabora profile --with-signaling Enable signaling stack (talk + turn + janus + recording) + auto-config --without-signaling Disable signaling stack profiles --with-talk-signaling Enable Talk HPB signaling profile + auto-config --without-talk-signaling Disable Talk HPB signaling profile --with-turn Enable TURN configuration (implies Talk HPB) --without-turn Disable TURN configuration profile --with-janus Enable Janus media profile (uses Talk HPB container) --without-janus Disable Janus profile --with-recording Enable Talk recording backend profile --without-recording Disable Talk recording backend profile --nextcloud-domain Set NEXTCLOUD_DOMAIN --broker-domain Set BROKER_DOMAIN --office-domain Set COLLABORA_DOMAIN --signaling-domain Set SIGNALING_DOMAIN --env-file Env file to use (default: .env.devprod) --guided Run interactive guided env setup --no-guided Skip guided env setup --one-click Non-interactive full NuQloud auto-configuration + start + finish --configure-only Only write/validate configuration; do not start containers --auto-finish Run finish-initial-setup automatically after install --no-auto-finish Skip automatic finish-initial-setup call --post-finish-recreate Recreate app/broker/external_auth after auto-finish --no-post-finish-recreate Skip post-finish recreate step --with-custom-pwa Include/enable custom_pwa app --without-custom-pwa Do not include/enable custom_pwa app --with-default-app-bundle Install/enable the recommended default Nextcloud apps bundle --without-default-app-bundle Skip the recommended default Nextcloud apps bundle --log-file Write live installer output to this log file --no-log Do not write a persistent installer log --accept-defaults If env file is auto-created from template, continue without stopping --skip-occ Skip post-start Nextcloud app enable/repair commands -h, --help Show this help USAGE } start_install_log() { local log_dir="" local timestamp="" case "$(printf '%s' "${NUQLOUD_INSTALL_LOG:-${log_enabled}}" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')" in 0|false|no|n|off) return 0 ;; esac timestamp="$(date +%Y%m%d-%H%M%S)" if [[ -z "${log_file}" ]]; then log_file="${NUQLOUD_INSTALL_LOG_FILE:-${repo_root}/logs/install-production-docker-${timestamp}.log}" elif [[ "${log_file}" != /* ]]; then log_file="${repo_root}/${log_file}" fi log_dir="$(dirname "${log_file}")" mkdir -p "${log_dir}" chmod 700 "${log_dir}" >/dev/null 2>&1 || true touch "${log_file}" chmod 600 "${log_file}" >/dev/null 2>&1 || true ln -sfn "$(basename "${log_file}")" "${log_dir}/install-production-docker-latest.log" >/dev/null 2>&1 || true exec > >(tee -a "${log_file}") 2>&1 echo "Installer log: ${log_file}" echo "Started: $(date -Is)" printf 'Command: %q' "$0" printf ' %q' "$@" printf '\n' trap finish_install_log EXIT } finish_install_log() { local rc=$? echo "Finished: $(date -Is) status=${rc}" echo "Installer log saved: ${log_file}" exit "${rc}" } is_port_in_use() { local host="${1:-}" local port="${2:-}" if [[ -z "${port}" ]]; then return 1 fi if command -v ss >/dev/null 2>&1; then if [[ -n "${host}" && "${host}" != "0.0.0.0" && "${host}" != "::" ]]; then if ss -H -ltn 2>/dev/null | awk '{print $4}' | grep -Eq "(^|[\\[\\]:])${host}:${port}$|:${port}$"; then return 0 fi if ss -H -lun 2>/dev/null | awk '{print $5}' | grep -Eq "(^|[\\[\\]:])${host}:${port}$|:${port}$"; then return 0 fi return 1 fi if ss -H -ltn "( sport = :${port} )" 2>/dev/null | grep -q .; then return 0 fi if ss -H -lun "( sport = :${port} )" 2>/dev/null | grep -q .; then return 0 fi return 1 fi if command -v lsof >/dev/null 2>&1; then lsof -nP -iTCP:"${port}" -sTCP:LISTEN >/dev/null 2>&1 return $? fi return 1 } occ_has_command() { local command_name="${1:-}" if [[ -z "${command_name}" ]]; then return 1 fi docker compose -f "${compose_file}" --env-file "${env_file}" exec -T app php occ list 2>/dev/null \ | grep -qE "^[[:space:]]+${command_name}([[:space:]]|$)" } 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[@]}" "$@" } prompt_qortal_branch_if_needed() { local use_specific_branch="" local requested_branch="" if [[ "${with_qortal}" != "1" || -n "${qortal_branch}" || "${one_click}" == "1" || ! -t 0 ]]; then return 0 fi read -r -p "Use a specific Qortal Core branch for this install? (y/N): " use_specific_branch if [[ ! "${use_specific_branch}" =~ ^[Yy]$ ]]; then return 0 fi while true; do read -r -p "Qortal branch name: " requested_branch requested_branch="${requested_branch//[$'\r\n']}" if [[ -z "${requested_branch}" ]]; then echo "Branch name cannot be empty." continue fi if git check-ref-format --branch "${requested_branch}" >/dev/null 2>&1; then qortal_branch="${requested_branch}" echo "Using Qortal branch override for this install: ${qortal_branch}" return 0 fi echo "Invalid Qortal branch name: ${requested_branch}" done } cleanup_nextcloud_defaults() { local defaults_config_status=0 local firstrun_disable_status=0 local errexit_was_set="0" [[ $- == *e* ]] && errexit_was_set="1" set +e "${script_dir}/configure-nextcloud-defaults.sh" \ --compose-file "${compose_file}" \ --env-file "${env_file}" \ --restore-core-skeleton >/dev/null 2>&1 defaults_config_status=$? docker compose -f "${compose_file}" --env-file "${env_file}" exec -T app php occ app:disable firstrunwizard >/dev/null 2>&1 firstrun_disable_status=$? if [[ "${errexit_was_set}" == "1" ]]; then set -e fi if [[ "${defaults_config_status}" -ne 0 ]]; then echo "Warning: could not configure Nextcloud default onboarding/skeleton settings." fi if [[ "${firstrun_disable_status}" -ne 0 ]]; then echo "Warning: could not disable the Nextcloud First Run Wizard app." fi } apply_nextcloud_local_patch_if_present() { local patch_file="$1" local target_root="$2" local marker="${3:-}" local target_file_rel="${4:-}" local target_file="" local patch_rc=0 local patch_name="" local patch_log="" patch_name="$(basename "${patch_file}")" nextcloud_patch_effective() { local file_path="${1:-}" if [[ -z "${file_path}" || ! -f "${file_path}" ]]; then return 1 fi if [[ -n "${marker}" ]] && grep -qF "${marker}" "${file_path}" 2>/dev/null; then return 0 fi # PWA theming patch can be effectively present even if context/line numbers shifted. if [[ "${patch_name}" == "nextcloud-pwa-theming-fix.patch" ]]; then if grep -q "theming.Theming.getManifest" "${file_path}" 2>/dev/null \ && grep -q "'app' => 'core'" "${file_path}" 2>/dev/null; then return 0 fi fi return 1 } if [[ -z "${patch_file}" || -z "${target_root}" ]]; then return 0 fi if [[ ! -f "${patch_file}" ]]; then return 0 fi if [[ ! -d "${target_root}" ]]; then return 0 fi if [[ -n "${target_file_rel}" ]]; then target_file="${target_root}/${target_file_rel}" if [[ ! -f "${target_file}" ]]; then return 0 fi fi if [[ -n "${target_file}" ]] && nextcloud_patch_effective "${target_file}"; then echo "Nextcloud patch already applied/effective: ${patch_name}" return 0 fi if command -v patch >/dev/null 2>&1; then patch_log="$(mktemp)" patch --forward --batch -d "${target_root}" -p1 < "${patch_file}" >"${patch_log}" 2>&1 || patch_rc=$? if [[ "${patch_rc}" -eq 0 ]]; then rm -f "${patch_log}" >/dev/null 2>&1 || true echo "Applied Nextcloud patch: ${patch_name}" return 0 fi # patch exits 1 for already-applied/reversed hunks when using --forward. if [[ "${patch_rc}" -eq 1 ]]; then if [[ -n "${target_file}" ]] && nextcloud_patch_effective "${target_file}"; then rm -f "${patch_log}" >/dev/null 2>&1 || true echo "Nextcloud patch already applied/effective: ${patch_name}" return 0 fi fi if [[ -s "${patch_log}" ]]; then echo "Warning: patch(1) could not apply ${patch_name}; trying git apply fallback." sed -n '1,10p' "${patch_log}" | sed 's/^/ patch: /' fi rm -f "${patch_log}" >/dev/null 2>&1 || true fi if command -v git >/dev/null 2>&1; then if git -C "${target_root}" apply --check "${patch_file}" >/dev/null 2>&1 || git apply --check --directory="${target_root}" "${patch_file}" >/dev/null 2>&1; then if git -C "${target_root}" apply "${patch_file}" >/dev/null 2>&1 || git apply --directory="${target_root}" "${patch_file}" >/dev/null 2>&1; then echo "Applied Nextcloud patch: ${patch_name}" return 0 fi fi fi if [[ -n "${target_file}" ]] && nextcloud_patch_effective "${target_file}"; then echo "Nextcloud patch already applied/effective: ${patch_name}" return 0 fi echo "Warning: could not apply Nextcloud patch ${patch_name} (patch and git apply failed)" return 1 } while [[ $# -gt 0 ]]; do case "$1" in --mode) mode="${2:-}" shift 2 ;; --mode=*) mode="${1#*=}" shift ;; --ssl) mode="ssl" shift ;; --nossl) mode="nossl" shift ;; --with-external-auth) with_external_auth="1" with_external_auth_explicit="1" shift ;; --without-external-auth) with_external_auth="0" with_external_auth_explicit="1" shift ;; --with-qortal) with_qortal="1" with_qortal_explicit="1" shift ;; --without-qortal) with_qortal="0" with_qortal_explicit="1" shift ;; --qortal-branch) qortal_branch="${2:-}" shift 2 ;; --qortal-branch=*) qortal_branch="${1#*=}" shift ;; --with-office) with_office="1" with_office_explicit="1" shift ;; --without-office) with_office="0" with_office_explicit="1" shift ;; --with-signaling) with_signaling="1" with_signaling_explicit="1" shift ;; --without-signaling) with_signaling="0" with_signaling_explicit="1" shift ;; --with-talk-signaling|--with-talk) with_talk="1" with_talk_explicit="1" shift ;; --without-talk-signaling|--without-talk) with_talk="0" with_talk_explicit="1" shift ;; --with-turn) with_turn="1" with_turn_explicit="1" shift ;; --without-turn) with_turn="0" with_turn_explicit="1" shift ;; --with-janus) with_janus="1" with_janus_explicit="1" shift ;; --without-janus) with_janus="0" with_janus_explicit="1" shift ;; --with-recording) with_recording="1" with_recording_explicit="1" shift ;; --without-recording) with_recording="0" with_recording_explicit="1" shift ;; --nextcloud-domain) nextcloud_domain_input="${2:-}" shift 2 ;; --nextcloud-domain=*) nextcloud_domain_input="${1#*=}" shift ;; --broker-domain) broker_domain_input="${2:-}" shift 2 ;; --broker-domain=*) broker_domain_input="${1#*=}" shift ;; --collabora-domain|--office-domain) collabora_domain_input="${2:-}" shift 2 ;; --collabora-domain=*|--office-domain=*) collabora_domain_input="${1#*=}" shift ;; --signaling-domain|--talk-domain|--talk-signaling-domain) signaling_domain_input="${2:-}" shift 2 ;; --signaling-domain=*|--talk-domain=*|--talk-signaling-domain=*) signaling_domain_input="${1#*=}" shift ;; --env-file) env_file="${2:-}" shift 2 ;; --env-file=*) env_file="${1#*=}" shift ;; --guided) guided_mode="1" shift ;; --no-guided) guided_mode="0" shift ;; --one-click) one_click="1" guided_mode="0" accept_defaults="1" auto_finish="1" shift ;; --configure-only) configure_only="1" shift ;; --auto-finish) auto_finish="1" shift ;; --no-auto-finish) auto_finish="0" shift ;; --post-finish-recreate) post_finish_recreate="1" shift ;; --no-post-finish-recreate) post_finish_recreate="0" shift ;; --with-custom-pwa) install_custom_pwa_override="1" shift ;; --without-custom-pwa) install_custom_pwa_override="0" shift ;; --with-default-app-bundle) install_default_app_bundle_override="1" shift ;; --without-default-app-bundle) install_default_app_bundle_override="0" shift ;; --log-file) log_file="${2:-}" shift 2 ;; --log-file=*) log_file="${1#*=}" shift ;; --no-log) log_enabled="0" shift ;; --accept-defaults) accept_defaults="1" shift ;; --skip-occ) skip_occ="1" shift ;; -h|--help) usage exit 0 ;; *) echo "Unknown option: $1" usage exit 1 ;; esac done start_install_log "${original_args[@]}" if [[ -n "${qortal_branch}" ]] && ! git check-ref-format --branch "${qortal_branch}" >/dev/null 2>&1; then echo "Invalid Qortal branch name: ${qortal_branch}" exit 1 fi if [[ "${mode}" != "ssl" && "${mode}" != "nossl" ]]; then echo "Invalid mode: ${mode}" usage exit 1 fi if [[ "${one_click}" == "1" ]]; then if [[ "${with_external_auth_explicit}" != "1" ]]; then with_external_auth="1" fi if [[ "${with_qortal_explicit}" != "1" ]]; then with_qortal="1" fi if [[ "${with_office_explicit}" != "1" ]]; then with_office="1" fi if [[ "${with_signaling_explicit}" != "1" ]]; then with_signaling="1" fi if [[ "${with_talk_explicit}" != "1" ]]; then with_talk="1" fi if [[ "${with_turn_explicit}" != "1" ]]; then with_turn="1" fi if [[ "${with_janus_explicit}" != "1" ]]; then with_janus="1" fi if [[ "${with_recording_explicit}" != "1" ]]; then with_recording="1" fi if [[ -z "${install_custom_pwa_override}" ]]; then install_custom_pwa_override="1" fi if [[ -z "${install_default_app_bundle_override}" ]]; then install_default_app_bundle_override="1" fi fi if [[ "${with_office}" == "auto" ]]; then with_office="0" fi if [[ "${with_signaling}" == "auto" ]]; then with_signaling="0" fi if [[ "${with_talk}" == "auto" ]]; then with_talk="0" fi if [[ "${with_turn}" == "auto" ]]; then with_turn="0" fi if [[ "${with_janus}" == "auto" ]]; then with_janus="0" fi if [[ "${with_recording}" == "auto" ]]; then with_recording="0" fi if [[ "${with_turn}" == "1" || "${with_janus}" == "1" ]]; then with_talk="1" fi if [[ "${with_recording}" == "1" ]]; then with_talk="1" fi if [[ "${with_signaling_explicit}" == "1" ]]; then if [[ "${with_signaling}" == "1" ]]; then if [[ "${with_talk_explicit}" != "1" ]]; then with_talk="1" fi if [[ "${with_turn_explicit}" != "1" ]]; then with_turn="1" fi if [[ "${with_janus_explicit}" != "1" ]]; then with_janus="1" fi if [[ "${with_recording_explicit}" != "1" ]]; then with_recording="1" fi else if [[ "${with_talk_explicit}" != "1" ]]; then with_talk="0" fi if [[ "${with_turn_explicit}" != "1" ]]; then with_turn="0" fi if [[ "${with_janus_explicit}" != "1" ]]; then with_janus="0" fi if [[ "${with_recording_explicit}" != "1" ]]; then with_recording="0" fi fi fi if [[ "${with_talk}" == "0" ]]; then if [[ "${with_turn}" == "1" || "${with_janus}" == "1" ]]; then echo "--with-turn/--with-janus require Talk HPB. Re-run with --with-talk-signaling." exit 1 fi fi read_env_value() { local key="$1" local default_value="${2:-}" local value if command -v rg >/dev/null 2>&1; then value="$(rg -m1 "^${key}=" "${env_file}" | cut -d= -f2- || true)" else value="$(grep -m1 -E "^${key}=" "${env_file}" | cut -d= -f2- || true)" fi if [[ -z "${value}" ]]; then echo "${default_value}" return fi if [[ "${value}" =~ ^\".*\"$ ]]; then value="${value:1:${#value}-2}" elif [[ "${value}" =~ ^\'.*\'$ ]]; then value="${value:1:${#value}-2}" fi echo "${value}" } install_custom_pwa_enabled() { local raw raw="$(read_env_value "INSTALL_CUSTOM_PWA" "1")" case "$(echo "${raw}" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')" in 0|false|no|n|off) return 1 ;; *) return 0 ;; esac } install_default_app_bundle_enabled() { local raw raw="$(read_env_value "INSTALL_DEFAULT_APP_BUNDLE" "0")" case "$(echo "${raw}" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')" in 1|true|yes|y|on) return 0 ;; *) return 1 ;; esac } set_env_value() { 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 } prompt_env_value() { local key="$1" local fallback_default="$2" local label="$3" local secret="${4:-0}" local current_value local value current_value="$(read_env_value "${key}" "${fallback_default}")" if [[ "${secret}" == "1" ]]; then read -r -s -p "${label} [hidden, Enter to keep current]: " value echo value="${value:-${current_value}}" else read -r -p "${label} [${current_value}]: " value value="${value:-${current_value}}" fi set_env_value "${key}" "${value}" } prompt_yes_no() { local label="$1" local default_answer="${2:-N}" local answer if [[ "${default_answer}" =~ ^[Yy]$ ]]; then read -r -p "${label} (Y/n): " answer answer="${answer:-Y}" else read -r -p "${label} (y/N): " answer answer="${answer:-N}" fi if [[ "${answer}" =~ ^[Yy]$ ]]; then return 0 fi return 1 } is_reserved_nonpublic_domain() { local domain="${1:-}" domain="$(echo "${domain}" | tr '[:upper:]' '[:lower:]' | xargs)" domain="${domain%.}" if [[ -z "${domain}" ]]; then return 0 fi case "${domain}" in localhost|localhost.localdomain|*.localhost|*.local|*.test|*.example|*.invalid|example.test) return 0 ;; esac return 1 } is_valid_public_contact_email() { local email="${1:-}" local domain="" email="$(echo "${email}" | tr '[:upper:]' '[:lower:]' | xargs)" if [[ ! "${email}" =~ ^[^@[:space:]]+@[^@[:space:]]+\.[^@[:space:]]+$ ]]; then return 1 fi domain="${email#*@}" if is_reserved_nonpublic_domain "${domain}"; then return 1 fi return 0 } default_caddy_email_for_domain() { local domain="${1:-}" domain="$(echo "${domain}" | tr '[:upper:]' '[:lower:]' | xargs)" domain="${domain%.}" if [[ -z "${domain}" ]]; then echo "admin@example.test" return 0 fi echo "admin@${domain}" } validate_caddy_public_tls_settings() { local tls_mode caddy_email nextcloud_domain broker_domain collabora_domain signaling_domain local invalid_domains=() if [[ "${mode}" != "ssl" ]]; then return 0 fi tls_mode="$(read_env_value "CADDY_TLS" "")" tls_mode="$(echo "${tls_mode}" | tr '[:upper:]' '[:lower:]' | xargs)" if [[ "${tls_mode}" == "tls internal" ]]; then return 0 fi caddy_email="$(read_env_value "CADDY_EMAIL" "")" if ! is_valid_public_contact_email "${caddy_email}"; then echo "Invalid Caddy ACME contact email for public TLS: ${caddy_email:-}" echo "Set CADDY_EMAIL to a real email address on a public domain, or set CADDY_TLS=tls internal for self-signed/local testing." exit 1 fi nextcloud_domain="$(read_env_value "NEXTCLOUD_DOMAIN" "")" broker_domain="$(read_env_value "BROKER_DOMAIN" "")" collabora_domain="$(read_env_value "COLLABORA_DOMAIN" "")" signaling_domain="$(read_env_value "SIGNALING_DOMAIN" "")" for domain in "${nextcloud_domain}" "${broker_domain}" "${collabora_domain}" "${signaling_domain}"; do if is_reserved_nonpublic_domain "${domain}"; then invalid_domains+=("${domain}") fi done if [[ "${#invalid_domains[@]}" -gt 0 ]]; then echo "Caddy public TLS is configured, but these domains are not valid public ACME targets:" for domain in "${invalid_domains[@]}"; do echo " - ${domain}" done echo "Use public DNS names for SSL mode, or set CADDY_TLS=tls internal for self-signed/local testing." exit 1 fi } is_tested_nextcloud_image() { local image="$1" [[ "${image}" =~ (^|[^0-9])(33|34)([^0-9]|$) ]] } prompt_nextcloud_image_selection() { local current_value choice custom_value selected_image current_value="$(read_env_value "NEXTCLOUD_IMAGE" "${default_nextcloud_image}")" if [[ -z "${current_value}" ]]; then current_value="${default_nextcloud_image}" fi while true; do echo "Select Nextcloud Docker image:" echo " 1) nextcloud:34-apache (recommended default)" echo " 2) nextcloud:33-apache (tested fallback)" echo " 3) Keep current (${current_value})" echo " 4) Enter a custom image tag" read -r -p "Choice [1/2/3/4] (default: 1): " choice choice="${choice:-1}" case "${choice}" in 1) selected_image="nextcloud:34-apache" ;; 2) selected_image="nextcloud:33-apache" ;; 3) selected_image="${current_value}" ;; 4) read -r -p "Custom Nextcloud image (for example nextcloud:34.0.1-apache): " custom_value custom_value="${custom_value//[[:space:]]/}" if [[ -z "${custom_value}" ]]; then echo "Custom image cannot be empty." continue fi selected_image="${custom_value}" ;; *) echo "Invalid choice: ${choice}" continue ;; esac if ! is_tested_nextcloud_image "${selected_image}"; then echo "Warning: ${selected_image} has not been tested extensively in this repo." echo "${tested_nextcloud_image_note}" if ! prompt_yes_no "Use ${selected_image} anyway?" "N"; then continue fi fi set_env_value "NEXTCLOUD_IMAGE" "${selected_image}" echo "Using NEXTCLOUD_IMAGE=${selected_image}" break done } compute_public_url() { local domain="$1" local https_port="$2" if [[ "${https_port}" == "443" ]]; then echo "https://${domain}" else echo "https://${domain}:${https_port}" fi } stun_server_should_follow_talk_domain() { local stun_server="$1" local talk_domain="$2" local legacy_domain="${3:-}" local host_fqdn host_short host_fqdn_local host_short_local stun_host if [[ -z "${stun_server}" || "${stun_server}" == "stun.nextcloud.com:443" || "${stun_server}" == "stun.nextcloud.com" ]]; then return 0 fi stun_host="${stun_server%%:*}" host_fqdn="$(hostname -f 2>/dev/null || true)" host_short="$(hostname -s 2>/dev/null || true)" host_fqdn_local="${host_fqdn}.local" host_short_local="${host_short}.local" if [[ "${stun_host}" == "${talk_domain}" || -n "${legacy_domain}" && "${stun_host}" == "${legacy_domain}" || "${stun_host}" == "${host_fqdn}" || "${stun_host}" == "${host_short}" || "${stun_host}" == "${host_fqdn_local}" || "${stun_host}" == "${host_short_local}" || "${stun_host}" == "localhost" || "${stun_host}" == "127.0.0.1" ]]; then return 0 fi return 1 } janus_stun_server_should_follow_default() { local stun_server="$1" local legacy_domain_a="${2:-}" local legacy_domain_b="${3:-}" local host_fqdn host_short host_fqdn_local host_short_local if [[ -z "${stun_server}" || "${stun_server}" == "stun.nextcloud.com" ]]; then return 0 fi host_fqdn="$(hostname -f 2>/dev/null || true)" host_short="$(hostname -s 2>/dev/null || true)" host_fqdn_local="${host_fqdn}.local" host_short_local="${host_short}.local" if [[ "${stun_server}" == "${legacy_domain_a}" || "${stun_server}" == "${legacy_domain_b}" || "${stun_server}" == "${host_fqdn}" || "${stun_server}" == "${host_short}" || "${stun_server}" == "${host_fqdn_local}" || "${stun_server}" == "${host_short_local}" || "${stun_server}" == "localhost" || "${stun_server}" == "127.0.0.1" ]]; then return 0 fi return 1 } turn_server_should_follow_talk_domain() { local turn_server="$1" local talk_domain="$2" local talk_turn_port="$3" local legacy_domain="${4:-}" local host_fqdn host_short host_fqdn_local host_short_local turn_host turn_port if [[ -z "${turn_server}" ]]; then return 0 fi turn_host="${turn_server%%:*}" turn_port="${turn_server##*:}" host_fqdn="$(hostname -f 2>/dev/null || true)" host_short="$(hostname -s 2>/dev/null || true)" host_fqdn_local="${host_fqdn}.local" host_short_local="${host_short}.local" if [[ "${turn_port}" != "${talk_turn_port}" ]]; then return 1 fi if [[ "${turn_host}" == "${talk_domain}" || -n "${legacy_domain}" && "${turn_host}" == "${legacy_domain}" || "${turn_host}" == "${host_fqdn}" || "${turn_host}" == "${host_short}" || "${turn_host}" == "${host_fqdn_local}" || "${turn_host}" == "${host_short_local}" || "${turn_host}" == "localhost" || "${turn_host}" == "127.0.0.1" ]]; then return 0 fi return 1 } derive_default_nextcloud_domain() { local host_fqdn host_short host_fqdn="$(hostname -f 2>/dev/null || true)" host_short="$(hostname -s 2>/dev/null || true)" if [[ -n "${host_fqdn}" && "${host_fqdn}" != "(none)" && "${host_fqdn}" != "localhost" ]]; then if [[ "${host_fqdn}" != *.* ]]; then echo "${host_fqdn}.local" return fi echo "${host_fqdn}" return fi if [[ -n "${host_short}" && "${host_short}" != "(none)" && "${host_short}" != "localhost" ]]; then echo "${host_short}.local" return fi echo "cloud.local" } derive_broker_domain() { local nextcloud_domain="$1" if [[ "${nextcloud_domain}" == *.*.* ]]; then local tail="${nextcloud_domain#*.}" if [[ -n "${tail}" && "${tail}" != "${nextcloud_domain}" ]]; then echo "broker.${tail}" return fi fi echo "broker.${nextcloud_domain}" } derive_prefixed_domain() { local prefix="$1" local nextcloud_domain="$2" if [[ -z "${nextcloud_domain}" ]]; then echo "${prefix}.local" return fi if [[ "${nextcloud_domain}" == "${prefix}."* ]]; then echo "${nextcloud_domain}" return fi echo "${prefix}.${nextcloud_domain}" } random_hex() { local bytes="${1:-16}" if command -v openssl >/dev/null 2>&1; then openssl rand -hex "${bytes}" return fi od -An -N "${bytes}" -tx1 /dev/urandom | tr -d ' \n' } ensure_secret_if_placeholder() { local key="$1" local bytes="$2" shift 2 local current current="$(read_env_value "${key}" "")" if [[ -z "${current}" ]]; then set_env_value "${key}" "$(random_hex "${bytes}")" return fi if [[ "${auto_generate_secrets}" != "1" ]]; then return fi for placeholder in "$@"; do if [[ "${current}" == "${placeholder}" ]]; then set_env_value "${key}" "$(random_hex "${bytes}")" return fi done } auto_derive_env_defaults() { local nextcloud_domain broker_domain collabora_domain signaling_domain https_port local nc_public_url broker_public_url collabora_public_url signaling_public_url caddy_email local external_auth_base_url talk_turn_port_value talk_stun_server_value local admin_user_current service_user_current service_password_current admin_password_current local service_user_default="nuqloud_oidc_service" nextcloud_domain="$(read_env_value "NEXTCLOUD_DOMAIN" "")" if [[ -z "${nextcloud_domain}" || "${nextcloud_domain}" == "cloud.example.test" ]]; then nextcloud_domain="$(derive_default_nextcloud_domain)" set_env_value "NEXTCLOUD_DOMAIN" "${nextcloud_domain}" fi broker_domain="$(read_env_value "BROKER_DOMAIN" "")" if [[ -z "${broker_domain}" || "${broker_domain}" == "qortalbroker.example.test" ]]; then broker_domain="$(derive_broker_domain "${nextcloud_domain}")" set_env_value "BROKER_DOMAIN" "${broker_domain}" fi collabora_domain="$(read_env_value "COLLABORA_DOMAIN" "")" if [[ -z "${collabora_domain}" || "${collabora_domain}" == "office.cloud.example.test" ]]; then collabora_domain="$(derive_prefixed_domain "office" "${nextcloud_domain}")" set_env_value "COLLABORA_DOMAIN" "${collabora_domain}" fi signaling_domain="$(read_env_value "SIGNALING_DOMAIN" "")" if [[ -z "${signaling_domain}" ]]; then signaling_domain="$(read_env_value "TALK_DOMAIN" "")" fi if [[ -z "${signaling_domain}" || "${signaling_domain}" == "talk.cloud.example.test" ]]; then signaling_domain="$(derive_prefixed_domain "talk" "${nextcloud_domain}")" set_env_value "SIGNALING_DOMAIN" "${signaling_domain}" fi if [[ -z "$(read_env_value "TALK_DOMAIN" "")" || "$(read_env_value "TALK_DOMAIN" "")" == "talk.cloud.example.test" ]]; then set_env_value "TALK_DOMAIN" "${signaling_domain}" fi if [[ "${mode}" == "nossl" ]]; then if [[ -z "$(read_env_value "EXTERNAL_PROXY_MODE" "")" ]]; then set_env_value "EXTERNAL_PROXY_MODE" "manual" fi if [[ -z "$(read_env_value "DEVPROD_HTTP_PORT" "")" ]]; then set_env_value "DEVPROD_HTTP_PORT" "8081" fi if [[ -z "$(read_env_value "DEVPROD_BROKER_PORT" "")" ]]; then set_env_value "DEVPROD_BROKER_PORT" "3001" fi if [[ -z "$(read_env_value "PUBLIC_HTTPS_PORT" "")" ]]; then set_env_value "PUBLIC_HTTPS_PORT" "443" fi https_port="$(read_env_value "PUBLIC_HTTPS_PORT" "443")" else if [[ -z "$(read_env_value "EXTERNAL_PROXY_MODE" "")" ]]; then set_env_value "EXTERNAL_PROXY_MODE" "none" fi if [[ -z "$(read_env_value "CADDY_HTTP_PORT" "")" ]]; then set_env_value "CADDY_HTTP_PORT" "80" fi if [[ -z "$(read_env_value "CADDY_HTTPS_PORT" "")" ]]; then set_env_value "CADDY_HTTPS_PORT" "443" fi https_port="$(read_env_value "CADDY_HTTPS_PORT" "443")" caddy_email="$(read_env_value "CADDY_EMAIL" "")" if [[ -z "${caddy_email}" || "${caddy_email}" == "admin@example.test" ]]; then set_env_value "CADDY_EMAIL" "admin@${nextcloud_domain}" fi fi nc_public_url="$(compute_public_url "${nextcloud_domain}" "${https_port}")" broker_public_url="$(compute_public_url "${broker_domain}" "${https_port}")" collabora_public_url="$(compute_public_url "${collabora_domain}" "${https_port}")" signaling_public_url="$(compute_public_url "${signaling_domain}" "${https_port}")" set_env_value "NEXTCLOUD_TRUSTED_DOMAINS" "${nextcloud_domain}" set_env_value "NEXTCLOUD_BASE_URL" "http://app" set_env_value "NEXTCLOUD_PUBLIC_URL" "${nc_public_url}" set_env_value "OIDC_ISSUER" "${broker_public_url}" set_env_value "OIDC_REDIRECT_URI_ALLOWLIST" "${nc_public_url}/apps/user_oidc/code" set_env_value "COLLABORA_PUBLIC_URL" "${collabora_public_url}" set_env_value "TALK_SIGNALING_PUBLIC_URL" "${signaling_public_url}" if [[ -z "$(read_env_value "BROKER_CORS_ALLOWED_ORIGINS" "")" ]]; then set_env_value "BROKER_CORS_ALLOWED_ORIGINS" "${nc_public_url}" fi if [[ -z "$(read_env_value "BROKER_REQUEST_BODY_LIMIT" "")" ]]; then set_env_value "BROKER_REQUEST_BODY_LIMIT" "64mb" fi if [[ -z "$(read_env_value "NEXTCLOUD_PHP_UPLOAD_MAX_FILESIZE" "")" ]]; then set_env_value "NEXTCLOUD_PHP_UPLOAD_MAX_FILESIZE" "5G" fi if [[ -z "$(read_env_value "NEXTCLOUD_PHP_POST_MAX_SIZE" "")" ]]; then set_env_value "NEXTCLOUD_PHP_POST_MAX_SIZE" "5G" fi if [[ -z "$(read_env_value "OIDC_POLICY_MODE" "")" ]]; then set_env_value "OIDC_POLICY_MODE" "auto_provision" fi if [[ -z "$(read_env_value "OIDC_AUTO_PROVISION_GUARD" "")" ]]; then set_env_value "OIDC_AUTO_PROVISION_GUARD" "invite_or_allowlist" fi if [[ -z "$(read_env_value "OIDC_REQUIRE_EMAIL_FOR_NEW_ACCOUNT" "")" ]]; then set_env_value "OIDC_REQUIRE_EMAIL_FOR_NEW_ACCOUNT" "true" fi if [[ -z "$(read_env_value "NEXTCLOUD_IMAGE" "")" ]]; then set_env_value "NEXTCLOUD_IMAGE" "${default_nextcloud_image}" fi if [[ -z "$(read_env_value "NEXTCLOUD_ADMIN_USER" "")" ]]; then set_env_value "NEXTCLOUD_ADMIN_USER" "admin" fi ensure_secret_if_placeholder "NEXTCLOUD_ADMIN_PASSWORD" 16 "admin123" if [[ -z "$(read_env_value "COLLABORA_ADMIN_USER" "")" ]]; then set_env_value "COLLABORA_ADMIN_USER" "admin" fi ensure_secret_if_placeholder "COLLABORA_ADMIN_PASSWORD" 16 admin_user_current="$(read_env_value "NEXTCLOUD_ADMIN_USER" "admin")" service_user_current="$(read_env_value "NEXTCLOUD_SERVICE_USER" "")" if [[ -z "${service_user_current}" ]]; then set_env_value "NEXTCLOUD_SERVICE_USER" "${service_user_default}" service_user_current="${service_user_default}" elif [[ "${auto_generate_secrets}" == "1" && "${service_user_current}" == "${admin_user_current}" && "${service_user_default}" != "${admin_user_current}" ]]; then # For auto-generated fresh installs, split service auth away from the human admin account. set_env_value "NEXTCLOUD_SERVICE_USER" "${service_user_default}" service_user_current="${service_user_default}" fi ensure_secret_if_placeholder "NEXTCLOUD_SERVICE_PASSWORD" 16 "admin123" "service-oidc-change-me" service_password_current="$(read_env_value "NEXTCLOUD_SERVICE_PASSWORD" "")" admin_password_current="$(read_env_value "NEXTCLOUD_ADMIN_PASSWORD" "")" if [[ -z "${service_password_current}" ]]; then set_env_value "NEXTCLOUD_SERVICE_PASSWORD" "$(random_hex 16)" service_password_current="$(read_env_value "NEXTCLOUD_SERVICE_PASSWORD" "")" fi if [[ "${auto_generate_secrets}" == "1" && -n "${admin_password_current}" && "${service_user_current}" != "${admin_user_current}" && "${service_password_current}" == "${admin_password_current}" ]]; then set_env_value "NEXTCLOUD_SERVICE_PASSWORD" "$(random_hex 16)" fi if [[ -z "$(read_env_value "MYSQL_DATABASE" "")" ]]; then set_env_value "MYSQL_DATABASE" "nextcloud" fi if [[ -z "$(read_env_value "MYSQL_USER" "")" ]]; then set_env_value "MYSQL_USER" "nextcloud" fi ensure_secret_if_placeholder "MYSQL_ROOT_PASSWORD" 16 "rootpass" ensure_secret_if_placeholder "MYSQL_PASSWORD" 16 "nextcloudpass" if [[ -z "$(read_env_value "BROKER_DB_NAME" "")" ]]; then set_env_value "BROKER_DB_NAME" "qortal_broker" fi if [[ -z "$(read_env_value "BROKER_DB_USER" "")" ]]; then set_env_value "BROKER_DB_USER" "qortal_broker" fi ensure_secret_if_placeholder "BROKER_DB_PASSWORD" 16 "qortal_brokerpass" if [[ -z "$(read_env_value "OIDC_CLIENT_ID" "")" ]]; then set_env_value "OIDC_CLIENT_ID" "nextcloud-local" fi ensure_secret_if_placeholder "OIDC_CLIENT_SECRET" 24 "dev-secret" if [[ -z "$(read_env_value "COLLABORA_PORT" "")" ]]; then set_env_value "COLLABORA_PORT" "9980" fi if [[ -z "$(read_env_value "TALK_SIGNALING_PORT" "")" ]]; then set_env_value "TALK_SIGNALING_PORT" "8081" fi if [[ -z "$(read_env_value "TALK_SIGNALING_CONTAINER_PORT" "")" ]]; then set_env_value "TALK_SIGNALING_CONTAINER_PORT" "8081" fi if [[ -z "$(read_env_value "TALK_SIGNALING_BIND_HOST" "")" ]]; then set_env_value "TALK_SIGNALING_BIND_HOST" "0.0.0.0" fi if [[ -z "$(read_env_value "TALK_ETURNAL_EPMD_PORT" "")" ]]; then set_env_value "TALK_ETURNAL_EPMD_PORT" "3470" fi talk_signaling_port_value="$(read_env_value "TALK_SIGNALING_PORT" "8081")" talk_signaling_upstream_host_value="$(read_env_value "TALK_SIGNALING_UPSTREAM_HOST" "")" if [[ -z "${talk_signaling_upstream_host_value}" || "${talk_signaling_upstream_host_value}" == "talk_hpb" ]]; then set_env_value "TALK_SIGNALING_UPSTREAM_HOST" "host.docker.internal" fi talk_signaling_upstream_port_value="$(read_env_value "TALK_SIGNALING_UPSTREAM_PORT" "")" if [[ -z "${talk_signaling_upstream_port_value}" || "${talk_signaling_upstream_port_value}" == "8081" || "${talk_signaling_upstream_port_value}" == "8082" ]]; then set_env_value "TALK_SIGNALING_UPSTREAM_PORT" "${talk_signaling_port_value}" fi if [[ -z "$(read_env_value "TALK_HPB_IMAGE" "")" ]]; then set_env_value "TALK_HPB_IMAGE" "nuqloud-talk-hpb:local" fi if [[ -z "$(read_env_value "TALK_HPB_BASE_IMAGE" "")" ]]; then set_env_value "TALK_HPB_BASE_IMAGE" "ghcr.io/nextcloud-releases/aio-talk:latest" fi if [[ -z "$(read_env_value "TALK_TURN_BIND_HOST" "")" ]]; then set_env_value "TALK_TURN_BIND_HOST" "0.0.0.0" fi if [[ -z "$(read_env_value "TALK_TURN_PORT" "")" ]]; then set_env_value "TALK_TURN_PORT" "3478" fi signaling_domain_value="$(read_env_value "SIGNALING_DOMAIN" "")" talk_turn_host_value="$(read_env_value "TALK_TURN_HOST" "")" if [[ -z "${talk_turn_host_value}" || "${talk_turn_host_value}" == "${signaling_domain_value}" ]]; then talk_turn_host_value="${nextcloud_domain}" set_env_value "TALK_TURN_HOST" "${talk_turn_host_value}" fi talk_janus_stun_server_value="$(read_env_value "TALK_JANUS_STUN_SERVER" "")" if janus_stun_server_should_follow_default "${talk_janus_stun_server_value}" "${signaling_domain_value}" "${talk_turn_host_value}"; then set_env_value "TALK_JANUS_STUN_SERVER" "${talk_turn_host_value}" fi talk_turn_port_value="$(read_env_value "TALK_TURN_PORT" "3478")" talk_janus_stun_port_value="$(read_env_value "TALK_JANUS_STUN_PORT" "")" if [[ -z "${talk_janus_stun_port_value}" || "${talk_janus_stun_port_value}" == "3478" || "${talk_janus_stun_port_value}" == "${talk_turn_port_value}" ]]; then set_env_value "TALK_JANUS_STUN_PORT" "${talk_turn_port_value}" fi talk_stun_server_value="$(read_env_value "TALK_STUN_SERVER" "")" talk_turn_server_value="$(read_env_value "TALK_TURN_SERVER" "")" if turn_server_should_follow_talk_domain "${talk_turn_server_value}" "${talk_turn_host_value}" "${talk_turn_port_value}" "${signaling_domain_value}"; then set_env_value "TALK_TURN_SERVER" "${talk_turn_host_value}:${talk_turn_port_value}" fi if stun_server_should_follow_talk_domain "${talk_stun_server_value}" "${talk_turn_host_value}" "${signaling_domain_value}"; then set_env_value "TALK_STUN_SERVER" "${talk_turn_host_value}:${talk_turn_port_value}" fi if [[ -z "$(read_env_value "TALK_RECORDING_IMAGE" "")" ]]; then set_env_value "TALK_RECORDING_IMAGE" "nextcloud/aio-talk-recording:latest" fi if [[ -z "$(read_env_value "TALK_RECORDING_PORT" "")" ]]; then set_env_value "TALK_RECORDING_PORT" "1234" fi if [[ -z "$(read_env_value "TALK_RECORDING_URL" "")" ]]; then set_env_value "TALK_RECORDING_URL" "http://talk_recording:1234" fi ensure_secret_if_placeholder "TALK_SIGNALING_SECRET" 24 ensure_secret_if_placeholder "TALK_INTERNAL_SECRET" 24 ensure_secret_if_placeholder "TALK_TURN_SECRET" 24 ensure_secret_if_placeholder "TALK_RECORDING_SHARED_SECRET" 24 if [[ "${with_external_auth}" == "1" ]]; then set_env_value "QORTAL_EXTERNAL_AUTH_BASE_URL" "http://external_auth:3191" if [[ -z "$(read_env_value "EXTERNAL_AUTH_PORT" "")" ]]; then set_env_value "EXTERNAL_AUTH_PORT" "3191" fi if [[ "${with_qortal}" == "1" ]]; then set_env_value "QORTAL_AUTH_NODE_URL" "http://qortal_node:12391" fi set_profile_enabled "external-auth" "1" elif [[ "${with_external_auth}" == "0" ]]; then external_auth_base_url="$(read_env_value "QORTAL_EXTERNAL_AUTH_BASE_URL" "")" if [[ -z "${external_auth_base_url}" || "${external_auth_base_url}" == "http://external_auth:3191" ]]; then set_env_value "QORTAL_EXTERNAL_AUTH_BASE_URL" "http://gateway.docker.internal:3191" fi set_profile_enabled "external-auth" "0" else external_auth_base_url="$(read_env_value "QORTAL_EXTERNAL_AUTH_BASE_URL" "")" if [[ "${external_auth_base_url}" == *"external_auth"* ]]; then set_profile_enabled "external-auth" "1" fi fi if [[ "${with_office}" == "1" ]]; then set_profile_enabled "office" "1" elif [[ "${with_office}" == "0" ]]; then set_profile_enabled "office" "0" fi if [[ "${with_talk}" == "1" ]]; then set_profile_enabled "talk" "1" elif [[ "${with_talk}" == "0" ]]; then set_profile_enabled "talk" "0" fi if [[ "${with_turn}" == "1" ]]; then set_profile_enabled "turn" "1" elif [[ "${with_turn}" == "0" ]]; then set_profile_enabled "turn" "0" fi if [[ "${with_janus}" == "1" ]]; then set_profile_enabled "janus" "1" elif [[ "${with_janus}" == "0" ]]; then set_profile_enabled "janus" "0" fi if [[ "${with_recording}" == "1" ]]; then set_profile_enabled "recording" "1" elif [[ "${with_recording}" == "0" ]]; then set_profile_enabled "recording" "0" fi sync_broker_database_url } set_profile_enabled() { local profile="$1" local enabled="$2" local current profiles result local filtered=() local p p_trimmed current="$(read_env_value "COMPOSE_PROFILES" "")" IFS=',' read -r -a profiles <<< "${current}" for p in "${profiles[@]}"; do p_trimmed="$(echo "${p}" | tr -d '[:space:]')" [[ -z "${p_trimmed}" ]] && continue [[ "${p_trimmed}" == "${profile}" ]] && continue filtered+=("${p_trimmed}") done if [[ "${enabled}" == "1" ]]; then filtered+=("${profile}") fi if [[ "${#filtered[@]}" -gt 0 ]]; then result="$(IFS=','; echo "${filtered[*]}")" set_env_value "COMPOSE_PROFILES" "${result}" else set_env_value "COMPOSE_PROFILES" "" fi } sync_profile_options_from_env() { local current_profiles current_profiles="$(read_env_value "COMPOSE_PROFILES" "")" if [[ "${with_office_explicit}" != "1" ]]; then if [[ ",${current_profiles}," == *",office,"* ]]; then with_office="1" else with_office="0" fi fi if [[ "${with_talk_explicit}" != "1" ]]; then if [[ ",${current_profiles}," == *",talk,"* ]]; then with_talk="1" else with_talk="0" fi fi if [[ "${with_turn_explicit}" != "1" ]]; then if [[ ",${current_profiles}," == *",turn,"* ]]; then with_turn="1" else with_turn="0" fi fi if [[ "${with_janus_explicit}" != "1" ]]; then if [[ ",${current_profiles}," == *",janus,"* ]]; then with_janus="1" else with_janus="0" fi fi if [[ "${with_recording_explicit}" != "1" ]]; then if [[ ",${current_profiles}," == *",recording,"* ]]; then with_recording="1" else with_recording="0" fi fi if [[ "${with_signaling_explicit}" != "1" ]]; then if [[ "${with_talk}" == "1" || "${with_turn}" == "1" || "${with_janus}" == "1" || "${with_recording}" == "1" ]]; then with_signaling="1" else with_signaling="0" fi fi } has_meaningful_env_value() { local key="$1" local value placeholder shift || true value="$(read_env_value "${key}" "")" value="$(echo "${value}" | tr -d '\r' | xargs)" if [[ -z "${value}" ]]; then return 1 fi for placeholder in "$@"; do if [[ -n "${placeholder}" && "${value}" == "${placeholder}" ]]; then return 1 fi done return 0 } sync_broker_database_url() { local db_name db_user db_password expected current db_name="$(read_env_value "BROKER_DB_NAME" "qortal_broker")" db_user="$(read_env_value "BROKER_DB_USER" "qortal_broker")" db_password="$(read_env_value "BROKER_DB_PASSWORD" "qortal_brokerpass")" expected="postgresql://${db_user}:${db_password}@broker_db:5432/${db_name}" current="$(read_env_value "BROKER_DATABASE_URL" "")" if [[ "${current}" != "${expected}" ]]; then echo "Aligning BROKER_DATABASE_URL with BROKER_DB_* values." set_env_value "BROKER_DATABASE_URL" "${expected}" fi } run_guided_setup() { local admin_user admin_password service_user_default service_password_default local nc_domain broker_domain collabora_domain signaling_domain https_port nc_public_url broker_public_url collabora_public_url signaling_public_url local external_auth_use_bundled="0" local node_api_key_required="0" local msp_installation="0" local current_external_auth_url local current_profiles prompt_default local show_advanced_prompts="0" echo echo "Guided setup for ${env_file}" echo "Press Enter to keep current values." echo "Standard guided mode auto-handles host ports and generates missing service/DB secrets." echo "Only cloud domains and the human Nextcloud admin login are prompted by default." echo prompt_env_value "NEXTCLOUD_DOMAIN" "cloud.example.test" "Nextcloud domain (public)" prompt_env_value "BROKER_DOMAIN" "qortalbroker.example.test" "Broker domain (public)" prompt_env_value "COLLABORA_DOMAIN" "$(derive_prefixed_domain "office" "$(read_env_value "NEXTCLOUD_DOMAIN" "cloud.example.test")")" "Collabora domain (public)" prompt_env_value "SIGNALING_DOMAIN" "$(derive_prefixed_domain "talk" "$(read_env_value "NEXTCLOUD_DOMAIN" "cloud.example.test")")" "Signaling domain (public)" prompt_nextcloud_image_selection prompt_env_value "NEXTCLOUD_CUSTOM_APPS_PATH" "./nextcloud/custom_apps" "Nextcloud custom_apps host path" if prompt_yes_no "Show advanced prompts (host ports, service/DB credentials, extra secrets)?" "N"; then show_advanced_prompts="1" fi if [[ "${show_advanced_prompts}" == "1" ]]; then if [[ "${mode}" == "nossl" ]]; then prompt_env_value "DEVPROD_HTTP_PORT" "8081" "Nextcloud host HTTP port (internal)" prompt_env_value "DEVPROD_BROKER_PORT" "3001" "Broker host HTTP port (internal)" prompt_env_value "PUBLIC_HTTPS_PORT" "443" "Public HTTPS port from reverse proxy" set_env_value "CADDY_HTTP_PORT" "$(read_env_value "CADDY_HTTP_PORT" "80")" set_env_value "CADDY_HTTPS_PORT" "$(read_env_value "CADDY_HTTPS_PORT" "443")" else prompt_env_value "CADDY_HTTP_PORT" "80" "Caddy HTTP port" prompt_env_value "CADDY_HTTPS_PORT" "443" "Caddy HTTPS port" prompt_env_value "CADDY_EMAIL" "$(default_caddy_email_for_domain "$(read_env_value "NEXTCLOUD_DOMAIN" "cloud.example.test")")" "Caddy email" prompt_env_value "CADDY_TLS" "" "Caddy TLS directive (blank or 'tls internal')" fi elif [[ "${mode}" == "nossl" ]]; then set_env_value "CADDY_HTTP_PORT" "$(read_env_value "CADDY_HTTP_PORT" "80")" set_env_value "CADDY_HTTPS_PORT" "$(read_env_value "CADDY_HTTPS_PORT" "443")" fi prompt_env_value "NEXTCLOUD_ADMIN_USER" "admin" "Nextcloud admin user" prompt_env_value "NEXTCLOUD_ADMIN_PASSWORD" "admin123" "Nextcloud admin password" "1" if prompt_yes_no "Is this an MSP installation (enable delegated MSP admin group support)?" "N"; then msp_installation="1" fi set_env_value "MSP_MODE_ENABLED" "${msp_installation}" if [[ "${msp_installation}" == "1" ]]; then prompt_env_value "MSP_ADMIN_GROUP" "MSP_Admin" "Delegated MSP admin group ID" else set_env_value "MSP_ADMIN_GROUP" "$(read_env_value "MSP_ADMIN_GROUP" "MSP_Admin")" fi admin_user="$(read_env_value "NEXTCLOUD_ADMIN_USER" "admin")" admin_password="$(read_env_value "NEXTCLOUD_ADMIN_PASSWORD" "admin123")" service_user_default="$(read_env_value "NEXTCLOUD_SERVICE_USER" "nuqloud_oidc_service")" service_password_default="$(read_env_value "NEXTCLOUD_SERVICE_PASSWORD" "")" if [[ "${show_advanced_prompts}" == "1" ]]; then prompt_env_value "NEXTCLOUD_SERVICE_USER" "${service_user_default}" "Broker Nextcloud service user" prompt_env_value "NEXTCLOUD_SERVICE_PASSWORD" "${service_password_default}" "Broker Nextcloud service password" "1" prompt_env_value "MYSQL_ROOT_PASSWORD" "rootpass" "MariaDB root password" "1" prompt_env_value "MYSQL_DATABASE" "nextcloud" "MariaDB Nextcloud database" prompt_env_value "MYSQL_USER" "nextcloud" "MariaDB Nextcloud user" prompt_env_value "MYSQL_PASSWORD" "nextcloudpass" "MariaDB Nextcloud user password" "1" prompt_env_value "BROKER_DB_NAME" "qortal_broker" "Broker PostgreSQL database" prompt_env_value "BROKER_DB_USER" "qortal_broker" "Broker PostgreSQL user" prompt_env_value "BROKER_DB_PASSWORD" "qortal_brokerpass" "Broker PostgreSQL password" "1" fi nc_domain="$(read_env_value "NEXTCLOUD_DOMAIN" "cloud.example.test")" broker_domain="$(read_env_value "BROKER_DOMAIN" "qortalbroker.example.test")" if [[ "${mode}" == "nossl" ]]; then https_port="$(read_env_value "PUBLIC_HTTPS_PORT" "443")" else https_port="$(read_env_value "CADDY_HTTPS_PORT" "443")" fi nc_public_url="$(compute_public_url "${nc_domain}" "${https_port}")" broker_public_url="$(compute_public_url "${broker_domain}" "${https_port}")" collabora_domain="$(read_env_value "COLLABORA_DOMAIN" "")" if [[ -z "${collabora_domain}" || "${collabora_domain}" == "office.cloud.example.test" ]]; then collabora_domain="$(derive_prefixed_domain "office" "${nc_domain}")" set_env_value "COLLABORA_DOMAIN" "${collabora_domain}" fi signaling_domain="$(read_env_value "SIGNALING_DOMAIN" "")" if [[ -z "${signaling_domain}" ]]; then signaling_domain="$(read_env_value "TALK_DOMAIN" "")" fi if [[ -z "${signaling_domain}" || "${signaling_domain}" == "talk.cloud.example.test" ]]; then signaling_domain="$(derive_prefixed_domain "talk" "${nc_domain}")" set_env_value "SIGNALING_DOMAIN" "${signaling_domain}" fi set_env_value "TALK_DOMAIN" "${signaling_domain}" collabora_public_url="$(compute_public_url "${collabora_domain}" "${https_port}")" signaling_public_url="$(compute_public_url "${signaling_domain}" "${https_port}")" set_env_value "NEXTCLOUD_TRUSTED_DOMAINS" "${nc_domain}" set_env_value "NEXTCLOUD_BASE_URL" "http://app" set_env_value "NEXTCLOUD_PUBLIC_URL" "${nc_public_url}" set_env_value "BROKER_CORS_ALLOWED_ORIGINS" "${nc_public_url}" set_env_value "OIDC_ISSUER" "${broker_public_url}" set_env_value "OIDC_REDIRECT_URI_ALLOWLIST" "${nc_public_url}/apps/user_oidc/code" set_env_value "COLLABORA_PUBLIC_URL" "${collabora_public_url}" set_env_value "TALK_SIGNALING_PUBLIC_URL" "${signaling_public_url}" prompt_env_value "OIDC_CLIENT_ID" "nextcloud-local" "OIDC client ID" if [[ "${show_advanced_prompts}" == "1" ]]; then prompt_env_value "OIDC_CLIENT_SECRET" "dev-secret" "OIDC client secret" "1" fi prompt_env_value "OIDC_POLICY_MODE" "auto_provision" "OIDC policy mode (link_only/auto_provision)" prompt_env_value "OIDC_AUTO_PROVISION_GUARD" "invite_or_allowlist" "OIDC auto-provision guard" prompt_env_value "OIDC_REQUIRE_EMAIL_FOR_NEW_ACCOUNT" "true" "Require email for new accounts (true/false)" if [[ "${with_external_auth}" == "1" ]]; then external_auth_use_bundled="1" elif [[ "${with_external_auth}" == "0" ]]; then external_auth_use_bundled="0" else current_external_auth_url="$(read_env_value "QORTAL_EXTERNAL_AUTH_BASE_URL" "http://gateway.docker.internal:3191")" if [[ "${current_external_auth_url}" == *"external_auth"* ]]; then if prompt_yes_no "Use bundled External Auth container for this cloud (docker service 'external_auth')?" "Y"; then external_auth_use_bundled="1" fi else if prompt_yes_no "Use bundled External Auth container for this cloud (docker service 'external_auth')?" "N"; then external_auth_use_bundled="1" fi fi fi if [[ "${external_auth_use_bundled}" == "1" ]]; then if [[ "${with_qortal}" != "1" ]]; then echo "Bundled external_auth requires bundled qortal_node; enabling --with-qortal." with_qortal="1" fi with_external_auth="1" if [[ "${show_advanced_prompts}" == "1" ]]; then prompt_env_value "EXTERNAL_AUTH_PORT" "3191" "Bundled External Auth host port" fi set_env_value "QORTAL_EXTERNAL_AUTH_BASE_URL" "http://external_auth:3191" set_profile_enabled "external-auth" "1" if [[ "${with_qortal}" == "1" ]]; then set_env_value "QORTAL_AUTH_NODE_URL" "http://qortal_node:12391" if prompt_yes_no "Does your Qortal node require X-API-KEY?" "N"; then node_api_key_required="1" fi if [[ "${node_api_key_required}" == "1" ]]; then prompt_env_value "QORTAL_AUTH_NODE_API_KEY" "" "Qortal node API key" "1" set_env_value "QORTAL_AUTH_NODE_API_KEY_MODE" "paths" set_env_value "QORTAL_AUTH_NODE_API_KEY_PATHS" "/" else set_env_value "QORTAL_AUTH_NODE_API_KEY" "" fi fi else with_external_auth="0" prompt_env_value "QORTAL_EXTERNAL_AUTH_BASE_URL" "http://gateway.docker.internal:3191" "External Auth base URL" set_profile_enabled "external-auth" "0" fi prompt_env_value "QORTAL_EXTERNAL_AUTH_APP_ID" "" "External Auth app ID" prompt_env_value "QORTAL_EXTERNAL_AUTH_APP_SECRET" "" "External Auth app secret" "1" current_profiles="$(read_env_value "COMPOSE_PROFILES" "")" prompt_default="N" if [[ ",${current_profiles}," == *",office,"* ]]; then prompt_default="Y" elif has_meaningful_env_value "COLLABORA_DOMAIN" "office.cloud.example.test"; then prompt_default="Y" elif has_meaningful_env_value "COLLABORA_PUBLIC_URL"; then prompt_default="Y" fi if prompt_yes_no "Enable Nextcloud Office (Collabora) profile?" "${prompt_default}"; then set_profile_enabled "office" "1" else set_profile_enabled "office" "0" fi prompt_default="N" if [[ ",${current_profiles}," == *",talk,"* || ",${current_profiles}," == *",turn,"* || ",${current_profiles}," == *",janus,"* ]]; then prompt_default="Y" elif has_meaningful_env_value "SIGNALING_DOMAIN" "talk.cloud.example.test"; then prompt_default="Y" elif has_meaningful_env_value "TALK_SIGNALING_PUBLIC_URL"; then prompt_default="Y" elif has_meaningful_env_value "TALK_SIGNALING_SECRET"; then prompt_default="Y" elif has_meaningful_env_value "TALK_INTERNAL_SECRET"; then prompt_default="Y" elif has_meaningful_env_value "TALK_TURN_SECRET"; then prompt_default="Y" elif has_meaningful_env_value "TALK_RECORDING_SHARED_SECRET"; then prompt_default="Y" fi if prompt_yes_no "Enable signaling stack (talk + turn + janus + recording)?" "${prompt_default}"; then set_profile_enabled "talk" "1" set_profile_enabled "turn" "1" set_profile_enabled "janus" "1" set_profile_enabled "recording" "1" else set_profile_enabled "talk" "0" set_profile_enabled "turn" "0" set_profile_enabled "janus" "0" set_profile_enabled "recording" "0" fi prompt_default="Y" if ! install_custom_pwa_enabled; then prompt_default="N" fi if prompt_yes_no "Install/enable CustomPWA app?" "${prompt_default}"; then set_env_value "INSTALL_CUSTOM_PWA" "1" else set_env_value "INSTALL_CUSTOM_PWA" "0" fi prompt_default_app_bundle_selection sync_broker_database_url echo echo "Guided setup complete." echo } prompt_default_app_bundle_selection() { local prompt_default="N" if install_default_app_bundle_enabled; then prompt_default="Y" fi if prompt_yes_no "Install/enable the recommended default Nextcloud app bundle?" "${prompt_default}"; then set_env_value "INSTALL_DEFAULT_APP_BUNDLE" "1" else set_env_value "INSTALL_DEFAULT_APP_BUNDLE" "0" fi } resolve_context_path() { local raw_path="$1" if [[ "${raw_path}" == /* ]]; then echo "${raw_path}" else echo "${repo_root}/${raw_path}" fi } ensure_context_repo() { local label="$1" local target_path="$2" local repo_url="$3" if [[ -d "${target_path}/.git" ]]; then echo "Using existing ${label} repo at ${target_path}" return 0 fi if [[ -e "${target_path}" && ! -d "${target_path}/.git" ]]; then echo "${label} context exists but is not a git repo: ${target_path}" echo "Set a valid path in ${env_file} or remove this path." exit 1 fi echo "Cloning ${label} repo into ${target_path}" mkdir -p "$(dirname "${target_path}")" if ! git clone --depth 1 "${repo_url}" "${target_path}"; then echo "Failed to clone ${label} from ${repo_url}" echo "Set the context manually in ${env_file} and rerun." exit 1 fi } if [[ "${configure_only}" != "1" ]]; then ensure_docker_access if ! docker compose version >/dev/null 2>&1; then echo "docker compose plugin is required" exit 1 fi fi env_template="${repo_root}/.env.devprod.example" if [[ ! -f "${env_template}" ]]; then echo "Missing env template: ${env_template}" exit 1 fi if [[ "${env_file}" != /* ]]; then env_file="${repo_root}/${env_file}" fi created_env="0" if [[ ! -f "${env_file}" ]]; then cp "${env_template}" "${env_file}" created_env="1" echo "Created ${env_file} from template." fi if [[ "${created_env}" == "1" ]]; then seeded_compose_project_name="$(derive_compose_project_name_seed "${env_file}")" set_compose_project_name_in_env "${env_file}" "${seeded_compose_project_name}" export COMPOSE_PROJECT_NAME="${seeded_compose_project_name}" echo "Pinned COMPOSE_PROJECT_NAME=${seeded_compose_project_name}" else if ! export_compose_project_name_from_env "${env_file}"; then warn_missing_compose_project_name "${env_file}" fi fi if [[ "${created_env}" == "1" || "${one_click}" == "1" ]]; then auto_generate_secrets="1" fi if [[ -n "${nextcloud_domain_input}" ]]; then set_env_value "NEXTCLOUD_DOMAIN" "${nextcloud_domain_input}" fi if [[ -n "${broker_domain_input}" ]]; then set_env_value "BROKER_DOMAIN" "${broker_domain_input}" fi if [[ -n "${collabora_domain_input}" ]]; then set_env_value "COLLABORA_DOMAIN" "${collabora_domain_input}" fi if [[ -n "${signaling_domain_input}" ]]; then set_env_value "SIGNALING_DOMAIN" "${signaling_domain_input}" set_env_value "TALK_DOMAIN" "${signaling_domain_input}" fi if [[ -n "${install_custom_pwa_override}" ]]; then set_env_value "INSTALL_CUSTOM_PWA" "${install_custom_pwa_override}" fi if [[ -n "${install_default_app_bundle_override}" ]]; then set_env_value "INSTALL_DEFAULT_APP_BUNDLE" "${install_default_app_bundle_override}" fi auto_derive_env_defaults run_guided="0" if [[ "${guided_mode}" == "1" ]]; then run_guided="1" elif [[ "${guided_mode}" == "auto" ]]; then if [[ "${created_env}" == "1" && "${accept_defaults}" != "1" ]]; then run_guided="1" elif [[ "${created_env}" == "0" && "${accept_defaults}" != "1" && -t 0 ]]; then read -r -p "Run guided setup to review/update ${env_file}? (y/N): " run_guided_prompt if [[ "${run_guided_prompt}" =~ ^[Yy]$ ]]; then run_guided="1" fi fi fi if [[ "${run_guided}" == "1" ]]; then if [[ ! -t 0 ]]; then echo "Guided setup requires an interactive terminal." echo "Use --accept-defaults for non-interactive smoke testing." exit 1 fi run_guided_setup sync_profile_options_from_env # Re-derive dependent URLs and Talk defaults after interactive domain/profile edits. auto_derive_env_defaults sync_broker_database_url if [[ "${configure_only}" != "1" ]]; then if ! prompt_yes_no "Configuration complete. Start/rebuild containers now?" "N"; then configure_only="1" fi fi elif [[ "${created_env}" == "1" && "${accept_defaults}" != "1" ]]; then cat <}" 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 '^COMPOSE_PROFILES=' "${env_file}" || true)" fi if [[ -n "${profiles_line}" ]]; then current_profiles="${profiles_line#COMPOSE_PROFILES=}" fi external_auth_base_url="$(read_env_value "QORTAL_EXTERNAL_AUTH_BASE_URL" "")" if [[ "${with_external_auth}" == "1" ]]; then if [[ -z "${current_profiles}" ]]; then current_profiles="external-auth" elif [[ ",${current_profiles}," != *",external-auth,"* ]]; then current_profiles="${current_profiles},external-auth" fi elif [[ "${with_external_auth}" == "0" ]]; then filtered=() IFS=',' read -r -a profile_parts <<< "${current_profiles}" for p in "${profile_parts[@]}"; do p_trimmed="$(echo "${p}" | tr -d '[:space:]')" [[ -z "${p_trimmed}" ]] && continue [[ "${p_trimmed}" == "external-auth" ]] && continue filtered+=("${p_trimmed}") done current_profiles="$(IFS=','; echo "${filtered[*]}")" elif [[ "${with_external_auth}" == "auto" ]]; then # If broker points to bundled service hostname, enable profile automatically. if [[ "${external_auth_base_url}" == *"external_auth"* ]]; then if [[ -z "${current_profiles}" ]]; then current_profiles="external-auth" elif [[ ",${current_profiles}," != *",external-auth,"* ]]; then current_profiles="${current_profiles},external-auth" fi fi fi if [[ -n "${current_profiles}" ]]; then export COMPOSE_PROFILES="${current_profiles}" else unset COMPOSE_PROFILES || true fi if [[ "${with_qortal}" == "0" && ",${COMPOSE_PROFILES:-}," == *",external-auth,"* ]]; then echo "Cannot run --without-qortal while external-auth profile is enabled." exit 1 fi office_profile_enabled="0" talk_profile_enabled="0" turn_profile_enabled="0" janus_profile_enabled="0" recording_profile_enabled="0" if [[ ",${COMPOSE_PROFILES:-}," == *",office,"* ]]; then office_profile_enabled="1" fi if [[ ",${COMPOSE_PROFILES:-}," == *",talk,"* ]]; then talk_profile_enabled="1" fi if [[ ",${COMPOSE_PROFILES:-}," == *",turn,"* ]]; then turn_profile_enabled="1" talk_profile_enabled="1" fi if [[ ",${COMPOSE_PROFILES:-}," == *",janus,"* ]]; then janus_profile_enabled="1" talk_profile_enabled="1" fi if [[ ",${COMPOSE_PROFILES:-}," == *",recording,"* ]]; then recording_profile_enabled="1" talk_profile_enabled="1" fi nextcloud_custom_apps_raw="$(read_env_value "NEXTCLOUD_CUSTOM_APPS_PATH" "./nextcloud/custom_apps")" nextcloud_custom_apps_path="$(resolve_context_path "${nextcloud_custom_apps_raw}")" 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 prompt_qortal_branch_if_needed if [[ "${with_qortal}" == "1" ]]; then qortal_context_raw="$(read_env_value "QORTAL_NODE_CONTEXT" "../qortal")" qortal_context_path="$(resolve_context_path "${qortal_context_raw}")" if [[ -x "${repo_root}/scripts/ensure-qortal-core-repo.sh" ]]; then ensure_qortal_args=(--target-path "${qortal_context_path}") if [[ -n "${qortal_branch}" ]]; then ensure_qortal_args+=(--branch "${qortal_branch}" --refresh) fi "${repo_root}/scripts/ensure-qortal-core-repo.sh" "${ensure_qortal_args[@]}" else ensure_context_repo "Qortal Core" "${qortal_context_path}" "${default_qortal_repo_url}" fi mkdir -p "${repo_root}/qortal/data" if [[ -x "${repo_root}/scripts/ensure-qortal-settings.sh" ]]; then "${repo_root}/scripts/ensure-qortal-settings.sh" fi if [[ -x "${repo_root}/scripts/ensure-qortal-start-args.sh" ]]; then "${repo_root}/scripts/ensure-qortal-start-args.sh" "${env_file}" fi if [[ -x "${repo_root}/scripts/select-qortal-p2p-port.sh" ]]; then "${repo_root}/scripts/select-qortal-p2p-port.sh" "${env_file}" fi fi if [[ ",${COMPOSE_PROFILES:-}," == *",external-auth,"* ]]; then external_auth_context_raw="$(read_env_value "EXTERNAL_AUTH_CONTEXT" "../Qortal-External-Auth")" external_auth_context_path="$(resolve_context_path "${external_auth_context_raw}")" external_auth_repo_url="${EXTERNAL_AUTH_GIT_URL:-${default_external_auth_repo_url}}" ensure_context_repo "Qortal External Auth" "${external_auth_context_path}" "${external_auth_repo_url}" fi if [[ -x "${repo_root}/scripts/select-host-service-ports.sh" ]]; then "${repo_root}/scripts/select-host-service-ports.sh" \ --env-file "${env_file}" \ --mode "${mode}" \ --compose-file "${compose_file}" \ --skip-when-running fi existing_stack_ids="$(docker compose -f "${compose_file}" --env-file "${env_file}" ps -q 2>/dev/null || true)" if [[ -z "${existing_stack_ids}" ]]; then port_conflicts=() if [[ "${mode}" == "nossl" ]]; then devprod_http_port="$(read_env_value "DEVPROD_HTTP_PORT" "8081")" devprod_broker_port="$(read_env_value "DEVPROD_BROKER_PORT" "3001")" if is_port_in_use "127.0.0.1" "${devprod_http_port}"; then port_conflicts+=("127.0.0.1:${devprod_http_port} (DEVPROD_HTTP_PORT)") fi if is_port_in_use "127.0.0.1" "${devprod_broker_port}"; then port_conflicts+=("127.0.0.1:${devprod_broker_port} (DEVPROD_BROKER_PORT)") fi else caddy_http_port="$(read_env_value "CADDY_HTTP_PORT" "80")" caddy_https_port="$(read_env_value "CADDY_HTTPS_PORT" "443")" if is_port_in_use "" "${caddy_http_port}"; then port_conflicts+=("0.0.0.0:${caddy_http_port} (CADDY_HTTP_PORT)") fi if is_port_in_use "" "${caddy_https_port}"; then port_conflicts+=("0.0.0.0:${caddy_https_port} (CADDY_HTTPS_PORT)") fi fi if [[ ",${COMPOSE_PROFILES:-}," == *",external-auth,"* ]]; then external_auth_port="$(read_env_value "EXTERNAL_AUTH_PORT" "3191")" if is_port_in_use "127.0.0.1" "${external_auth_port}"; then port_conflicts+=("127.0.0.1:${external_auth_port} (EXTERNAL_AUTH_PORT)") fi fi if [[ "${office_profile_enabled}" == "1" ]]; then collabora_port="$(read_env_value "COLLABORA_PORT" "9980")" if is_port_in_use "127.0.0.1" "${collabora_port}"; then port_conflicts+=("127.0.0.1:${collabora_port} (COLLABORA_PORT)") fi fi if [[ "${talk_profile_enabled}" == "1" ]]; then talk_signaling_bind_host="$(read_env_value "TALK_SIGNALING_BIND_HOST" "0.0.0.0")" talk_signaling_port="$(read_env_value "TALK_SIGNALING_PORT" "8081")" if is_port_in_use "${talk_signaling_bind_host}" "${talk_signaling_port}"; then port_conflicts+=("${talk_signaling_bind_host}:${talk_signaling_port} (TALK_SIGNALING_PORT)") fi if [[ "${mode}" == "nossl" && "${devprod_http_port:-}" == "${talk_signaling_port}" ]]; then port_conflicts+=("127.0.0.1:${devprod_http_port} (DEVPROD_HTTP_PORT conflicts with TALK_SIGNALING_PORT)") fi fi if [[ "${talk_profile_enabled}" == "1" ]]; then talk_turn_bind_host="$(read_env_value "TALK_TURN_BIND_HOST" "0.0.0.0")" talk_turn_port="$(read_env_value "TALK_TURN_PORT" "3478")" if is_port_in_use "${talk_turn_bind_host}" "${talk_turn_port}"; then port_conflicts+=("${talk_turn_bind_host}:${talk_turn_port} (TALK_TURN_PORT)") fi fi if [[ "${recording_profile_enabled}" == "1" ]]; then talk_recording_port="$(read_env_value "TALK_RECORDING_PORT" "1234")" if is_port_in_use "127.0.0.1" "${talk_recording_port}"; then port_conflicts+=("127.0.0.1:${talk_recording_port} (TALK_RECORDING_PORT)") fi fi if [[ "${with_qortal}" == "1" ]]; then qortal_gateway_port="$(read_env_value "QORTAL_NODE_GATEWAY_HOST_PORT" "12390")" qortal_api_port="$(read_env_value "QORTAL_NODE_API_HOST_PORT" "12391")" qortal_p2p_port="$(read_env_value "QORTAL_NODE_P2P_HOST_PORT" "12392")" qortal_qdn_port="$(read_env_value "QORTAL_NODE_QDN_HOST_PORT" "12394")" qortal_gateway_bind="$(read_env_value "QORTAL_NODE_GATEWAY_BIND_HOST" "127.0.0.1")" qortal_api_bind="$(read_env_value "QORTAL_NODE_API_BIND_HOST" "127.0.0.1")" qortal_p2p_bind="$(read_env_value "QORTAL_NODE_P2P_BIND_HOST" "0.0.0.0")" qortal_qdn_bind="$(read_env_value "QORTAL_NODE_QDN_BIND_HOST" "0.0.0.0")" if is_port_in_use "${qortal_gateway_bind}" "${qortal_gateway_port}"; then port_conflicts+=("${qortal_gateway_bind}:${qortal_gateway_port} (QORTAL_NODE_GATEWAY_HOST_PORT)") fi if is_port_in_use "${qortal_api_bind}" "${qortal_api_port}"; then port_conflicts+=("${qortal_api_bind}:${qortal_api_port} (QORTAL_NODE_API_HOST_PORT)") fi if is_port_in_use "${qortal_p2p_bind}" "${qortal_p2p_port}"; then port_conflicts+=("${qortal_p2p_bind}:${qortal_p2p_port} (QORTAL_NODE_P2P_HOST_PORT)") fi if is_port_in_use "${qortal_qdn_bind}" "${qortal_qdn_port}"; then port_conflicts+=("${qortal_qdn_bind}:${qortal_qdn_port} (QORTAL_NODE_QDN_HOST_PORT)") fi fi if [[ "${#port_conflicts[@]}" -gt 0 ]]; then echo "Port conflict detected. Update these values in ${env_file}:" for entry in "${port_conflicts[@]}"; do echo " - ${entry}" done exit 1 fi else echo "Existing compose project detected; skipping host port conflict preflight." fi echo "Using compose file: ${compose_file}" echo "Using env file: ${env_file}" echo "Using profiles: ${COMPOSE_PROFILES:-}" if [[ "${with_qortal}" == "1" ]]; then docker compose -f "${compose_file}" --env-file "${env_file}" up -d --build else mapfile -t services < <(docker compose -f "${compose_file}" --env-file "${env_file}" config --services) target_services=() for svc in "${services[@]}"; do if [[ "${svc}" == "qortal_node" ]]; then continue fi target_services+=("${svc}") done if [[ "${#target_services[@]}" -eq 0 ]]; then echo "No services selected to start." exit 1 fi docker compose -f "${compose_file}" --env-file "${env_file}" up -d --build "${target_services[@]}" fi # Apply local Nextcloud core/theming patches after first container startup so the bind-mounted # ./nextcloud/html tree has been populated by the Nextcloud image entrypoint. nextcloud_html_root="${repo_root}/nextcloud/html" pwa_theming_patch="${repo_root}/patches/nextcloud-pwa-theming-fix.patch" if [[ -f "${pwa_theming_patch}" ]]; then for _ in $(seq 1 24); do if [[ -f "${nextcloud_html_root}/apps/theming/lib/ThemingDefaults.php" ]]; then break fi sleep 2 done if [[ -f "${nextcloud_html_root}/apps/theming/lib/ThemingDefaults.php" ]]; then apply_nextcloud_local_patch_if_present \ "${pwa_theming_patch}" \ "${nextcloud_html_root}" \ "Force instance branding for the installable shell manifest." \ "apps/theming/lib/ThemingDefaults.php" || true 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 [[ "${skip_occ}" != "1" ]]; then 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 docker compose -f "${compose_file}" --env-file "${env_file}" 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 oidc_issuer="$(read_env_value "OIDC_ISSUER" "")" oidc_client_id="$(read_env_value "OIDC_CLIENT_ID" "nextcloud-local")" oidc_client_secret="$(read_env_value "OIDC_CLIENT_SECRET" "")" collabora_public_url="$(read_env_value "COLLABORA_PUBLIC_URL" "")" talk_signaling_public_url="$(read_env_value "TALK_SIGNALING_PUBLIC_URL" "")" talk_signaling_secret="$(read_env_value "TALK_SIGNALING_SECRET" "")" talk_stun_server="$(read_env_value "TALK_STUN_SERVER" "")" talk_turn_server="$(read_env_value "TALK_TURN_SERVER" "")" talk_domain="$(read_env_value "SIGNALING_DOMAIN" "")" talk_turn_host="$(read_env_value "TALK_TURN_HOST" "")" if [[ -z "${talk_turn_host}" ]]; then talk_turn_host="$(read_env_value "NEXTCLOUD_DOMAIN" "")" fi talk_turn_port="$(read_env_value "TALK_TURN_PORT" "3478")" talk_turn_secret="$(read_env_value "TALK_TURN_SECRET" "")" if [[ -z "${talk_turn_server}" ]]; then talk_turn_server="${talk_turn_host}:${talk_turn_port}" fi talk_recording_url="$(read_env_value "TALK_RECORDING_URL" "http://talk_recording:1234")" talk_recording_secret="$(read_env_value "TALK_RECORDING_SHARED_SECRET" "")" oidc_provider_config_status=0 oidc_provider_skipped=0 office_install_status=0 office_enable_status=0 office_wopi_status=0 office_public_wopi_status=0 talk_install_status=0 talk_enable_status=0 talk_signaling_add_status=0 talk_signaling_skipped=0 talk_stun_add_status=0 talk_stun_skipped=0 talk_turn_add_status=0 talk_turn_skipped=0 talk_recording_add_status=0 talk_recording_skipped=0 talk_recording_command_missing=0 talk_namespace_missing=0 talk_command_prefix="" talk_recording_prefix="" notify_push_enable_status=0 notify_push_install_status=0 notify_push_verify_status=0 repair_expensive_status=0 talk_signaling_prune_status=0 talk_signaling_multi_count=0 office_verify_status=0 office_verify_skipped=1 talk_verify_status=0 talk_verify_skipped=1 public_env_resync_status=0 nextcloud_url_verify_status=0 custom_pwa_push_status=0 custom_pwa_push_skipped=1 qortal_runtime_sync_status=0 qortal_runtime_sync_skipped=1 oidc_discovery_url="" if [[ -n "${oidc_issuer}" ]]; then oidc_discovery_url="${oidc_issuer%/}/.well-known/openid-configuration" fi if occ_has_command "talk:signaling:list"; then talk_command_prefix="talk" elif occ_has_command "spreed:signaling:list"; then talk_command_prefix="spreed" fi if occ_has_command "talk:recording:add"; then talk_recording_prefix="talk" elif occ_has_command "spreed:recording:add"; then talk_recording_prefix="spreed" fi set +e cleanup_nextcloud_defaults 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 custom_apps_to_enable=(chd_admin qortal_files_bridge qortal_talk_bridge nuqloud_scrum) if install_custom_pwa_enabled; then custom_apps_to_enable=(custom_pwa "${custom_apps_to_enable[@]}") fi for custom_app in "${custom_apps_to_enable[@]}"; do docker compose -f "${compose_file}" --env-file "${env_file}" exec -T app php occ app:enable "${custom_app}" >/dev/null 2>&1 || true done docker compose -f "${compose_file}" --env-file "${env_file}" 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 docker compose -f "${compose_file}" --env-file "${env_file}" exec -T app php occ app:install user_oidc >/dev/null 2>&1 install_oidc_status=$? docker compose -f "${compose_file}" --env-file "${env_file}" exec -T app php occ app:enable --force user_oidc >/dev/null 2>&1 enable_oidc_status=$? fi docker compose -f "${compose_file}" --env-file "${env_file}" exec -T app php occ app:enable qortal_integration >/dev/null 2>&1 enable_qortal_status=$? docker compose -f "${compose_file}" --env-file "${env_file}" 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 docker compose -f "${compose_file}" --env-file "${env_file}" exec -T app php occ app:install notify_push >/dev/null 2>&1 notify_push_install_status=$? docker compose -f "${compose_file}" --env-file "${env_file}" exec -T app php occ app:enable --force notify_push >/dev/null 2>&1 notify_push_enable_status=$? fi docker compose -f "${compose_file}" --env-file "${env_file}" exec -T app php occ app:list >/tmp/install_occ_app_list_notify_push.txt 2>/dev/null if grep -qE '^[[:space:]]+- notify_push([[:space:]:]|$)' /tmp/install_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/install_occ_app_list_notify_push.txt >/dev/null 2>&1 || true docker compose -f "${compose_file}" --env-file "${env_file}" 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 docker compose -f "${compose_file}" --env-file "${env_file}" 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="$(docker compose -f "${compose_file}" --env-file "${env_file}" 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 docker compose -f "${compose_file}" --env-file "${env_file}" exec -T app php occ config:system:delete user_oidc login_label >/dev/null 2>&1 oidc_login_label_status=$? else 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" >/dev/null 2>&1 oidc_login_label_status=$? fi if [[ "${office_profile_enabled}" == "1" ]]; then docker compose -f "${compose_file}" --env-file "${env_file}" exec -T app php occ app:enable --force richdocuments >/dev/null 2>&1 office_enable_status=$? if [[ "${office_enable_status}" -ne 0 ]]; then docker compose -f "${compose_file}" --env-file "${env_file}" exec -T app php occ app:install richdocuments >/dev/null 2>&1 office_install_status=$? docker compose -f "${compose_file}" --env-file "${env_file}" exec -T app php occ app:enable --force richdocuments >/dev/null 2>&1 office_enable_status=$? fi if [[ -n "${collabora_public_url}" ]]; then docker compose -f "${compose_file}" --env-file "${env_file}" exec -T app php occ config:app:set richdocuments wopi_url --value="${collabora_public_url}" >/dev/null 2>&1 office_wopi_status=$? docker compose -f "${compose_file}" --env-file "${env_file}" exec -T app php occ config:app:set richdocuments public_wopi_url --value="${collabora_public_url}" >/dev/null 2>&1 office_public_wopi_status=$? else office_wopi_status=1 fi fi if [[ "${talk_profile_enabled}" == "1" ]]; then docker compose -f "${compose_file}" --env-file "${env_file}" exec -T app php occ app:enable --force spreed >/dev/null 2>&1 talk_enable_status=$? if [[ "${talk_enable_status}" -ne 0 ]]; then docker compose -f "${compose_file}" --env-file "${env_file}" exec -T app php occ app:install spreed >/dev/null 2>&1 talk_install_status=$? docker compose -f "${compose_file}" --env-file "${env_file}" exec -T app php occ app:enable --force spreed >/dev/null 2>&1 talk_enable_status=$? fi if [[ -z "${talk_command_prefix}" ]]; then talk_namespace_missing=1 talk_signaling_skipped=1 talk_stun_skipped=1 if [[ "${turn_profile_enabled}" == "1" ]]; then talk_turn_skipped=1 fi else if [[ -n "${talk_signaling_public_url}" && -n "${talk_signaling_secret}" ]]; then docker compose -f "${compose_file}" --env-file "${env_file}" exec -T app php occ "${talk_command_prefix}:signaling:list" >/tmp/occ_talk_signaling_list.txt 2>/dev/null if grep -Fq "${talk_signaling_public_url}" /tmp/occ_talk_signaling_list.txt 2>/dev/null; then talk_signaling_add_status=0 else docker compose -f "${compose_file}" --env-file "${env_file}" exec -T app php occ "${talk_command_prefix}:signaling:add" "${talk_signaling_public_url}" "${talk_signaling_secret}" >/dev/null 2>&1 talk_signaling_add_status=$? if [[ "${talk_signaling_add_status}" -ne 0 ]]; then docker compose -f "${compose_file}" --env-file "${env_file}" exec -T app php occ "${talk_command_prefix}:signaling:list" >/tmp/occ_talk_signaling_list_retry.txt 2>/dev/null if grep -Fq "${talk_signaling_public_url}" /tmp/occ_talk_signaling_list_retry.txt 2>/dev/null; then talk_signaling_add_status=0 fi rm -f /tmp/occ_talk_signaling_list_retry.txt >/dev/null 2>&1 || true fi fi rm -f /tmp/occ_talk_signaling_list.txt >/dev/null 2>&1 || true if occ_has_command "${talk_command_prefix}:signaling:delete"; then docker compose -f "${compose_file}" --env-file "${env_file}" exec -T app php occ "${talk_command_prefix}:signaling:list" >/tmp/occ_talk_signaling_list_prune.txt 2>/dev/null mapfile -t talk_signaling_delete_ids < <( awk -v keep="${talk_signaling_public_url}" ' /https?:\/\// { if (keep != "" && index($0, keep) > 0) { next; } if (match($0, /\|[[:space:]]*([0-9]+)[[:space:]]*\|/, m)) { print m[1]; next; } if (match($0, /^[[:space:]]*([0-9]+)[[:space:]]+/, m)) { print m[1]; next; } } ' /tmp/occ_talk_signaling_list_prune.txt 2>/dev/null | sort -u ) for signaling_id in "${talk_signaling_delete_ids[@]}"; do if [[ -n "${signaling_id}" ]]; then docker compose -f "${compose_file}" --env-file "${env_file}" exec -T app php occ "${talk_command_prefix}:signaling:delete" "${signaling_id}" >/dev/null 2>&1 || true fi done rm -f /tmp/occ_talk_signaling_list_prune.txt >/dev/null 2>&1 || true fi docker compose -f "${compose_file}" --env-file "${env_file}" exec -T app php occ "${talk_command_prefix}:signaling:list" >/tmp/occ_talk_signaling_list_final.txt 2>/dev/null talk_signaling_multi_count="$( grep -Eo 'https?://[^[:space:]|]+' /tmp/occ_talk_signaling_list_final.txt 2>/dev/null | sort -u | wc -l | tr -d '[:space:]' )" rm -f /tmp/occ_talk_signaling_list_final.txt >/dev/null 2>&1 || true if [[ -n "${talk_signaling_multi_count}" && "${talk_signaling_multi_count}" -gt 1 ]]; then talk_signaling_prune_status=1 else talk_signaling_prune_status=0 fi else talk_signaling_skipped=1 fi if [[ -n "${talk_stun_server}" ]]; then docker compose -f "${compose_file}" --env-file "${env_file}" exec -T app php occ "${talk_command_prefix}:stun:list" >/tmp/occ_talk_stun_list.txt 2>/dev/null if grep -Fq "${talk_stun_server}" /tmp/occ_talk_stun_list.txt 2>/dev/null; then talk_stun_add_status=0 else docker compose -f "${compose_file}" --env-file "${env_file}" exec -T app php occ "${talk_command_prefix}:stun:add" "${talk_stun_server}" >/dev/null 2>&1 talk_stun_add_status=$? if [[ "${talk_stun_add_status}" -ne 0 ]]; then docker compose -f "${compose_file}" --env-file "${env_file}" exec -T app php occ "${talk_command_prefix}:stun:list" >/tmp/occ_talk_stun_list_retry.txt 2>/dev/null if grep -Fq "${talk_stun_server}" /tmp/occ_talk_stun_list_retry.txt 2>/dev/null; then talk_stun_add_status=0 fi rm -f /tmp/occ_talk_stun_list_retry.txt >/dev/null 2>&1 || true fi fi if occ_has_command "${talk_command_prefix}:stun:delete"; then mapfile -t talk_stun_delete_ids < <( awk -v keep="${talk_stun_server}" ' { if (keep != "" && index($0, keep) > 0) { next; } if (match($0, /\|[[:space:]]*([0-9]+)[[:space:]]*\|/, m)) { print m[1]; next; } if (match($0, /^[[:space:]]*([0-9]+)[[:space:]]+/, m)) { print m[1]; next; } } ' /tmp/occ_talk_stun_list.txt 2>/dev/null | sort -u ) for stun_id in "${talk_stun_delete_ids[@]}"; do if [[ -n "${stun_id}" ]]; then docker compose -f "${compose_file}" --env-file "${env_file}" exec -T app php occ "${talk_command_prefix}:stun:delete" "${stun_id}" >/dev/null 2>&1 || true fi done fi rm -f /tmp/occ_talk_stun_list.txt >/dev/null 2>&1 || true else talk_stun_skipped=1 fi if [[ "${turn_profile_enabled}" == "1" && -n "${talk_turn_secret}" && -n "${talk_domain}" ]]; then docker compose -f "${compose_file}" --env-file "${env_file}" exec -T app php occ "${talk_command_prefix}:turn:list" >/tmp/occ_talk_turn_list.txt 2>/dev/null if grep -Fq "${talk_turn_server}" /tmp/occ_talk_turn_list.txt 2>/dev/null; then talk_turn_add_status=0 else docker compose -f "${compose_file}" --env-file "${env_file}" exec -T app php occ "${talk_command_prefix}:turn:add" turn "${talk_turn_server}" udp,tcp --secret "${talk_turn_secret}" >/dev/null 2>&1 talk_turn_add_status=$? if [[ "${talk_turn_add_status}" -ne 0 ]]; then docker compose -f "${compose_file}" --env-file "${env_file}" exec -T app php occ "${talk_command_prefix}:turn:list" >/tmp/occ_talk_turn_list_retry.txt 2>/dev/null if grep -Fq "${talk_turn_server}" /tmp/occ_talk_turn_list_retry.txt 2>/dev/null; then talk_turn_add_status=0 fi rm -f /tmp/occ_talk_turn_list_retry.txt >/dev/null 2>&1 || true fi fi if occ_has_command "${talk_command_prefix}:turn:delete"; then mapfile -t talk_turn_delete_ids < <( awk -v keep="${talk_turn_server}" ' { if (keep != "" && index($0, keep) > 0) { next; } if (match($0, /\|[[:space:]]*([0-9]+)[[:space:]]*\|/, m)) { print m[1]; next; } if (match($0, /^[[:space:]]*([0-9]+)[[:space:]]+/, m)) { print m[1]; next; } } ' /tmp/occ_talk_turn_list.txt 2>/dev/null | sort -u ) for turn_id in "${talk_turn_delete_ids[@]}"; do if [[ -n "${turn_id}" ]]; then docker compose -f "${compose_file}" --env-file "${env_file}" exec -T app php occ "${talk_command_prefix}:turn:delete" "${turn_id}" >/dev/null 2>&1 || true fi done fi rm -f /tmp/occ_talk_turn_list.txt >/dev/null 2>&1 || true elif [[ "${turn_profile_enabled}" == "1" ]]; then talk_turn_skipped=1 fi fi if [[ "${recording_profile_enabled}" == "1" && -n "${talk_recording_url}" && -n "${talk_recording_secret}" ]]; then if [[ -n "${talk_recording_prefix}" ]]; then docker compose -f "${compose_file}" --env-file "${env_file}" exec -T app php occ "${talk_recording_prefix}:recording:add" "${talk_recording_url}" "${talk_recording_secret}" >/dev/null 2>&1 talk_recording_add_status=$? if [[ "${talk_recording_add_status}" -ne 0 ]]; then docker compose -f "${compose_file}" --env-file "${env_file}" exec -T app php occ "${talk_recording_prefix}:recording:set" "${talk_recording_url}" "${talk_recording_secret}" >/dev/null 2>&1 talk_recording_add_status=$? fi else talk_recording_command_missing=1 talk_recording_skipped=1 fi elif [[ "${recording_profile_enabled}" == "1" ]]; then talk_recording_skipped=1 fi 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 docker compose -f "${compose_file}" --env-file "${env_file}" exec -T app php occ maintenance:repair >/dev/null 2>&1 repair_status=$? docker compose -f "${compose_file}" --env-file "${env_file}" exec -T app php occ maintenance:repair --include-expensive >/dev/null 2>&1 repair_expensive_status=$? if [[ -x "${repo_root}/scripts/sync-public-env-urls.sh" ]]; then "${repo_root}/scripts/sync-public-env-urls.sh" \ --env-file "${env_file}" \ --mode "${mode}" >/dev/null 2>&1 public_env_resync_status=$? fi 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_verify_status=$? fi if [[ -x "${repo_root}/scripts/ensure-custom-pwa-push-config.sh" ]]; then "${repo_root}/scripts/ensure-custom-pwa-push-config.sh" \ --compose-file "${compose_file}" \ --env-file "${env_file}" >/dev/null 2>&1 custom_pwa_push_status=$? custom_pwa_push_skipped=0 fi if [[ -x "${repo_root}/scripts/ensure-qortal-integration-runtime-config.sh" ]]; then qortal_runtime_log="$(mktemp)" "${repo_root}/scripts/ensure-qortal-integration-runtime-config.sh" \ --compose-file "${compose_file}" \ --env-file "${env_file}" >"${qortal_runtime_log}" 2>&1 qortal_runtime_sync_status=$? if [[ "${qortal_runtime_sync_status}" -ne 0 && -s "${qortal_runtime_log}" ]]; then echo "Qortal runtime sync details:" sed 's/^/ /' "${qortal_runtime_log}" fi rm -f "${qortal_runtime_log}" >/dev/null 2>&1 || true qortal_runtime_sync_skipped=0 fi if [[ "${office_profile_enabled}" == "1" && -x "${repo_root}/scripts/ensure-nextcloud-office-config.sh" ]]; then office_verify_log="$(mktemp)" "${repo_root}/scripts/ensure-nextcloud-office-config.sh" \ --compose-file "${compose_file}" \ --env-file "${env_file}" >"${office_verify_log}" 2>&1 office_verify_status=$? if [[ "${office_verify_status}" -ne 0 && -s "${office_verify_log}" ]]; then echo "Office validation details:" sed 's/^/ /' "${office_verify_log}" fi rm -f "${office_verify_log}" >/dev/null 2>&1 || true office_verify_skipped=0 fi if [[ ( "${talk_profile_enabled}" == "1" || "${recording_profile_enabled}" == "1" ) && -x "${repo_root}/scripts/ensure-nextcloud-talk-config.sh" ]]; then talk_verify_log="$(mktemp)" "${repo_root}/scripts/ensure-nextcloud-talk-config.sh" \ --compose-file "${compose_file}" \ --env-file "${env_file}" >"${talk_verify_log}" 2>&1 talk_verify_status=$? if [[ "${talk_verify_status}" -ne 0 && -s "${talk_verify_log}" ]]; then echo "Talk validation details:" sed 's/^/ /' "${talk_verify_log}" fi rm -f "${talk_verify_log}" >/dev/null 2>&1 || true talk_verify_skipped=0 fi set -e occ_failure="0" if [[ "${nextcloud_url_sync_status}" -ne 0 || "${nextcloud_service_auth_status}" -ne 0 || "${enable_qortal_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 || "${default_app_bundle_status}" -ne 0 || "${repair_status}" -ne 0 || "${repair_expensive_status}" -ne 0 || "${public_env_resync_status}" -ne 0 || "${nextcloud_url_verify_status}" -ne 0 ]]; then occ_failure="1" fi if [[ "${custom_pwa_push_skipped}" -eq 0 && "${custom_pwa_push_status}" -ne 0 ]]; then occ_failure="1" fi if [[ "${qortal_runtime_sync_skipped}" -eq 0 && "${qortal_runtime_sync_status}" -ne 0 ]]; then occ_failure="1" fi if [[ "${office_verify_skipped}" -eq 0 ]]; then if [[ "${office_verify_status}" -ne 0 ]]; then occ_failure="1" fi else if [[ "${office_profile_enabled}" == "1" && ( "${office_enable_status}" -ne 0 || "${office_wopi_status}" -ne 0 || "${office_public_wopi_status}" -ne 0 ) ]]; then occ_failure="1" fi fi if [[ "${talk_verify_skipped}" -eq 0 ]]; then if [[ "${talk_verify_status}" -ne 0 ]]; then occ_failure="1" fi else if [[ "${talk_profile_enabled}" == "1" && ( "${talk_enable_status}" -ne 0 || "${talk_signaling_add_status}" -ne 0 || "${talk_stun_add_status}" -ne 0 ) ]]; then occ_failure="1" fi if [[ "${talk_signaling_prune_status}" -ne 0 ]]; then occ_failure="1" fi if [[ "${turn_profile_enabled}" == "1" && "${talk_turn_add_status}" -ne 0 ]]; then occ_failure="1" fi if [[ "${recording_profile_enabled}" == "1" && "${talk_recording_add_status}" -ne 0 && "${talk_recording_command_missing}" -ne 1 ]]; then occ_failure="1" fi fi if [[ "${occ_failure}" == "1" ]]; then echo "Warning: OCC post-setup command(s) did not fully complete. You can rerun:" echo " ./scripts/sync-public-env-urls.sh --env-file ${env_file} --mode ${mode}" 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-custom-pwa-push-config.sh --compose-file ${compose_file} --env-file ${env_file}" 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:enable qortal_integration" 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'" if [[ "${office_profile_enabled}" == "1" ]]; then echo " docker compose -f ${compose_file} --env-file ${env_file} exec -T app php occ app:enable --force richdocuments" echo " docker compose -f ${compose_file} --env-file ${env_file} exec -T app php occ config:app:set richdocuments wopi_url --value='${collabora_public_url}'" fi if [[ "${talk_profile_enabled}" == "1" ]]; then talk_cmd="${talk_command_prefix:-talk}" echo " docker compose -f ${compose_file} --env-file ${env_file} exec -T app php occ app:enable --force spreed" echo " docker compose -f ${compose_file} --env-file ${env_file} exec -T app php occ ${talk_cmd}:signaling:add '${talk_signaling_public_url}' ''" echo " docker compose -f ${compose_file} --env-file ${env_file} exec -T app php occ ${talk_cmd}:signaling:list" echo " docker compose -f ${compose_file} --env-file ${env_file} exec -T app php occ ${talk_cmd}:stun:add '${talk_stun_server}'" fi if [[ "${turn_profile_enabled}" == "1" ]]; then talk_cmd="${talk_command_prefix:-talk}" echo " docker compose -f ${compose_file} --env-file ${env_file} exec -T app php occ ${talk_cmd}:turn:add turn '${talk_turn_server}' udp,tcp --secret ''" fi if [[ "${recording_profile_enabled}" == "1" ]]; then recording_cmd="${talk_recording_prefix:-${talk_command_prefix:-talk}}" echo " docker compose -f ${compose_file} --env-file ${env_file} exec -T app php occ ${recording_cmd}:recording:add '${talk_recording_url}' ''" fi if [[ -x "${repo_root}/scripts/ensure-nextcloud-talk-config.sh" ]]; then echo " ./scripts/ensure-nextcloud-talk-config.sh --compose-file ${compose_file} --env-file ${env_file}" fi if [[ -x "${repo_root}/scripts/ensure-nextcloud-office-config.sh" ]]; then echo " ./scripts/ensure-nextcloud-office-config.sh --compose-file ${compose_file} --env-file ${env_file}" fi 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; verify appstore connectivity and install/enable it." fi if [[ "${talk_signaling_skipped}" -eq 1 ]]; then echo " Note: Talk signaling auto-config skipped because TALK_SIGNALING_PUBLIC_URL or TALK_SIGNALING_SECRET is empty." fi if [[ "${talk_signaling_prune_status}" -ne 0 ]]; then echo " Note: multiple signaling backends are still configured (count=${talk_signaling_multi_count}); remove extras in Talk admin or via ${talk_command_prefix:-talk}:signaling:delete." fi if [[ "${talk_stun_skipped}" -eq 1 ]]; then echo " Note: Talk STUN auto-config skipped because TALK_STUN_SERVER is empty." fi if [[ "${talk_turn_skipped}" -eq 1 ]]; then echo " Note: TURN auto-config skipped because TALK_TURN_SECRET or SIGNALING_DOMAIN/TALK_TURN_PORT is empty." fi if [[ "${talk_namespace_missing}" -eq 1 ]]; then echo " Note: Talk OCC namespace was not found (expected talk:* or spreed:*). Verify Nextcloud Talk app activation." fi if [[ "${talk_recording_command_missing}" -eq 1 ]]; then echo " Note: Talk recording OCC command not available (checked talk:* and spreed:*); configure recording backend URL/secret in Talk admin settings." elif [[ "${talk_recording_skipped}" -eq 1 ]]; then echo " Note: Talk recording auto-config skipped because TALK_RECORDING_URL or TALK_RECORDING_SHARED_SECRET is empty." fi if [[ "${talk_verify_skipped}" -eq 0 ]]; then echo " Note: Talk backend verification status=${talk_verify_status}." fi if [[ "${office_verify_skipped}" -eq 0 ]]; then echo " Note: Office backend verification status=${office_verify_status}." 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:enable qortal_integration" 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" if [[ "${office_profile_enabled}" == "1" ]]; then echo " docker compose -f ${compose_file} --env-file ${env_file} exec -T app php occ app:enable --force richdocuments" fi if [[ "${talk_profile_enabled}" == "1" ]]; then echo " docker compose -f ${compose_file} --env-file ${env_file} exec -T app php occ app:enable --force spreed" fi if [[ "${recording_profile_enabled}" == "1" ]]; then echo " Then configure Talk recording backend URL/secret in Nextcloud Talk admin settings." fi 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 --include-expensive" echo " docker compose -f ${compose_file} --env-file ${env_file} exec -T app php occ maintenance:repair" fi fi should_auto_finish="0" if [[ "${auto_finish}" == "1" ]]; then should_auto_finish="1" elif [[ "${auto_finish}" == "auto" ]]; then if [[ "${with_qortal}" == "1" && ",${COMPOSE_PROFILES:-}," == *",external-auth,"* ]]; then should_auto_finish="1" fi fi if [[ "${should_auto_finish}" == "1" ]]; then finish_status="failed" if [[ -x "${repo_root}/scripts/finish-initial-setup.sh" ]]; then echo "Running finish-initial-setup automatically..." finish_args=(--mode "${mode}" --env-file "${env_file}" --skip-occ) if "${repo_root}/scripts/finish-initial-setup.sh" "${finish_args[@]}"; then finish_status="success" else echo "Warning: automatic finish-initial-setup did not complete successfully." echo "You can rerun manually:" echo " ./scripts/finish-initial-setup.sh --mode ${mode} --env-file ${env_file}" fi else finish_status="skipped (script missing)" echo "Warning: scripts/finish-initial-setup.sh not found; skipping automatic finish." fi should_post_finish_recreate="0" if [[ "${finish_status}" == "success" ]]; then if [[ "${post_finish_recreate}" == "1" ]]; then should_post_finish_recreate="1" elif [[ "${post_finish_recreate}" == "auto" ]]; then should_post_finish_recreate="1" fi fi if [[ "${should_post_finish_recreate}" == "1" ]]; then echo "Running post-finish recreate for app + broker + external_auth..." docker compose -f "${compose_file}" --env-file "${env_file}" up -d --build --force-recreate app broker external_auth || true fi else finish_status="skipped" fi cat < Qortal Integration and run "Test Broker Connection". DONE