#!/usr/bin/env bash set -euo pipefail script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" repo_root="$(cd "${script_dir}/.." && pwd)" source "${script_dir}/lib-compose-project.sh" compose_file="" env_file="" app_service="app" verify_only="0" usage() { cat < --env-file [options] Ensure CustomPWA push/VAPID configuration is present and synced: - generates CUSTOM_PWA_VAPID_SUBJECT when missing - generates CUSTOM_PWA_VAPID_PUBLIC_KEY / CUSTOM_PWA_VAPID_PRIVATE_KEY when missing - enables/installs custom_pwa when INSTALL_CUSTOM_PWA is enabled - syncs VAPID + fallback settings into Nextcloud app config Options: --compose-file docker compose file to use (required) --env-file env file to use (required) --app-service Nextcloud app service name (default: app) --verify-only Do not install/repair; only verify current state -h, --help Show this help USAGE } read_kv() { local key="$1" local line line="$(grep -m1 -E "^${key}=" "${env_file}" || true)" if [[ -z "${line}" ]]; then return 1 fi echo "${line#*=}" } read_kv_first() { local value="" local key for key in "$@"; do value="$(read_kv "${key}" || true)" if [[ -n "${value}" ]]; then echo "${value}" return 0 fi done return 1 } set_kv() { local key="$1" local value="$2" local tmp esc esc="${value//\\/\\\\}" esc="${esc//&/\\&}" esc="${esc//|/\\|}" tmp="$(mktemp)" if grep -q -E "^${key}=" "${env_file}"; then sed -E "s|^${key}=.*$|${key}=${esc}|" "${env_file}" > "${tmp}" else cat "${env_file}" > "${tmp}" printf '\n%s=%s\n' "${key}" "${value}" >> "${tmp}" fi mv "${tmp}" "${env_file}" } normalize_bool_01() { local raw="${1:-}" case "$(echo "${raw}" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')" in 1|true|yes|y|on) echo "1" ;; 0|false|no|n|off) echo "0" ;; *) echo "" ;; esac } custom_pwa_should_install() { local raw normalized raw="$(read_kv_first "INSTALL_CUSTOM_PWA" || true)" normalized="$(normalize_bool_01 "${raw}")" if [[ -z "${normalized}" ]]; then normalized="1" fi [[ "${normalized}" == "1" ]] } normalize_vapid_subject() { local raw="${1:-}" raw="$(echo "${raw}" | tr -d '\r' | xargs)" if [[ -z "${raw}" ]]; then echo "" return 0 fi if [[ "${raw}" == mailto:* || "${raw}" == http://* || "${raw}" == https://* ]]; then echo "${raw}" return 0 fi if [[ "${raw}" == *"@"* ]]; then echo "mailto:${raw}" return 0 fi echo "${raw}" } derive_email_domain_from_host() { local raw_host="${1:-}" local host parts_count last_part second_last third_last raw_host="$(echo "${raw_host}" | tr -d '\r' | xargs)" host="${raw_host#http://}" host="${host#https://}" host="${host%%/*}" host="${host%%:*}" host="${host#[}" host="${host%]}" host="${host,,}" if [[ -z "${host}" ]]; then echo "" return 0 fi if [[ "${host}" == "localhost" ]]; then echo "localhost" return 0 fi if [[ "${host}" =~ ^[0-9.]+$ ]]; then echo "${host}" return 0 fi if [[ "${host}" == *:* ]]; then echo "${host}" return 0 fi parts_count="$(awk -F'.' '{print NF}' <<< "${host}")" if [[ "${parts_count}" -le 2 ]]; then echo "${host}" return 0 fi last_part="${host##*.}" second_last="${host%.*}" second_last="${second_last##*.}" third_last="${host%.*.*}" third_last="${third_last##*.}" if [[ ${#last_part} -eq 2 && ${#second_last} -le 3 ]]; then echo "${third_last}.${second_last}.${last_part}" return 0 fi echo "${second_last}.${last_part}" } 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}") 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}") return 0 fi echo "docker requires elevated privileges on this machine." if sudo "${docker_path}" info >/dev/null 2>&1; then DOCKER_CMD=(sudo "${docker_path}") return 0 fi echo "Unable to access docker, even with sudo." exit 1 } docker() { "${DOCKER_CMD[@]}" "$@" } compose_cmd() { docker compose -f "${compose_file}" --env-file "${env_file}" "$@" } occ_exec_quiet_retry() { local rc=1 local max_attempts="${OCC_RETRY_ATTEMPTS:-6}" local retry_sleep="${OCC_RETRY_SLEEP_SECONDS:-4}" local attempt if [[ $# -eq 0 ]]; then return 1 fi for attempt in $(seq 1 "${max_attempts}"); do compose_cmd exec -T --user www-data "${app_service}" php occ "$@" >/dev/null 2>&1 rc=$? if [[ "${rc}" -eq 0 ]]; then return 0 fi sleep "${retry_sleep}" done return "${rc}" } occ_app_set_custom_pwa() { local key="$1" local value="$2" compose_cmd exec -T --user www-data "${app_service}" php occ config:app:set custom_pwa "${key}" --value="${value}" >/dev/null 2>&1 } occ_app_get_custom_pwa() { local key="$1" compose_cmd exec -T --user www-data "${app_service}" php occ config:app:get custom_pwa "${key}" 2>/dev/null | tr -d '\r' } generate_custom_pwa_vapid_keypair_openssl() { local tmp_key key_text priv_hex pub_hex public_key private_key tmp_key="$(mktemp)" if ! openssl ecparam -name prime256v1 -genkey -noout -out "${tmp_key}" >/dev/null 2>&1; then rm -f "${tmp_key}" >/dev/null 2>&1 || true return 1 fi key_text="$(openssl ec -in "${tmp_key}" -text -noout 2>/dev/null || true)" rm -f "${tmp_key}" >/dev/null 2>&1 || true if [[ -z "${key_text}" ]]; then return 1 fi priv_hex="$(echo "${key_text}" | awk '/priv:/{flag=1;next}/pub:/{flag=0}flag' | tr -d '[:space:]:')" pub_hex="$(echo "${key_text}" | awk '/pub:/{flag=1;next}/ASN1 OID:/{flag=0}flag' | tr -d '[:space:]:')" if [[ -z "${priv_hex}" || -z "${pub_hex}" ]]; then return 1 fi public_key="$(echo "${pub_hex}" | xxd -r -p | base64 | tr '+/' '-_' | tr -d '=' | tr -d '\n' || true)" private_key="$(echo "${priv_hex}" | xxd -r -p | base64 | tr '+/' '-_' | tr -d '=' | tr -d '\n' || true)" if [[ -z "${public_key}" || -z "${private_key}" ]]; then return 1 fi echo "PUBLIC=${public_key}" echo "PRIVATE=${private_key}" return 0 } generate_custom_pwa_vapid_keypair() { local output public_key private_key php_code php_code='$cfg=["private_key_type"=>OPENSSL_KEYTYPE_EC,"curve_name"=>"prime256v1"];$res=openssl_pkey_new($cfg);if($res===false){fwrite(STDERR,"generate_failed\n");exit(1);}$details=openssl_pkey_get_details($res);if(!is_array($details)||!isset($details["ec"])||!is_array($details["ec"])||!isset($details["ec"]["x"],$details["ec"]["y"],$details["ec"]["d"])){fwrite(STDERR,"details_failed\n");exit(1);}$rawPublic="\x04".$details["ec"]["x"].$details["ec"]["y"];$b64=function($v){return rtrim(strtr(base64_encode($v),"+/","-_"),"=");};echo "PUBLIC=".$b64($rawPublic).PHP_EOL;echo "PRIVATE=".$b64($details["ec"]["d"]).PHP_EOL;' output="" if command -v php >/dev/null 2>&1; then output="$(php -r "${php_code}" 2>/dev/null || true)" fi if [[ -z "${output}" ]]; then output="$(compose_cmd exec -T "${app_service}" php -r "${php_code}" 2>/dev/null || true)" fi if [[ -z "${output}" && $(command -v openssl >/dev/null 2>&1; echo $?) -eq 0 ]]; then output="$(generate_custom_pwa_vapid_keypair_openssl || true)" fi public_key="$(echo "${output}" | sed -n 's/^PUBLIC=//p' | head -n1)" private_key="$(echo "${output}" | sed -n 's/^PRIVATE=//p' | head -n1)" if [[ -z "${public_key}" || -z "${private_key}" ]]; then return 1 fi echo "${public_key}|${private_key}" return 0 } ensure_custom_pwa_vapid_env() { local subject public_key private_key admin_email nextcloud_domain nextcloud_root_domain default_subject generated generated_public generated_private status status=0 subject="$(read_kv_first "CUSTOM_PWA_VAPID_SUBJECT" || true)" public_key="$(read_kv_first "CUSTOM_PWA_VAPID_PUBLIC_KEY" || true)" private_key="$(read_kv_first "CUSTOM_PWA_VAPID_PRIVATE_KEY" || true)" if [[ -z "${subject}" ]]; then admin_email="$(read_kv "NEXTCLOUD_ADMIN_EMAIL" || true)" nextcloud_domain="$(read_kv "NEXTCLOUD_DOMAIN" || true)" nextcloud_root_domain="$(derive_email_domain_from_host "${nextcloud_domain}")" if [[ -n "${admin_email}" ]]; then default_subject="mailto:${admin_email}" elif [[ -n "${nextcloud_root_domain}" ]]; then default_subject="mailto:admin@${nextcloud_root_domain}" else default_subject="mailto:admin@localhost" fi subject="${default_subject}" set_kv "CUSTOM_PWA_VAPID_SUBJECT" "${subject}" else subject="$(normalize_vapid_subject "${subject}")" set_kv "CUSTOM_PWA_VAPID_SUBJECT" "${subject}" fi if [[ -z "${public_key}" || -z "${private_key}" ]]; then generated="$(generate_custom_pwa_vapid_keypair || true)" generated_public="${generated%%|*}" generated_private="${generated#*|}" if [[ -z "${generated_public}" || -z "${generated_private}" || "${generated_public}" == "${generated_private}" ]]; then status=1 else set_kv "CUSTOM_PWA_VAPID_PUBLIC_KEY" "${generated_public}" set_kv "CUSTOM_PWA_VAPID_PRIVATE_KEY" "${generated_private}" fi fi return "${status}" } sync_custom_pwa_settings() { local status subject public_key private_key email_fallback sms_fallback normalized_bool status=0 subject="$(read_kv_first "CUSTOM_PWA_VAPID_SUBJECT" || true)" public_key="$(read_kv_first "CUSTOM_PWA_VAPID_PUBLIC_KEY" || true)" private_key="$(read_kv_first "CUSTOM_PWA_VAPID_PRIVATE_KEY" || true)" if [[ -n "${subject}" ]]; then occ_app_set_custom_pwa "vapid_subject" "${subject}" || status=1 fi if [[ -n "${public_key}" ]]; then occ_app_set_custom_pwa "vapid_public_key" "${public_key}" || status=1 fi if [[ -n "${private_key}" ]]; then occ_app_set_custom_pwa "vapid_private_key" "${private_key}" || status=1 fi email_fallback="$(read_kv_first "CUSTOM_PWA_EMAIL_FALLBACK_ENABLED" || true)" normalized_bool="$(normalize_bool_01 "${email_fallback}")" if [[ -n "${normalized_bool}" ]]; then occ_app_set_custom_pwa "email_fallback_enabled" "${normalized_bool}" || status=1 else occ_app_set_custom_pwa "email_fallback_enabled" "1" || status=1 fi sms_fallback="$(read_kv_first "CUSTOM_PWA_SMS_CRITICAL_FALLBACK_ENABLED" || true)" normalized_bool="$(normalize_bool_01 "${sms_fallback}")" if [[ -n "${normalized_bool}" ]]; then occ_app_set_custom_pwa "sms_critical_fallback_enabled" "${normalized_bool}" || status=1 else occ_app_set_custom_pwa "sms_critical_fallback_enabled" "0" || status=1 fi return "${status}" } verify_custom_pwa_settings() { local vapid_subject vapid_public_key vapid_private_key vapid_subject="$(occ_app_get_custom_pwa "vapid_subject" || true)" vapid_public_key="$(occ_app_get_custom_pwa "vapid_public_key" || true)" vapid_private_key="$(occ_app_get_custom_pwa "vapid_private_key" || true)" if [[ -z "${vapid_subject}" || -z "${vapid_public_key}" || -z "${vapid_private_key}" ]]; then return 1 fi return 0 } while [[ $# -gt 0 ]]; do case "$1" in --compose-file) compose_file="${2:-}" shift 2 ;; --compose-file=*) compose_file="${1#*=}" shift ;; --env-file) env_file="${2:-}" shift 2 ;; --env-file=*) env_file="${1#*=}" shift ;; --app-service) app_service="${2:-}" shift 2 ;; --app-service=*) app_service="${1#*=}" shift ;; --verify-only) verify_only="1" shift ;; -h|--help) usage exit 0 ;; *) echo "Unknown option: $1" usage exit 1 ;; esac done if [[ -z "${compose_file}" || -z "${env_file}" ]]; then usage exit 1 fi if [[ "${compose_file}" != /* ]]; then compose_file="${repo_root}/${compose_file}" fi if [[ "${env_file}" != /* ]]; then env_file="${repo_root}/${env_file}" fi if [[ ! -f "${compose_file}" ]]; then echo "Missing compose file: ${compose_file}" exit 1 fi if [[ ! -f "${env_file}" ]]; then echo "Missing env file: ${env_file}" exit 1 fi export_compose_project_name_from_env "${env_file}" || true if ! custom_pwa_should_install; then echo "CustomPWA install is disabled in ${env_file}; skipping push config sync." exit 0 fi ensure_docker_access if ! compose_cmd exec -T --user www-data "${app_service}" php occ status >/dev/null 2>&1; then echo "Nextcloud OCC is not ready; cannot ensure CustomPWA push config yet." exit 1 fi if [[ "${verify_only}" != "1" ]]; then ensure_custom_pwa_vapid_env occ_exec_quiet_retry app:enable custom_pwa >/dev/null 2>&1 || true if ! occ_exec_quiet_retry app:enable --force custom_pwa; then occ_exec_quiet_retry app:install custom_pwa occ_exec_quiet_retry app:enable --force custom_pwa fi sync_custom_pwa_settings else if ! compose_cmd exec -T --user www-data "${app_service}" php occ app:list 2>/dev/null | grep -qE '^[[:space:]]+- custom_pwa([[:space:]:]|$)'; then echo "custom_pwa is not enabled." exit 1 fi fi if ! verify_custom_pwa_settings; then echo "CustomPWA push settings are still incomplete after sync." exit 1 fi echo "CustomPWA push config verified."