diff --git a/.gitlab/ci/lint.gitlab-ci.yml b/.gitlab/ci/lint.gitlab-ci.yml index 03861eca1..bffd3d7c4 100644 --- a/.gitlab/ci/lint.gitlab-ci.yml +++ b/.gitlab/ci/lint.gitlab-ci.yml @@ -18,6 +18,13 @@ invalidcode39: script: - tox -e py39-invalidcode +mypy: + stage: lint + image: "before-install" + needs: [] + script: + - tox -e py37-mypy + format-check: stage: lint image: "before-install" diff --git a/.gitlab/ci/test.gitlab-ci.yml b/.gitlab/ci/test.gitlab-ci.yml index e0e0e001a..b3aea606f 100644 --- a/.gitlab/ci/test.gitlab-ci.yml +++ b/.gitlab/ci/test.gitlab-ci.yml @@ -71,7 +71,7 @@ test-translation-format-consistency: test-actionmap: extends: .test-stage script: - - python3 -m pytest tests tests/test_actionmap.py + - python3 -m pytest tests/test_actionmap.py only: changes: - data/actionsmap/*.yml @@ -85,11 +85,27 @@ test-helpers: changes: - data/helpers.d/* +test-domains: + extends: .test-stage + script: + - python3 -m pytest src/yunohost/tests/test_domains.py + only: + changes: + - src/yunohost/domain.py + +test-dns: + extends: .test-stage + script: + - python3 -m pytest src/yunohost/tests/test_dns.py + only: + changes: + - src/yunohost/dns.py + - src/yunohost/utils/dns.py + test-apps: extends: .test-stage script: - - cd src/yunohost - - python3 -m pytest tests/test_apps.py + - python3 -m pytest src/yunohost/tests/test_apps.py only: changes: - src/yunohost/app.py @@ -97,8 +113,7 @@ test-apps: test-appscatalog: extends: .test-stage script: - - cd src/yunohost - - python3 -m pytest tests/test_appscatalog.py + - python3 -m pytest src/yunohost/tests/test_appscatalog.py only: changes: - src/yunohost/app.py @@ -106,26 +121,32 @@ test-appscatalog: test-appurl: extends: .test-stage script: - - cd src/yunohost - - python3 -m pytest tests/test_appurl.py + - python3 -m pytest src/yunohost/tests/test_appurl.py only: changes: - src/yunohost/app.py -test-apps-arguments-parsing: +test-questions: extends: .test-stage script: - - cd src/yunohost - - python3 -m pytest tests/test_apps_arguments_parsing.py + - python3 -m pytest src/yunohost/tests/test_questions.py + only: + changes: + - src/yunohost/utils/config.py + +test-app-config: + extends: .test-stage + script: + - python3 -m pytest src/yunohost/tests/test_app_config.py only: changes: - src/yunohost/app.py + - src/yunohost/utils/config.py test-changeurl: extends: .test-stage script: - - cd src/yunohost - - python3 -m pytest tests/test_changeurl.py + - python3 -m pytest src/yunohost/tests/test_changeurl.py only: changes: - src/yunohost/app.py @@ -133,8 +154,7 @@ test-changeurl: test-backuprestore: extends: .test-stage script: - - cd src/yunohost - - python3 -m pytest tests/test_backuprestore.py + - python3 -m pytest src/yunohost/tests/test_backuprestore.py only: changes: - src/yunohost/backup.py @@ -142,8 +162,7 @@ test-backuprestore: test-permission: extends: .test-stage script: - - cd src/yunohost - - python3 -m pytest tests/test_permission.py + - python3 -m pytest src/yunohost/tests/test_permission.py only: changes: - src/yunohost/permission.py @@ -151,8 +170,7 @@ test-permission: test-settings: extends: .test-stage script: - - cd src/yunohost - - python3 -m pytest tests/test_settings.py + - python3 -m pytest src/yunohost/tests/test_settings.py only: changes: - src/yunohost/settings.py @@ -160,8 +178,7 @@ test-settings: test-user-group: extends: .test-stage script: - - cd src/yunohost - - python3 -m pytest tests/test_user-group.py + - python3 -m pytest src/yunohost/tests/test_user-group.py only: changes: - src/yunohost/user.py @@ -169,8 +186,7 @@ test-user-group: test-regenconf: extends: .test-stage script: - - cd src/yunohost - - python3 -m pytest tests/test_regenconf.py + - python3 -m pytest src/yunohost/tests/test_regenconf.py only: changes: - src/yunohost/regenconf.py @@ -178,8 +194,7 @@ test-regenconf: test-service: extends: .test-stage script: - - cd src/yunohost - - python3 -m pytest tests/test_service.py + - python3 -m pytest src/yunohost/tests/test_service.py only: changes: - src/yunohost/service.py @@ -187,8 +202,7 @@ test-service: test-ldapauth: extends: .test-stage script: - - cd src/yunohost - - python3 -m pytest tests/test_ldapauth.py + - python3 -m pytest src/yunohost/tests/test_ldapauth.py only: changes: - src/yunohost/authenticators/*.py diff --git a/.gitlab/ci/translation.gitlab-ci.yml b/.gitlab/ci/translation.gitlab-ci.yml index e6365adbc..41e8c82d2 100644 --- a/.gitlab/ci/translation.gitlab-ci.yml +++ b/.gitlab/ci/translation.gitlab-ci.yml @@ -20,7 +20,7 @@ autofix-translated-strings: - python3 reformat_locales.py - '[ $(git diff -w | wc -l) != 0 ] || exit 0' # stop if there is nothing to commit - git commit -am "[CI] Reformat / remove stale translated strings" || true - - git push -f origin "ci-remove-stale-translated-strings-${CI_COMMIT_REF_NAME}":"ci-remove-stale-translated-strings-${CI_COMMIT_REF_NAME}" + - git push -f origin "HEAD":"ci-remove-stale-translated-strings-${CI_COMMIT_REF_NAME}" - hub pull-request -m "[CI] Reformat / remove stale translated strings" -b Yunohost:dev -p || true # GITHUB_USER and GITHUB_TOKEN registered here https://gitlab.com/yunohost/yunohost/-/settings/ci_cd only: variables: diff --git a/data/actionsmap/yunohost.yml b/data/actionsmap/yunohost.yml index d880dc536..85a2e958b 100644 --- a/data/actionsmap/yunohost.yml +++ b/data/actionsmap/yunohost.yml @@ -476,21 +476,17 @@ domain: help: Do not ask confirmation to remove apps action: store_true + ### domain_dns_conf() dns-conf: + deprecated: true action_help: Generate sample DNS configuration for a domain - api: GET /domains//dns arguments: domain: help: Target domain - -t: - full: --ttl - help: Time To Live (TTL) in second before DNS servers update. Default is 3600 seconds (i.e. 1 hour). extra: - pattern: - - !!str ^[0-9]+$ - - "pattern_positive_number" - + pattern: *pattern_domain + ### domain_maindomain() main-domain: action_help: Check the current main domain, or change it @@ -508,8 +504,8 @@ domain: ### certificate_status() cert-status: + deprecated: true action_help: List status of current certificates (all by default). - api: GET /domains//cert arguments: domain_list: help: Domains to check @@ -520,8 +516,8 @@ domain: ### certificate_install() cert-install: + deprecated: true action_help: Install Let's Encrypt certificates for given domains (all by default). - api: PUT /domains//cert arguments: domain_list: help: Domains for which to install the certificates @@ -541,8 +537,8 @@ domain: ### certificate_renew() cert-renew: + deprecated: true action_help: Renew the Let's Encrypt certificates for given domains (all by default). - api: PUT /domains//cert/renew arguments: domain_list: help: Domains for which to renew the certificates @@ -572,6 +568,141 @@ domain: path: help: The path to check (e.g. /coffee) + subcategories: + + config: + subcategory_help: Domain settings + actions: + + ### domain_config_get() + get: + action_help: Display a domain configuration + api: GET /domains//config + arguments: + domain: + help: Domain name + key: + help: A specific panel, section or a question identifier + nargs: '?' + -f: + full: --full + help: Display all details (meant to be used by the API) + action: store_true + -e: + full: --export + help: Only export key/values, meant to be reimported using "config set --args-file" + action: store_true + + ### domain_config_set() + set: + action_help: Apply a new configuration + api: PUT /domains//config + arguments: + domain: + help: Domain name + key: + help: The question or form key + nargs: '?' + -v: + full: --value + help: new value + -a: + full: --args + help: Serialized arguments for new configuration (i.e. "mail_in=0&mail_out=0") + + dns: + subcategory_help: Manage domains DNS + actions: + ### domain_dns_conf() + suggest: + action_help: Generate sample DNS configuration for a domain + api: + - GET /domains//dns + - GET /domains//dns/suggest + arguments: + domain: + help: Target domain + extra: + pattern: *pattern_domain + + ### domain_dns_push() + push: + action_help: Push DNS records to registrar + api: POST /domains//dns/push + arguments: + domain: + help: Domain name to push DNS conf for + extra: + pattern: *pattern_domain + -d: + full: --dry-run + help: Only display what's to be pushed + action: store_true + --force: + help: Also update/remove records which were not originally set by Yunohost, or which have been manually modified + action: store_true + --purge: + help: Delete all records + action: store_true + + cert: + subcategory_help: Manage domain certificates + actions: + ### certificate_status() + status: + action_help: List status of current certificates (all by default). + api: GET /domains//cert + arguments: + domain_list: + help: Domains to check + nargs: "*" + --full: + help: Show more details + action: store_true + + ### certificate_install() + install: + action_help: Install Let's Encrypt certificates for given domains (all by default). + api: PUT /domains//cert + arguments: + domain_list: + help: Domains for which to install the certificates + nargs: "*" + --force: + help: Install even if current certificate is not self-signed + action: store_true + --no-checks: + help: Does not perform any check that your domain seems correctly configured (DNS, reachability) before attempting to install. (Not recommended) + action: store_true + --self-signed: + help: Install self-signed certificate instead of Let's Encrypt + action: store_true + --staging: + help: Use the fake/staging Let's Encrypt certification authority. The new certificate won't actually be enabled - it is only intended to test the main steps of the procedure. + action: store_true + + ### certificate_renew() + renew: + action_help: Renew the Let's Encrypt certificates for given domains (all by default). + api: PUT /domains//cert/renew + arguments: + domain_list: + help: Domains for which to renew the certificates + nargs: "*" + --force: + help: Ignore the validity threshold (30 days) + action: store_true + --email: + help: Send an email to root with logs if some renewing fails + action: store_true + --no-checks: + help: Does not perform any check that your domain seems correctly configured (DNS, reachability) before attempting to renew. (Not recommended) + action: store_true + --staging: + help: Use the fake/staging Let's Encrypt certification authority. The new certificate won't actually be enabled - it is only intended to test the main steps of the procedure. + action: store_true + + ############################# # App # ############################# @@ -811,24 +942,45 @@ app: subcategory_help: Applications configuration panel actions: - ### app_config_show_panel() - show-panel: - action_help: show config panel for the application + ### app_config_get() + get: + action_help: Display an app configuration api: GET /apps//config-panel arguments: - app: - help: App name + app: + help: App name + key: + help: A specific panel, section or a question identifier + nargs: '?' + -f: + full: --full + help: Display all details (meant to be used by the API) + action: store_true + -e: + full: --export + help: Only export key/values, meant to be reimported using "config set --args-file" + action: store_true - ### app_config_apply() - apply: - action_help: apply the new configuration + ### app_config_set() + set: + action_help: Apply a new configuration api: PUT /apps//config arguments: - app: - help: App name - -a: - full: --args - help: Serialized arguments for new configuration (i.e. "domain=domain.tld&path=/path") + app: + help: App name + key: + help: The question or panel key + nargs: '?' + -v: + full: --value + help: new value + -a: + full: --args + help: Serialized arguments for new configuration (i.e. "domain=domain.tld&path=/path") + -f: + full: --args-file + help: YAML or JSON file with key/value couples + type: open ############################# # Backup # diff --git a/data/actionsmap/yunohost_completion.py b/data/actionsmap/yunohost_completion.py index 3891aee9c..c801e2f3c 100644 --- a/data/actionsmap/yunohost_completion.py +++ b/data/actionsmap/yunohost_completion.py @@ -13,6 +13,7 @@ import yaml THIS_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) ACTIONSMAP_FILE = THIS_SCRIPT_DIR + "/yunohost.yml" +os.system(f"mkdir {THIS_SCRIPT_DIR}/../bash-completion.d") BASH_COMPLETION_FILE = THIS_SCRIPT_DIR + "/../bash-completion.d/yunohost" diff --git a/data/bash-completion.d/yunohost b/data/bash-completion.d/yunohost deleted file mode 100644 index 2572a391d..000000000 --- a/data/bash-completion.d/yunohost +++ /dev/null @@ -1,3 +0,0 @@ -# This file is automatically generated -# during Debian's package build by the script -# data/actionsmap/yunohost_completion.py diff --git a/data/helpers.d/backup b/data/helpers.d/backup index 512422c5f..99f5ed11d 100644 --- a/data/helpers.d/backup +++ b/data/helpers.d/backup @@ -313,12 +313,25 @@ ynh_restore_file () { ynh_store_file_checksum () { # Declare an array to define the options of this helper. local legacy_args=f - local -A args_array=( [f]=file= ) + local -A args_array=( [f]=file= [u]=update_only ) local file + local update_only + update_only="${update_only:-0}" + # Manage arguments with getopts ynh_handle_getopts_args "$@" local checksum_setting_name=checksum_${file//[\/ ]/_} # Replace all '/' and ' ' by '_' + + # If update only, we don't save the new checksum if no old checksum exist + if [ $update_only -eq 1 ] ; then + local checksum_value=$(ynh_app_setting_get --app=$app --key=$checksum_setting_name) + if [ -z "${checksum_value}" ] ; then + unset backup_file_checksum + return 0 + fi + fi + ynh_app_setting_set --app=$app --key=$checksum_setting_name --value=$(md5sum "$file" | cut --delimiter=' ' --fields=1) # If backup_file_checksum isn't empty, ynh_backup_if_checksum_is_different has made a backup diff --git a/data/helpers.d/config b/data/helpers.d/config new file mode 100644 index 000000000..7a2ccde46 --- /dev/null +++ b/data/helpers.d/config @@ -0,0 +1,328 @@ +#!/bin/bash + + +_ynh_app_config_get() { + # From settings + local lines + lines=$(python3 << EOL +import toml +from collections import OrderedDict +with open("../config_panel.toml", "r") as f: + file_content = f.read() +loaded_toml = toml.loads(file_content, _dict=OrderedDict) + +for panel_name, panel in loaded_toml.items(): + if not isinstance(panel, dict): continue + for section_name, section in panel.items(): + if not isinstance(section, dict): continue + for name, param in section.items(): + if not isinstance(param, dict): + continue + print(';'.join([ + name, + param.get('type', 'string'), + param.get('bind', 'settings' if param.get('type', 'string') != 'file' else 'null') + ])) +EOL +) + for line in $lines + do + # Split line into short_setting, type and bind + IFS=';' read short_setting type bind <<< "$line" + local getter="get__${short_setting}" + binds[${short_setting}]="$bind" + types[${short_setting}]="$type" + file_hash[${short_setting}]="" + formats[${short_setting}]="" + # Get value from getter if exists + if type -t $getter 2>/dev/null | grep -q '^function$' 2>/dev/null; + then + old[$short_setting]="$($getter)" + formats[${short_setting}]="yaml" + + elif [[ "$bind" == "null" ]] + then + old[$short_setting]="YNH_NULL" + + # Get value from app settings or from another file + elif [[ "$type" == "file" ]] + then + if [[ "$bind" == "settings" ]] + then + ynh_die --message="File '${short_setting}' can't be stored in settings" + fi + old[$short_setting]="$(ls "$(echo $bind | sed s@__FINALPATH__@$final_path@ | sed s/__APP__/$app/)" 2> /dev/null || echo YNH_NULL)" + file_hash[$short_setting]="true" + + # Get multiline text from settings or from a full file + elif [[ "$type" == "text" ]] + then + if [[ "$bind" == "settings" ]] + then + old[$short_setting]="$(ynh_app_setting_get $app $short_setting)" + elif [[ "$bind" == *":"* ]] + then + ynh_die --message="For technical reasons, multiline text '${short_setting}' can't be stored automatically in a variable file, you have to create custom getter/setter" + else + old[$short_setting]="$(cat $(echo $bind | sed s@__FINALPATH__@$final_path@ | sed s/__APP__/$app/) 2> /dev/null || echo YNH_NULL)" + fi + + # Get value from a kind of key/value file + else + local bind_after="" + if [[ "$bind" == "settings" ]] + then + bind=":/etc/yunohost/apps/$app/settings.yml" + fi + local bind_key="$(echo "$bind" | cut -d: -f1)" + bind_key=${bind_key:-$short_setting} + if [[ "$bind_key" == *">"* ]]; + then + bind_after="$(echo "${bind_key}" | cut -d'>' -f1)" + bind_key="$(echo "${bind_key}" | cut -d'>' -f2)" + fi + local bind_file="$(echo "$bind" | cut -d: -f2 | sed s@__FINALPATH__@$final_path@ | sed s/__APP__/$app/)" + old[$short_setting]="$(ynh_read_var_in_file --file="${bind_file}" --key="${bind_key}" --after="${bind_after}")" + + fi + done + + +} + +_ynh_app_config_apply() { + for short_setting in "${!old[@]}" + do + local setter="set__${short_setting}" + local bind="${binds[$short_setting]}" + local type="${types[$short_setting]}" + if [ "${changed[$short_setting]}" == "true" ] + then + # Apply setter if exists + if type -t $setter 2>/dev/null | grep -q '^function$' 2>/dev/null; + then + $setter + + elif [[ "$bind" == "null" ]] + then + continue + + # Save in a file + elif [[ "$type" == "file" ]] + then + if [[ "$bind" == "settings" ]] + then + ynh_die --message="File '${short_setting}' can't be stored in settings" + fi + local bind_file="$(echo "$bind" | sed s@__FINALPATH__@$final_path@ | sed s/__APP__/$app/)" + if [[ "${!short_setting}" == "" ]] + then + ynh_backup_if_checksum_is_different --file="$bind_file" + ynh_secure_remove --file="$bind_file" + ynh_delete_file_checksum --file="$bind_file" --update_only + ynh_print_info --message="File '$bind_file' removed" + else + ynh_backup_if_checksum_is_different --file="$bind_file" + if [[ "${!short_setting}" != "$bind_file" ]] + then + cp "${!short_setting}" "$bind_file" + fi + ynh_store_file_checksum --file="$bind_file" --update_only + ynh_print_info --message="File '$bind_file' overwrited with ${!short_setting}" + fi + + # Save value in app settings + elif [[ "$bind" == "settings" ]] + then + ynh_app_setting_set --app=$app --key=$short_setting --value="${!short_setting}" + ynh_print_info --message="Configuration key '$short_setting' edited in app settings" + + # Save multiline text in a file + elif [[ "$type" == "text" ]] + then + if [[ "$bind" == *":"* ]] + then + ynh_die --message="For technical reasons, multiline text '${short_setting}' can't be stored automatically in a variable file, you have to create custom getter/setter" + fi + local bind_file="$(echo "$bind" | sed s@__FINALPATH__@$final_path@ | sed s/__APP__/$app/)" + ynh_backup_if_checksum_is_different --file="$bind_file" + echo "${!short_setting}" > "$bind_file" + ynh_store_file_checksum --file="$bind_file" --update_only + ynh_print_info --message="File '$bind_file' overwrited with the content you provieded in '${short_setting}' question" + + # Set value into a kind of key/value file + else + local bind_after="" + local bind_key="$(echo "$bind" | cut -d: -f1)" + bind_key=${bind_key:-$short_setting} + if [[ "$bind_key" == *">"* ]]; + then + bind_after="$(echo "${bind_key}" | cut -d'>' -f1)" + bind_key="$(echo "${bind_key}" | cut -d'>' -f2)" + fi + local bind_file="$(echo "$bind" | cut -d: -f2 | sed s@__FINALPATH__@$final_path@ | sed s/__APP__/$app/)" + + ynh_backup_if_checksum_is_different --file="$bind_file" + ynh_write_var_in_file --file="${bind_file}" --key="${bind_key}" --value="${!short_setting}" --after="${bind_after}" + ynh_store_file_checksum --file="$bind_file" --update_only + + # We stored the info in settings in order to be able to upgrade the app + ynh_app_setting_set --app=$app --key=$short_setting --value="${!short_setting}" + ynh_print_info --message="Configuration key '$bind_key' edited into $bind_file" + + fi + fi + done +} + +_ynh_app_config_show() { + for short_setting in "${!old[@]}" + do + if [[ "${old[$short_setting]}" != YNH_NULL ]] + then + if [[ "${formats[$short_setting]}" == "yaml" ]] + then + ynh_return "${short_setting}:" + ynh_return "$(echo "${old[$short_setting]}" | sed 's/^/ /g')" + else + ynh_return "${short_setting}: "'"'"$(echo "${old[$short_setting]}" | sed 's/"/\\"/g' | sed ':a;N;$!ba;s/\n/\n\n/g')"'"' + + fi + fi + done +} + +_ynh_app_config_validate() { + # Change detection + ynh_script_progression --message="Checking what changed in the new configuration..." --weight=1 + local nothing_changed=true + local changes_validated=true + for short_setting in "${!old[@]}" + do + changed[$short_setting]=false + if [ -z ${!short_setting+x} ] + then + # Assign the var with the old value in order to allows multiple + # args validation + declare "$short_setting"="${old[$short_setting]}" + continue + fi + if [ ! -z "${file_hash[${short_setting}]}" ] + then + file_hash[old__$short_setting]="" + file_hash[new__$short_setting]="" + if [ -f "${old[$short_setting]}" ] + then + file_hash[old__$short_setting]=$(sha256sum "${old[$short_setting]}" | cut -d' ' -f1) + if [ -z "${!short_setting}" ] + then + changed[$short_setting]=true + nothing_changed=false + fi + fi + if [ -f "${!short_setting}" ] + then + file_hash[new__$short_setting]=$(sha256sum "${!short_setting}" | cut -d' ' -f1) + if [[ "${file_hash[old__$short_setting]}" != "${file_hash[new__$short_setting]}" ]] + then + changed[$short_setting]=true + nothing_changed=false + fi + fi + else + if [[ "${!short_setting}" != "${old[$short_setting]}" ]] + then + changed[$short_setting]=true + nothing_changed=false + fi + fi + done + if [[ "$nothing_changed" == "true" ]] + then + ynh_print_info --message="Nothing has changed" + exit 0 + fi + + # Run validation if something is changed + ynh_script_progression --message="Validating the new configuration..." --weight=1 + + for short_setting in "${!old[@]}" + do + [[ "${changed[$short_setting]}" == "false" ]] && continue + local result="" + if type -t validate__$short_setting | grep -q '^function$' 2>/dev/null; + then + result="$(validate__$short_setting)" + fi + if [ -n "$result" ] + then + # + # Return a yaml such as: + # + # validation_errors: + # some_key: "An error message" + # some_other_key: "Another error message" + # + # We use changes_validated to know if this is + # the first validation error + if [[ "$changes_validated" == true ]] + then + ynh_return "validation_errors:" + fi + ynh_return " ${short_setting}: \"$result\"" + changes_validated=false + fi + done + + # If validation failed, exit the script right now (instead of going into apply) + # Yunohost core will pick up the errors returned via ynh_return previously + if [[ "$changes_validated" == "false" ]] + then + exit 0 + fi + +} + +ynh_app_config_get() { + _ynh_app_config_get +} + +ynh_app_config_show() { + _ynh_app_config_show +} + +ynh_app_config_validate() { + _ynh_app_config_validate +} + +ynh_app_config_apply() { + _ynh_app_config_apply +} + +ynh_app_config_run() { + declare -Ag old=() + declare -Ag changed=() + declare -Ag file_hash=() + declare -Ag binds=() + declare -Ag types=() + declare -Ag formats=() + + case $1 in + show) + ynh_app_config_get + ynh_app_config_show + ;; + apply) + max_progression=4 + ynh_script_progression --message="Reading config panel description and current configuration..." + ynh_app_config_get + + ynh_app_config_validate + + ynh_script_progression --message="Applying the new configuration..." + ynh_app_config_apply + ynh_script_progression --message="Configuration of $app completed" --last + ;; + esac +} + diff --git a/data/helpers.d/string b/data/helpers.d/string index 7036b3b3c..a96157f78 100644 --- a/data/helpers.d/string +++ b/data/helpers.d/string @@ -43,12 +43,14 @@ ynh_replace_string () { local target_file # Manage arguments with getopts ynh_handle_getopts_args "$@" + set +o xtrace # set +x local delimit=@ # Escape the delimiter if it's in the string. match_string=${match_string//${delimit}/"\\${delimit}"} replace_string=${replace_string//${delimit}/"\\${delimit}"} + set -o xtrace # set -x sed --in-place "s${delimit}${match_string}${delimit}${replace_string}${delimit}g" "$target_file" } diff --git a/data/helpers.d/utils b/data/helpers.d/utils index 3b2f49abf..2e4927c84 100644 --- a/data/helpers.d/utils +++ b/data/helpers.d/utils @@ -1,6 +1,6 @@ #!/bin/bash -YNH_APP_BASEDIR=$(realpath $([[ "$(basename $0)" =~ ^backup|restore$ ]] && echo '../settings' || echo '..')) +YNH_APP_BASEDIR=${YNH_APP_BASEDIR:-$(realpath ..)} # Handle script crashes / failures # @@ -473,6 +473,207 @@ ynh_replace_vars () { done } +# Get a value from heterogeneous file (yaml, json, php, python...) +# +# usage: ynh_read_var_in_file --file=PATH --key=KEY +# | arg: -f, --file= - the path to the file +# | arg: -k, --key= - the key to get +# +# This helpers match several var affectation use case in several languages +# We don't use jq or equivalent to keep comments and blank space in files +# This helpers work line by line, it is not able to work correctly +# if you have several identical keys in your files +# +# Example of line this helpers can managed correctly +# .yml +# title: YunoHost documentation +# email: 'yunohost@yunohost.org' +# .json +# "theme": "colib'ris", +# "port": 8102 +# "some_boolean": false, +# "user": null +# .ini +# some_boolean = On +# action = "Clear" +# port = 20 +# .php +# $user= +# user => 20 +# .py +# USER = 8102 +# user = 'https://donate.local' +# CUSTOM['user'] = 'YunoHost' +# +# Requires YunoHost version 4.3 or higher. +ynh_read_var_in_file() { + # Declare an array to define the options of this helper. + local legacy_args=fka + local -A args_array=( [f]=file= [k]=key= [a]=after=) + local file + local key + local after + # Manage arguments with getopts + ynh_handle_getopts_args "$@" + after="${after:-}" + + [[ -f $file ]] || ynh_die --message="File $file does not exists" + + set +o xtrace # set +x + + # Get the line number after which we search for the variable + local line_number=1 + if [[ -n "$after" ]]; + then + line_number=$(grep -n $after $file | cut -d: -f1) + if [[ -z "$line_number" ]]; + then + set -o xtrace # set -x + return 1 + fi + fi + + local filename="$(basename -- "$file")" + local ext="${filename##*.}" + local endline=',;' + local assign="=>|:|=" + local comments="#" + local string="\"'" + if [[ "$ext" =~ ^ini|env|toml|yml|yaml$ ]]; then + endline='#' + fi + if [[ "$ext" =~ ^ini|env$ ]]; then + comments="[;#]" + fi + if [[ "php" == "$ext" ]] || [[ "$ext" == "js" ]]; then + comments="//" + fi + local list='\[\s*['$string']?\w+['$string']?\]' + local var_part='^\s*((const|var|let)\s+)?\$?(\w+('$list')*(->|\.|\[))*\s*' + var_part+="[$string]?${key}[$string]?" + var_part+='\s*\]?\s*' + var_part+="($assign)" + var_part+='\s*' + + # Extract the part after assignation sign + local expression_with_comment="$(tail +$line_number ${file} | grep -i -o -P $var_part'\K.*$' || echo YNH_NULL | head -n1)" + if [[ "$expression_with_comment" == "YNH_NULL" ]]; then + set -o xtrace # set -x + echo YNH_NULL + return 0 + fi + + # Remove comments if needed + local expression="$(echo "$expression_with_comment" | sed "s@$comments[^$string]*\$@@g" | sed "s@\s*[$endline]*\s*]*\$@@")" + + local first_char="${expression:0:1}" + if [[ "$first_char" == '"' ]] ; then + echo "$expression" | grep -m1 -o -P '"\K([^"](\\")?)*[^\\](?=")' | head -n1 | sed 's/\\"/"/g' + elif [[ "$first_char" == "'" ]] ; then + echo "$expression" | grep -m1 -o -P "'\K([^'](\\\\')?)*[^\\\\](?=')" | head -n1 | sed "s/\\\\'/'/g" + else + echo "$expression" + fi + set -o xtrace # set -x +} + +# Set a value into heterogeneous file (yaml, json, php, python...) +# +# usage: ynh_write_var_in_file --file=PATH --key=KEY --value=VALUE +# | arg: -f, --file= - the path to the file +# | arg: -k, --key= - the key to set +# | arg: -v, --value= - the value to set +# +# Requires YunoHost version 4.3 or higher. +ynh_write_var_in_file() { + # Declare an array to define the options of this helper. + local legacy_args=fkva + local -A args_array=( [f]=file= [k]=key= [v]=value= [a]=after=) + local file + local key + local value + local after + # Manage arguments with getopts + ynh_handle_getopts_args "$@" + after="${after:-}" + + [[ -f $file ]] || ynh_die --message="File $file does not exists" + + set +o xtrace # set +x + + # Get the line number after which we search for the variable + local line_number=1 + if [[ -n "$after" ]]; + then + line_number=$(grep -n $after $file | cut -d: -f1) + if [[ -z "$line_number" ]]; + then + set -o xtrace # set -x + return 1 + fi + fi + local range="${line_number},\$ " + + local filename="$(basename -- "$file")" + local ext="${filename##*.}" + local endline=',;' + local assign="=>|:|=" + local comments="#" + local string="\"'" + if [[ "$ext" =~ ^ini|env|toml|yml|yaml$ ]]; then + endline='#' + fi + if [[ "$ext" =~ ^ini|env$ ]]; then + comments="[;#]" + fi + if [[ "php" == "$ext" ]] || [[ "$ext" == "js" ]]; then + comments="//" + fi + local list='\[\s*['$string']?\w+['$string']?\]' + local var_part='^\s*((const|var|let)\s+)?\$?(\w+('$list')*(->|\.|\[))*\s*' + var_part+="[$string]?${key}[$string]?" + var_part+='\s*\]?\s*' + var_part+="($assign)" + var_part+='\s*' + + # Extract the part after assignation sign + local expression_with_comment="$(tail +$line_number ${file} | grep -i -o -P $var_part'\K.*$' || echo YNH_NULL | head -n1)" + if [[ "$expression_with_comment" == "YNH_NULL" ]]; then + set -o xtrace # set -x + return 1 + fi + + # Remove comments if needed + local expression="$(echo "$expression_with_comment" | sed "s@$comments[^$string]*\$@@g" | sed "s@\s*[$endline]*\s*]*\$@@")" + endline=${expression_with_comment#"$expression"} + endline="$(echo "$endline" | sed 's/\\/\\\\/g')" + value="$(echo "$value" | sed 's/\\/\\\\/g')" + local first_char="${expression:0:1}" + delimiter=$'\001' + if [[ "$first_char" == '"' ]] ; then + # \ and sed is quite complex you need 2 \\ to get one in a sed + # So we need \\\\ to go through 2 sed + value="$(echo "$value" | sed 's/"/\\\\"/g')" + sed -ri "${range}s$delimiter"'(^'"${var_part}"'")([^"]|\\")*("[\s;,]*)(\s*'$comments'.*)?$'$delimiter'\1'"${value}"'"'"${endline}${delimiter}i" ${file} + elif [[ "$first_char" == "'" ]] ; then + # \ and sed is quite complex you need 2 \\ to get one in a sed + # However double quotes implies to double \\ to + # So we need \\\\\\\\ to go through 2 sed and 1 double quotes str + value="$(echo "$value" | sed "s/'/\\\\\\\\'/g")" + sed -ri "${range}s$delimiter(^${var_part}')([^']|\\')*('"'[\s,;]*)(\s*'$comments'.*)?$'$delimiter'\1'"${value}'${endline}${delimiter}i" ${file} + else + if [[ "$value" == *"'"* ]] || [[ "$value" == *'"'* ]] || [[ "$ext" =~ ^php|py|json|js$ ]] ; then + value='\"'"$(echo "$value" | sed 's/"/\\\\"/g')"'\"' + fi + if [[ "$ext" =~ ^yaml|yml$ ]] ; then + value=" $value" + fi + sed -ri "${range}s$delimiter(^${var_part}).*\$$delimiter\1${value}${endline}${delimiter}i" ${file} + fi + set -o xtrace # set -x +} + + # Render templates with Jinja2 # # [internal] @@ -516,6 +717,7 @@ ynh_secure_remove () { local file # Manage arguments with getopts ynh_handle_getopts_args "$@" + set +o xtrace # set +x local forbidden_path=" \ /var/www \ @@ -543,6 +745,8 @@ ynh_secure_remove () { else ynh_print_info --message="'$file' wasn't deleted because it doesn't exist." fi + + set -o xtrace # set -x } # Read the value of a key in a ynh manifest file diff --git a/data/hooks/backup/18-data_multimedia b/data/hooks/backup/18-data_multimedia new file mode 100644 index 000000000..f80cff0b3 --- /dev/null +++ b/data/hooks/backup/18-data_multimedia @@ -0,0 +1,17 @@ +#!/bin/bash + +# Exit hook on subcommand error or unset variable +set -eu + +# Source YNH helpers +source /usr/share/yunohost/helpers + +# Backup destination +backup_dir="${1}/data/multimedia" + +if [ -e "/home/yunohost.multimedia/.nobackup" ]; then + exit 0 +fi + +# Backup multimedia directory +ynh_backup --src_path="/home/yunohost.multimedia" --dest_path="${backup_dir}" --is_big --not_mandatory diff --git a/data/hooks/backup/20-conf_ynh_settings b/data/hooks/backup/20-conf_ynh_settings index 77148c4d9..9b56f1579 100644 --- a/data/hooks/backup/20-conf_ynh_settings +++ b/data/hooks/backup/20-conf_ynh_settings @@ -12,6 +12,7 @@ backup_dir="${1}/conf/ynh" # Backup the configuration ynh_backup "/etc/yunohost/firewall.yml" "${backup_dir}/firewall.yml" ynh_backup "/etc/yunohost/current_host" "${backup_dir}/current_host" +ynh_backup "/etc/yunohost/domains" "${backup_dir}/domains" [ ! -e "/etc/yunohost/settings.json" ] || ynh_backup "/etc/yunohost/settings.json" "${backup_dir}/settings.json" [ ! -d "/etc/yunohost/dyndns" ] || ynh_backup "/etc/yunohost/dyndns" "${backup_dir}/dyndns" [ ! -d "/etc/dkim" ] || ynh_backup "/etc/dkim" "${backup_dir}/dkim" diff --git a/data/hooks/backup/50-conf_manually_modified_files b/data/hooks/backup/50-conf_manually_modified_files index 685fb56a8..2cca11afb 100644 --- a/data/hooks/backup/50-conf_manually_modified_files +++ b/data/hooks/backup/50-conf_manually_modified_files @@ -12,7 +12,7 @@ ynh_backup --src_path="./manually_modified_files_list" for file in $(cat ./manually_modified_files_list) do - ynh_backup --src_path="$file" + [[ -e $file ]] && ynh_backup --src_path="$file" done ynh_backup --src_path="/etc/ssowat/conf.json.persistent" diff --git a/data/hooks/conf_regen/01-yunohost b/data/hooks/conf_regen/01-yunohost index 5ee01095b..ef0bc09fc 100755 --- a/data/hooks/conf_regen/01-yunohost +++ b/data/hooks/conf_regen/01-yunohost @@ -35,6 +35,10 @@ do_init_regen() { mkdir -p /home/yunohost.app chmod 755 /home/yunohost.app + # Domain settings + mkdir -p /etc/yunohost/domains + chmod 700 /etc/yunohost/domains + # Backup folders mkdir -p /home/yunohost.backup/archives chmod 750 /home/yunohost.backup/archives @@ -51,6 +55,15 @@ do_init_regen() { mkdir -p /var/cache/yunohost/repo chown root:root /var/cache/yunohost chmod 700 /var/cache/yunohost + + cp yunoprompt.service /etc/systemd/system/yunoprompt.service + cp dpkg-origins /etc/dpkg/origins/yunohost + + # Change dpkg vendor + # see https://wiki.debian.org/Derivatives/Guidelines#Vendor + readlink -f /etc/dpkg/origins/default | grep -q debian \ + && rm -f /etc/dpkg/origins/default \ + && ln -s /etc/dpkg/origins/yunohost /etc/dpkg/origins/default } do_pre_regen() { @@ -62,6 +75,7 @@ do_pre_regen() { touch /etc/yunohost/services.yml yunohost tools shell -c "from yunohost.service import _get_services, _save_services; _save_services(_get_services())" + mkdir -p $pending_dir/etc/systemd/system mkdir -p $pending_dir/etc/cron.d/ mkdir -p $pending_dir/etc/cron.daily/ @@ -82,7 +96,7 @@ EOF # Cron job that renew lets encrypt certificates if there's any that needs renewal cat > $pending_dir/etc/cron.daily/yunohost-certificate-renew << EOF #!/bin/bash -yunohost domain cert-renew --email +yunohost domain cert renew --email EOF # If we subscribed to a dyndns domain, add the corresponding cron @@ -122,8 +136,9 @@ HandleLidSwitch=ignore HandleLidSwitchDocked=ignore HandleLidSwitchExternalPower=ignore EOF - - mkdir -p ${pending_dir}/etc/systemd/ + + cp yunoprompt.service ${pending_dir}/etc/systemd/system/yunoprompt.service + if [[ "$(yunohost settings get 'security.experimental.enabled')" == "True" ]] then cp proc-hidepid.service ${pending_dir}/etc/systemd/system/proc-hidepid.service @@ -131,6 +146,8 @@ EOF touch ${pending_dir}/etc/systemd/system/proc-hidepid.service fi + cp dpkg-origins ${pending_dir}/etc/dpkg/origins/yunohost + } do_post_regen() { @@ -174,6 +191,8 @@ do_post_regen() { [ ! -e "/home/$USER" ] || setfacl -m g:all_users:--- /home/$USER done + # Domain settings + mkdir -p /etc/yunohost/domains # Misc configuration / state files chown root:root $(ls /etc/yunohost/{*.yml,*.yaml,*.json,mysql,psql} 2>/dev/null) @@ -182,6 +201,7 @@ do_post_regen() { # Apps folder, custom hooks folder [[ ! -e /etc/yunohost/hooks.d ]] || (chown root /etc/yunohost/hooks.d && chmod 700 /etc/yunohost/hooks.d) [[ ! -e /etc/yunohost/apps ]] || (chown root /etc/yunohost/apps && chmod 700 /etc/yunohost/apps) + [[ ! -e /etc/yunohost/domains ]] || (chown root /etc/yunohost/domains && chmod 700 /etc/yunohost/domains) # Create ssh.app and sftp.app groups if they don't exist yet grep -q '^ssh.app:' /etc/group || groupadd ssh.app @@ -191,31 +211,24 @@ do_post_regen() { [[ ! "$regen_conf_files" =~ "ntp.service.d/ynh-override.conf" ]] || { systemctl daemon-reload; systemctl restart ntp; } [[ ! "$regen_conf_files" =~ "nftables.service.d/ynh-override.conf" ]] || systemctl daemon-reload [[ ! "$regen_conf_files" =~ "login.conf.d/ynh-override.conf" ]] || systemctl daemon-reload + if [[ "$regen_conf_files" =~ "yunoprompt.service" ]] + then + systemctl daemon-reload + action=$([[ -e /etc/systemd/system/yunoprompt.service ]] && echo 'enable' || echo 'disable') + systemctl $action yunoprompt --quiet --now + fi if [[ "$regen_conf_files" =~ "proc-hidepid.service" ]] then systemctl daemon-reload action=$([[ -e /etc/systemd/system/proc-hidepid.service ]] && echo 'enable' || echo 'disable') systemctl $action proc-hidepid --quiet --now fi + + # Change dpkg vendor + # see https://wiki.debian.org/Derivatives/Guidelines#Vendor + readlink -f /etc/dpkg/origins/default | grep -q debian \ + && rm -f /etc/dpkg/origins/default \ + && ln -s /etc/dpkg/origins/yunohost /etc/dpkg/origins/default } -FORCE=${2:-0} -DRY_RUN=${3:-0} - -case "$1" in - pre) - do_pre_regen $4 - ;; - post) - do_post_regen $4 - ;; - init) - do_init_regen - ;; - *) - echo "hook called with unknown argument \`$1'" >&2 - exit 1 - ;; -esac - -exit 0 +do_$1_regen ${@:2} diff --git a/data/hooks/conf_regen/02-ssl b/data/hooks/conf_regen/02-ssl index 6536e7280..2b40c77a2 100755 --- a/data/hooks/conf_regen/02-ssl +++ b/data/hooks/conf_regen/02-ssl @@ -48,8 +48,6 @@ regen_local_ca() { popd } - - do_init_regen() { LOGFILE=/tmp/yunohost-ssl-init @@ -121,23 +119,4 @@ do_post_regen() { fi } -FORCE=${2:-0} -DRY_RUN=${3:-0} - -case "$1" in - pre) - do_pre_regen $4 - ;; - post) - do_post_regen $4 - ;; - init) - do_init_regen - ;; - *) - echo "hook called with unknown argument \`$1'" >&2 - exit 1 - ;; -esac - -exit 0 +do_$1_regen ${@:2} diff --git a/data/hooks/conf_regen/03-ssh b/data/hooks/conf_regen/03-ssh index 6b0445fd0..dd1589204 100755 --- a/data/hooks/conf_regen/03-ssh +++ b/data/hooks/conf_regen/03-ssh @@ -40,20 +40,4 @@ do_post_regen() { systemctl restart ssh } -FORCE=${2:-0} -DRY_RUN=${3:-0} - -case "$1" in - pre) - do_pre_regen $4 - ;; - post) - do_post_regen $4 - ;; - *) - echo "hook called with unknown argument \`$1'" >&2 - exit 1 - ;; -esac - -exit 0 +do_$1_regen ${@:2} diff --git a/data/hooks/conf_regen/06-slapd b/data/hooks/conf_regen/06-slapd index 19e1d4d90..13fdee6ab 100755 --- a/data/hooks/conf_regen/06-slapd +++ b/data/hooks/conf_regen/06-slapd @@ -194,23 +194,4 @@ objectClass: top" done } -FORCE=${2:-0} -DRY_RUN=${3:-0} - -case "$1" in - pre) - do_pre_regen $4 - ;; - post) - do_post_regen $4 - ;; - init) - do_init_regen - ;; - *) - echo "hook called with unknown argument \`$1'" >&2 - exit 1 - ;; -esac - -exit 0 +do_$1_regen ${@:2} diff --git a/data/hooks/conf_regen/09-nslcd b/data/hooks/conf_regen/09-nslcd index 2e911b328..cefd05cd3 100755 --- a/data/hooks/conf_regen/09-nslcd +++ b/data/hooks/conf_regen/09-nslcd @@ -22,23 +22,4 @@ do_post_regen() { || systemctl restart nslcd } -FORCE=${2:-0} -DRY_RUN=${3:-0} - -case "$1" in - pre) - do_pre_regen $4 - ;; - post) - do_post_regen $4 - ;; - init) - do_init_regen - ;; - *) - echo "hook called with unknown argument \`$1'" >&2 - exit 1 - ;; -esac - -exit 0 +do_$1_regen ${@:2} diff --git a/data/hooks/conf_regen/10-apt b/data/hooks/conf_regen/10-apt index fed45533d..e92372d4f 100755 --- a/data/hooks/conf_regen/10-apt +++ b/data/hooks/conf_regen/10-apt @@ -54,20 +54,4 @@ do_post_regen() { update-alternatives --set php /usr/bin/php7.4 } -FORCE=${2:-0} -DRY_RUN=${3:-0} - -case "$1" in - pre) - do_pre_regen $4 - ;; - post) - do_post_regen $4 - ;; - *) - echo "hook called with unknown argument \`$1'" >&2 - exit 1 - ;; -esac - -exit 0 +do_$1_regen ${@:2} diff --git a/data/hooks/conf_regen/12-metronome b/data/hooks/conf_regen/12-metronome index 9820f9881..ab9fca173 100755 --- a/data/hooks/conf_regen/12-metronome +++ b/data/hooks/conf_regen/12-metronome @@ -70,20 +70,4 @@ do_post_regen() { || systemctl restart metronome } -FORCE=${2:-0} -DRY_RUN=${3:-0} - -case "$1" in - pre) - do_pre_regen $4 - ;; - post) - do_post_regen $4 - ;; - *) - echo "hook called with unknown argument \`$1'" >&2 - exit 1 - ;; -esac - -exit 0 +do_$1_regen ${@:2} diff --git a/data/hooks/conf_regen/15-nginx b/data/hooks/conf_regen/15-nginx index 8acb3d8d3..f6975b86f 100755 --- a/data/hooks/conf_regen/15-nginx +++ b/data/hooks/conf_regen/15-nginx @@ -65,7 +65,7 @@ do_pre_regen() { export experimental="$(yunohost settings get 'security.experimental.enabled')" ynh_render_template "security.conf.inc" "${nginx_conf_dir}/security.conf.inc" - cert_status=$(yunohost domain cert-status --json) + cert_status=$(yunohost domain cert status --json) # add domain conf files for domain in $YNH_DOMAINS; do @@ -135,23 +135,4 @@ do_post_regen() { pgrep nginx && systemctl reload nginx || { journalctl --no-pager --lines=10 -u nginx >&2; exit 1; } } -FORCE=${2:-0} -DRY_RUN=${3:-0} - -case "$1" in - pre) - do_pre_regen $4 - ;; - post) - do_post_regen $4 - ;; - init) - do_init_regen - ;; - *) - echo "hook called with unknown argument \`$1'" >&2 - exit 1 - ;; -esac - -exit 0 +do_$1_regen ${@:2} diff --git a/data/hooks/conf_regen/19-postfix b/data/hooks/conf_regen/19-postfix index 166b5d5e9..c569e1ca1 100755 --- a/data/hooks/conf_regen/19-postfix +++ b/data/hooks/conf_regen/19-postfix @@ -80,20 +80,4 @@ do_post_regen() { } -FORCE=${2:-0} -DRY_RUN=${3:-0} - -case "$1" in - pre) - do_pre_regen $4 - ;; - post) - do_post_regen $4 - ;; - *) - echo "hook called with unknown argument \`$1'" >&2 - exit 1 - ;; -esac - -exit 0 +do_$1_regen ${@:2} diff --git a/data/hooks/conf_regen/25-dovecot b/data/hooks/conf_regen/25-dovecot index 916b88c35..a0663a4a6 100755 --- a/data/hooks/conf_regen/25-dovecot +++ b/data/hooks/conf_regen/25-dovecot @@ -63,20 +63,4 @@ do_post_regen() { systemctl restart dovecot } -FORCE=${2:-0} -DRY_RUN=${3:-0} - -case "$1" in - pre) - do_pre_regen $4 - ;; - post) - do_post_regen $4 - ;; - *) - echo "hook called with unknown argument \`$1'" >&2 - exit 1 - ;; -esac - -exit 0 +do_$1_regen ${@:2} diff --git a/data/hooks/conf_regen/31-rspamd b/data/hooks/conf_regen/31-rspamd index 87ed722a7..da9b35dfe 100755 --- a/data/hooks/conf_regen/31-rspamd +++ b/data/hooks/conf_regen/31-rspamd @@ -59,20 +59,4 @@ do_post_regen() { systemctl -q restart rspamd.service } -FORCE=${2:-0} -DRY_RUN=${3:-0} - -case "$1" in - pre) - do_pre_regen $4 - ;; - post) - do_post_regen $4 - ;; - *) - echo "hook called with unknown argument \`$1'" >&2 - exit 1 - ;; -esac - -exit 0 +do_$1_regen ${@:2} diff --git a/data/hooks/conf_regen/34-mysql b/data/hooks/conf_regen/34-mysql index 136207cc8..fa86db686 100755 --- a/data/hooks/conf_regen/34-mysql +++ b/data/hooks/conf_regen/34-mysql @@ -46,20 +46,4 @@ do_post_regen() { || systemctl restart mysql } -FORCE=${2:-0} -DRY_RUN=${3:-0} - -case "$1" in - pre) - do_pre_regen $4 - ;; - post) - do_post_regen $4 - ;; - *) - echo "hook called with unknown argument \`$1'" >&2 - exit 1 - ;; -esac - -exit 0 +do_$1_regen ${@:2} diff --git a/data/hooks/conf_regen/35-redis b/data/hooks/conf_regen/35-redis index 10358cefc..da5eac4c9 100755 --- a/data/hooks/conf_regen/35-redis +++ b/data/hooks/conf_regen/35-redis @@ -10,20 +10,4 @@ do_post_regen() { chown -R redis:adm /var/log/redis } -FORCE=${2:-0} -DRY_RUN=${3:-0} - -case "$1" in - pre) - do_pre_regen $4 - ;; - post) - do_post_regen $4 - ;; - *) - echo "hook called with unknown argument \`$1'" >&2 - exit 1 - ;; -esac - -exit 0 +do_$1_regen ${@:2} diff --git a/data/hooks/conf_regen/37-mdns b/data/hooks/conf_regen/37-mdns index 1d7381e26..17f7bb8e2 100755 --- a/data/hooks/conf_regen/37-mdns +++ b/data/hooks/conf_regen/37-mdns @@ -61,23 +61,4 @@ do_post_regen() { || systemctl restart yunomdns } -FORCE=${2:-0} -DRY_RUN=${3:-0} - -case "$1" in - pre) - do_pre_regen $4 - ;; - post) - do_post_regen $4 - ;; - init) - do_init_regen - ;; - *) - echo "hook called with unknown argument \`$1'" >&2 - exit 1 - ;; -esac - -exit 0 +do_$1_regen ${@:2} diff --git a/data/hooks/conf_regen/43-dnsmasq b/data/hooks/conf_regen/43-dnsmasq index e7b0531e8..f3bed7b04 100755 --- a/data/hooks/conf_regen/43-dnsmasq +++ b/data/hooks/conf_regen/43-dnsmasq @@ -80,20 +80,4 @@ do_post_regen() { systemctl restart dnsmasq } -FORCE=${2:-0} -DRY_RUN=${3:-0} - -case "$1" in - pre) - do_pre_regen $4 - ;; - post) - do_post_regen $4 - ;; - *) - echo "hook called with unknown argument \`$1'" >&2 - exit 1 - ;; -esac - -exit 0 +do_$1_regen ${@:2} diff --git a/data/hooks/conf_regen/46-nsswitch b/data/hooks/conf_regen/46-nsswitch index e6d998094..be5cb2b86 100755 --- a/data/hooks/conf_regen/46-nsswitch +++ b/data/hooks/conf_regen/46-nsswitch @@ -22,23 +22,4 @@ do_post_regen() { || systemctl restart unscd } -FORCE=${2:-0} -DRY_RUN=${3:-0} - -case "$1" in - pre) - do_pre_regen $4 - ;; - post) - do_post_regen $4 - ;; - init) - do_init_regen - ;; - *) - echo "hook called with unknown argument \`$1'" >&2 - exit 1 - ;; -esac - -exit 0 +do_$1_regen ${@:2} diff --git a/data/hooks/conf_regen/52-fail2ban b/data/hooks/conf_regen/52-fail2ban index c96940c94..7aef72ebc 100755 --- a/data/hooks/conf_regen/52-fail2ban +++ b/data/hooks/conf_regen/52-fail2ban @@ -27,20 +27,4 @@ do_post_regen() { || systemctl reload fail2ban } -FORCE=${2:-0} -DRY_RUN=${3:-0} - -case "$1" in - pre) - do_pre_regen $4 - ;; - post) - do_post_regen $4 - ;; - *) - echo "hook called with unknown argument \`$1'" >&2 - exit 1 - ;; -esac - -exit 0 +do_$1_regen ${@:2} diff --git a/data/hooks/diagnosis/12-dnsrecords.py b/data/hooks/diagnosis/12-dnsrecords.py index e39db651c..935d4f42d 100644 --- a/data/hooks/diagnosis/12-dnsrecords.py +++ b/data/hooks/diagnosis/12-dnsrecords.py @@ -8,11 +8,11 @@ from publicsuffix2 import PublicSuffixList from moulinette.utils.process import check_output -from yunohost.utils.network import dig +from yunohost.utils.dns import dig, YNH_DYNDNS_DOMAINS from yunohost.diagnosis import Diagnoser -from yunohost.domain import domain_list, _build_dns_conf, _get_maindomain +from yunohost.domain import domain_list, _get_maindomain +from yunohost.dns import _build_dns_conf, _get_dns_zone_for_domain -YNH_DYNDNS_DOMAINS = ["nohost.me", "noho.st", "ynh.fr"] SPECIAL_USE_TLDS = ["local", "localhost", "onion", "test"] @@ -26,17 +26,15 @@ class DNSRecordsDiagnoser(Diagnoser): main_domain = _get_maindomain() - all_domains = domain_list()["domains"] + all_domains = domain_list(exclude_subdomains=True)["domains"] for domain in all_domains: self.logger_debug("Diagnosing DNS conf for %s" % domain) - is_subdomain = domain.split(".", 1)[1] in all_domains is_specialusedomain = any( domain.endswith("." + tld) for tld in SPECIAL_USE_TLDS ) for report in self.check_domain( domain, domain == main_domain, - is_subdomain=is_subdomain, is_specialusedomain=is_specialusedomain, ): yield report @@ -55,16 +53,16 @@ class DNSRecordsDiagnoser(Diagnoser): for report in self.check_expiration_date(domains_from_registrar): yield report - def check_domain(self, domain, is_main_domain, is_subdomain, is_specialusedomain): + def check_domain(self, domain, is_main_domain, is_specialusedomain): + + base_dns_zone = _get_dns_zone_for_domain(domain) + basename = domain.replace(base_dns_zone, "").rstrip(".") or "@" expected_configuration = _build_dns_conf( domain, include_empty_AAAA_if_no_ipv6=True ) categories = ["basic", "mail", "xmpp", "extra"] - # For subdomains, we only diagnosis A and AAAA records - if is_subdomain: - categories = ["basic"] if is_specialusedomain: categories = [] @@ -82,8 +80,20 @@ class DNSRecordsDiagnoser(Diagnoser): results = {} for r in records: + id_ = r["type"] + ":" + r["name"] - r["current"] = self.get_current_record(domain, r["name"], r["type"]) + fqdn = r["name"] + "." + base_dns_zone if r["name"] != "@" else domain + + # Ugly hack to not check mail records for subdomains stuff, otherwise will end up in a shitstorm of errors for people with many subdomains... + # Should find a cleaner solution in the suggested conf... + if r["type"] in ["MX", "TXT"] and fqdn not in [ + domain, + f"mail._domainkey.{domain}", + f"_dmarc.{domain}", + ]: + continue + + r["current"] = self.get_current_record(fqdn, r["type"]) if r["value"] == "@": r["value"] = domain + "." @@ -106,7 +116,10 @@ class DNSRecordsDiagnoser(Diagnoser): # A bad or missing A record is critical ... # And so is a wrong AAAA record # (However, a missing AAAA record is acceptable) - if results["A:@"] != "OK" or results["AAAA:@"] == "WRONG": + if ( + results[f"A:{basename}"] != "OK" + or results[f"AAAA:{basename}"] == "WRONG" + ): return True return False @@ -139,10 +152,9 @@ class DNSRecordsDiagnoser(Diagnoser): yield output - def get_current_record(self, domain, name, type_): + def get_current_record(self, fqdn, type_): - query = "%s.%s" % (name, domain) if name != "@" else domain - success, answers = dig(query, type_, resolvers="force_external") + success, answers = dig(fqdn, type_, resolvers="force_external") if success != "ok": return None @@ -170,7 +182,7 @@ class DNSRecordsDiagnoser(Diagnoser): ) # For SPF, ignore parts starting by ip4: or ip6: - if r["name"] == "@": + if "v=spf1" in r["value"]: current = { part for part in current @@ -189,7 +201,6 @@ class DNSRecordsDiagnoser(Diagnoser): """ Alert if expiration date of a domain is soon """ - details = {"not_found": [], "error": [], "warning": [], "success": []} for domain in domains: @@ -199,6 +210,7 @@ class DNSRecordsDiagnoser(Diagnoser): status_ns, _ = dig(domain, "NS", resolvers="force_external") status_a, _ = dig(domain, "A", resolvers="force_external") if "ok" not in [status_ns, status_a]: + # i18n: diagnosis_domain_not_found_details details["not_found"].append( ( "diagnosis_domain_%s_details" % (expire_date), @@ -233,6 +245,12 @@ class DNSRecordsDiagnoser(Diagnoser): # Allow to ignore specifically a single domain if len(details[alert_type]) == 1: meta["domain"] = details[alert_type][0][1]["domain"] + + # i18n: diagnosis_domain_expiration_not_found + # i18n: diagnosis_domain_expiration_error + # i18n: diagnosis_domain_expiration_warning + # i18n: diagnosis_domain_expiration_success + # i18n: diagnosis_domain_expiration_not_found_details yield dict( meta=meta, data={}, diff --git a/data/hooks/diagnosis/21-web.py b/data/hooks/diagnosis/21-web.py index 40a6c26b4..2072937e5 100644 --- a/data/hooks/diagnosis/21-web.py +++ b/data/hooks/diagnosis/21-web.py @@ -121,6 +121,10 @@ class WebDiagnoser(Diagnoser): for domain in domains: + # i18n: diagnosis_http_bad_status_code + # i18n: diagnosis_http_connection_error + # i18n: diagnosis_http_timeout + # If both IPv4 and IPv6 (if applicable) are good if all( results[ipversion][domain]["status"] == "ok" for ipversion in ipversions diff --git a/data/hooks/diagnosis/24-mail.py b/data/hooks/diagnosis/24-mail.py index 63f685a26..c5af4bbc6 100644 --- a/data/hooks/diagnosis/24-mail.py +++ b/data/hooks/diagnosis/24-mail.py @@ -12,7 +12,7 @@ from moulinette.utils.filesystem import read_yaml from yunohost.diagnosis import Diagnoser from yunohost.domain import _get_maindomain, domain_list from yunohost.settings import settings_get -from yunohost.utils.network import dig +from yunohost.utils.dns import dig DEFAULT_DNS_BLACKLIST = "/usr/share/yunohost/other/dnsbl_list.yml" @@ -35,11 +35,11 @@ class MailDiagnoser(Diagnoser): # TODO check that the recent mail logs are not filled with thousand of email sending (unusual number of mail sent) # TODO check for unusual failed sending attempt being refused in the logs ? checks = [ - "check_outgoing_port_25", - "check_ehlo", - "check_fcrdns", - "check_blacklist", - "check_queue", + "check_outgoing_port_25", # i18n: diagnosis_mail_outgoing_port_25_ok + "check_ehlo", # i18n: diagnosis_mail_ehlo_ok + "check_fcrdns", # i18n: diagnosis_mail_fcrdns_ok + "check_blacklist", # i18n: diagnosis_mail_blacklist_ok + "check_queue", # i18n: diagnosis_mail_queue_ok ] for check in checks: self.logger_debug("Running " + check) @@ -102,6 +102,10 @@ class MailDiagnoser(Diagnoser): continue if r["status"] != "ok": + # i18n: diagnosis_mail_ehlo_bad_answer + # i18n: diagnosis_mail_ehlo_bad_answer_details + # i18n: diagnosis_mail_ehlo_unreachable + # i18n: diagnosis_mail_ehlo_unreachable_details summary = r["status"].replace("error_smtp_", "diagnosis_mail_ehlo_") yield dict( meta={"test": "mail_ehlo", "ipversion": ipversion}, diff --git a/data/hooks/diagnosis/80-apps.py b/data/hooks/diagnosis/80-apps.py index 177ec590f..a75193a45 100644 --- a/data/hooks/diagnosis/80-apps.py +++ b/data/hooks/diagnosis/80-apps.py @@ -76,7 +76,7 @@ class AppDiagnoser(Diagnoser): for deprecated_helper in deprecated_helpers: if ( os.system( - f"grep -nr -q '{deprecated_helper}' {app['setting_path']}/scripts/" + f"grep -hr '{deprecated_helper}' {app['setting_path']}/scripts/ | grep -v -q '^\s*#'" ) == 0 ): diff --git a/data/hooks/restore/18-data_multimedia b/data/hooks/restore/18-data_multimedia new file mode 100644 index 000000000..eb8ef2608 --- /dev/null +++ b/data/hooks/restore/18-data_multimedia @@ -0,0 +1,9 @@ +#!/bin/bash + +# Exit hook on subcommand error or unset variable +set -eu + +# Source YNH helpers +source /usr/share/yunohost/helpers + +ynh_restore_file --origin_path="/home/yunohost.multimedia" --not_mandatory diff --git a/data/hooks/restore/20-conf_ynh_settings b/data/hooks/restore/20-conf_ynh_settings index 4de29a4aa..4c4c6ed5e 100644 --- a/data/hooks/restore/20-conf_ynh_settings +++ b/data/hooks/restore/20-conf_ynh_settings @@ -2,6 +2,7 @@ backup_dir="$1/conf/ynh" cp -a "${backup_dir}/current_host" /etc/yunohost/current_host cp -a "${backup_dir}/firewall.yml" /etc/yunohost/firewall.yml +cp -a "${backup_dir}/domains" /etc/yunohost/domains [ ! -e "${backup_dir}/settings.json" ] || cp -a "${backup_dir}/settings.json" "/etc/yunohost/settings.json" [ ! -d "${backup_dir}/dyndns" ] || cp -raT "${backup_dir}/dyndns" "/etc/yunohost/dyndns" [ ! -d "${backup_dir}/dkim" ] || cp -raT "${backup_dir}/dkim" "/etc/dkim" diff --git a/data/other/config_domain.toml b/data/other/config_domain.toml new file mode 100644 index 000000000..93551458b --- /dev/null +++ b/data/other/config_domain.toml @@ -0,0 +1,55 @@ +version = "1.0" +i18n = "domain_config" + +# +# Other things we may want to implement in the future: +# +# - maindomain handling +# - default app +# - autoredirect www in nginx conf +# - ? +# + +[feature] + + [feature.mail] + #services = ['postfix', 'dovecot'] + + [feature.mail.features_disclaimer] + type = "alert" + style = "warning" + icon = "warning" + + [feature.mail.mail_out] + type = "boolean" + default = 1 + + [feature.mail.mail_in] + type = "boolean" + default = 1 + + #[feature.mail.backup_mx] + #type = "tags" + #default = [] + #pattern.regexp = '^([^\W_A-Z]+([-]*[^\W_A-Z]+)*\.)+((xn--)?[^\W_]{2,})$' + #pattern.error = "pattern_error" + + [feature.xmpp] + + [feature.xmpp.xmpp] + type = "boolean" + default = 0 + +[dns] + + [dns.registrar] + optional = true + + # This part is automatically generated in DomainConfigPanel + +# [dns.advanced] +# +# [dns.advanced.ttl] +# type = "number" +# min = 0 +# default = 3600 diff --git a/data/other/registrar_list.toml b/data/other/registrar_list.toml new file mode 100644 index 000000000..afb213aa1 --- /dev/null +++ b/data/other/registrar_list.toml @@ -0,0 +1,649 @@ +[aliyun] + [aliyun.auth_key_id] + type = "string" + redact = true + + [aliyun.auth_secret] + type = "string" + redact = true + +[aurora] + [aurora.auth_api_key] + type = "string" + redact = true + + [aurora.auth_secret_key] + type = "string" + redact = true + +[azure] + [azure.auth_client_id] + type = "string" + redact = true + + [azure.auth_client_secret] + type = "string" + redact = true + + [azure.auth_tenant_id] + type = "string" + redact = true + + [azure.auth_subscription_id] + type = "string" + redact = true + + [azure.resource_group] + type = "string" + redact = true + +[cloudflare] + [cloudflare.auth_username] + type = "string" + redact = true + + [cloudflare.auth_token] + type = "string" + redact = true + + [cloudflare.zone_id] + type = "string" + redact = true + +[cloudns] + [cloudns.auth_id] + type = "string" + redact = true + + [cloudns.auth_subid] + type = "string" + redact = true + + [cloudns.auth_subuser] + type = "string" + redact = true + + [cloudns.auth_password] + type = "password" + + [cloudns.weight] + type = "number" + + [cloudns.port] + type = "number" +[cloudxns] + [cloudxns.auth_username] + type = "string" + redact = true + + [cloudxns.auth_token] + type = "string" + redact = true + +[conoha] + [conoha.auth_region] + type = "string" + redact = true + + [conoha.auth_token] + type = "string" + redact = true + + [conoha.auth_username] + type = "string" + redact = true + + [conoha.auth_password] + type = "password" + + [conoha.auth_tenant_id] + type = "string" + redact = true + +[constellix] + [constellix.auth_username] + type = "string" + redact = true + + [constellix.auth_token] + type = "string" + redact = true + +[digitalocean] + [digitalocean.auth_token] + type = "string" + redact = true + +[dinahosting] + [dinahosting.auth_username] + type = "string" + redact = true + + [dinahosting.auth_password] + type = "password" + +[directadmin] + [directadmin.auth_password] + type = "password" + + [directadmin.auth_username] + type = "string" + redact = true + + [directadmin.endpoint] + type = "string" + redact = true + +[dnsimple] + [dnsimple.auth_token] + type = "string" + redact = true + + [dnsimple.auth_username] + type = "string" + redact = true + + [dnsimple.auth_password] + type = "password" + + [dnsimple.auth_2fa] + type = "string" + redact = true + +[dnsmadeeasy] + [dnsmadeeasy.auth_username] + type = "string" + redact = true + + [dnsmadeeasy.auth_token] + type = "string" + redact = true + +[dnspark] + [dnspark.auth_username] + type = "string" + redact = true + + [dnspark.auth_token] + type = "string" + redact = true + +[dnspod] + [dnspod.auth_username] + type = "string" + redact = true + + [dnspod.auth_token] + type = "string" + redact = true + +[dreamhost] + [dreamhost.auth_token] + type = "string" + redact = true + +[dynu] + [dynu.auth_token] + type = "string" + redact = true + +[easydns] + [easydns.auth_username] + type = "string" + redact = true + + [easydns.auth_token] + type = "string" + redact = true + +[easyname] + [easyname.auth_username] + type = "string" + redact = true + + [easyname.auth_password] + type = "password" + +[euserv] + [euserv.auth_username] + type = "string" + redact = true + + [euserv.auth_password] + type = "password" + +[exoscale] + [exoscale.auth_key] + type = "string" + redact = true + + [exoscale.auth_secret] + type = "string" + redact = true + +[gandi] + [gandi.auth_token] + type = "string" + redact = true + + [gandi.api_protocol] + type = "string" + choices.rpc = "RPC" + choices.rest = "REST" + default = "rest" + visible = "false" + +[gehirn] + [gehirn.auth_token] + type = "string" + redact = true + + [gehirn.auth_secret] + type = "string" + redact = true + +[glesys] + [glesys.auth_username] + type = "string" + redact = true + + [glesys.auth_token] + type = "string" + redact = true + +[godaddy] + [godaddy.auth_key] + type = "string" + redact = true + + [godaddy.auth_secret] + type = "string" + redact = true + +[googleclouddns] + [goggleclouddns.auth_service_account_info] + type = "string" + redact = true + +[gransy] + [gransy.auth_username] + type = "string" + redact = true + + [gransy.auth_password] + type = "password" + +[gratisdns] + [gratisdns.auth_username] + type = "string" + redact = true + + [gratisdns.auth_password] + type = "password" + +[henet] + [henet.auth_username] + type = "string" + redact = true + + [henet.auth_password] + type = "password" + +[hetzner] + [hetzner.auth_token] + type = "string" + redact = true + +[hostingde] + [hostingde.auth_token] + type = "string" + redact = true + +[hover] + [hover.auth_username] + type = "string" + redact = true + + [hover.auth_password] + type = "password" + +[infoblox] + [infoblox.auth_user] + type = "string" + redact = true + + [infoblox.auth_psw] + type = "password" + + [infoblox.ib_view] + type = "string" + redact = true + + [infoblox.ib_host] + type = "string" + redact = true + +[infomaniak] + [infomaniak.auth_token] + type = "string" + redact = true + +[internetbs] + [internetbs.auth_key] + type = "string" + redact = true + + [internetbs.auth_password] + type = "string" + redact = true + +[inwx] + [inwx.auth_username] + type = "string" + redact = true + + [inwx.auth_password] + type = "password" + +[joker] + [joker.auth_token] + type = "string" + redact = true + +[linode] + [linode.auth_token] + type = "string" + redact = true + +[linode4] + [linode4.auth_token] + type = "string" + redact = true + +[localzone] + [localzone.filename] + type = "string" + redact = true + +[luadns] + [luadns.auth_username] + type = "string" + redact = true + + [luadns.auth_token] + type = "string" + redact = true + +[memset] + [memset.auth_token] + type = "string" + redact = true + +[mythicbeasts] + [mythicbeasts.auth_username] + type = "string" + redact = true + + [mythicbeasts.auth_password] + type = "password" + + [mythicbeasts.auth_token] + type = "string" + redact = true + +[namecheap] + [namecheap.auth_token] + type = "string" + redact = true + + [namecheap.auth_username] + type = "string" + redact = true + + [namecheap.auth_client_ip] + type = "string" + redact = true + + [namecheap.auth_sandbox] + type = "string" + redact = true + +[namesilo] + [namesilo.auth_token] + type = "string" + redact = true + +[netcup] + [netcup.auth_customer_id] + type = "string" + redact = true + + [netcup.auth_api_key] + type = "string" + redact = true + + [netcup.auth_api_password] + type = "string" + redact = true + +[nfsn] + [nfsn.auth_username] + type = "string" + redact = true + + [nfsn.auth_token] + type = "string" + redact = true + +[njalla] + [njalla.auth_token] + type = "string" + redact = true + +[nsone] + [nsone.auth_token] + type = "string" + redact = true + +[onapp] + [onapp.auth_username] + type = "string" + redact = true + + [onapp.auth_token] + type = "string" + redact = true + + [onapp.auth_server] + type = "string" + redact = true + +[online] + [online.auth_token] + type = "string" + redact = true + +[ovh] + [ovh.auth_entrypoint] + type = "select" + choices = ["ovh-eu", "ovh-ca", "soyoustart-eu", "soyoustart-ca", "kimsufi-eu", "kimsufi-ca"] + default = "ovh-eu" + + [ovh.auth_application_key] + type = "string" + redact = true + + [ovh.auth_application_secret] + type = "string" + redact = true + + [ovh.auth_consumer_key] + type = "string" + redact = true + +[plesk] + [plesk.auth_username] + type = "string" + redact = true + + [plesk.auth_password] + type = "password" + + [plesk.plesk_server] + type = "string" + redact = true + +[pointhq] + [pointhq.auth_username] + type = "string" + redact = true + + [pointhq.auth_token] + type = "string" + redact = true + +[powerdns] + [powerdns.auth_token] + type = "string" + redact = true + + [powerdns.pdns_server] + type = "string" + redact = true + + [powerdns.pdns_server_id] + type = "string" + redact = true + + [powerdns.pdns_disable_notify] + type = "boolean" + +[rackspace] + [rackspace.auth_account] + type = "string" + redact = true + + [rackspace.auth_username] + type = "string" + redact = true + + [rackspace.auth_api_key] + type = "string" + redact = true + + [rackspace.auth_token] + type = "string" + redact = true + + [rackspace.sleep_time] + type = "string" + redact = true + +[rage4] + [rage4.auth_username] + type = "string" + redact = true + + [rage4.auth_token] + type = "string" + redact = true + +[rcodezero] + [rcodezero.auth_token] + type = "string" + redact = true + +[route53] + [route53.auth_access_key] + type = "string" + redact = true + + [route53.auth_access_secret] + type = "string" + redact = true + + [route53.private_zone] + type = "string" + redact = true + + [route53.auth_username] + type = "string" + redact = true + + [route53.auth_token] + type = "string" + redact = true + +[safedns] + [safedns.auth_token] + type = "string" + redact = true + +[sakuracloud] + [sakuracloud.auth_token] + type = "string" + redact = true + + [sakuracloud.auth_secret] + type = "string" + redact = true + +[softlayer] + [softlayer.auth_username] + type = "string" + redact = true + + [softlayer.auth_api_key] + type = "string" + redact = true + +[transip] + [transip.auth_username] + type = "string" + redact = true + + [transip.auth_api_key] + type = "string" + redact = true + +[ultradns] + [ultradns.auth_token] + type = "string" + redact = true + + [ultradns.auth_username] + type = "string" + redact = true + + [ultradns.auth_password] + type = "password" + +[vultr] + [vultr.auth_token] + type = "string" + redact = true + +[yandex] + [yandex.auth_token] + type = "string" + redact = true + +[zeit] + [zeit.auth_token] + type = "string" + redact = true + +[zilore] + [zilore.auth_key] + type = "string" + redact = true + +[zonomi] + [zonomy.auth_token] + type = "string" + redact = true + + [zonomy.auth_entrypoint] + type = "string" + redact = true + diff --git a/data/templates/mysql/my.cnf b/data/templates/mysql/my.cnf index 429596cf5..3da4377e1 100644 --- a/data/templates/mysql/my.cnf +++ b/data/templates/mysql/my.cnf @@ -30,7 +30,7 @@ skip-external-locking key_buffer_size = 16K max_allowed_packet = 16M table_open_cache = 4 -sort_buffer_size = 256K +sort_buffer_size = 4M read_buffer_size = 256K read_rnd_buffer_size = 256K net_buffer_length = 2K diff --git a/data/other/dpkg-origins/yunohost b/data/templates/yunohost/dpkg-origins similarity index 100% rename from data/other/dpkg-origins/yunohost rename to data/templates/yunohost/dpkg-origins diff --git a/data/other/yunoprompt.service b/data/templates/yunohost/yunoprompt.service similarity index 100% rename from data/other/yunoprompt.service rename to data/templates/yunohost/yunoprompt.service diff --git a/debian/changelog b/debian/changelog index 1b2817dbc..003a9de21 100644 --- a/debian/changelog +++ b/debian/changelog @@ -4,6 +4,41 @@ yunohost (11.0.0~alpha) unstable; urgency=low -- Alexandre Aubin Fri, 05 Feb 2021 00:02:38 +0100 +yunohost (4.3.0) testing; urgency=low + + - [users] Import/export users from/to CSV ([#1089](https://github.com/YunoHost/yunohost/pull/1089)) + - [domain] Add mDNS for .local domains / replace avahi-daemon ([#1112](https://github.com/YunoHost/yunohost/pull/1112)) + - [settings] new setting to enable experimental security features ([#1290](https://github.com/YunoHost/yunohost/pull/1290)) + - [settings] new setting to handle https redirect ([#1304](https://github.com/YunoHost/yunohost/pull/1304)) + - [diagnosis] add an "app" section to check that app are in catalog with good quality, check for deprecated practices ([#1217](https://github.com/YunoHost/yunohost/pull/1217)) + - [diagnosis] report suspiciously high number of auth failures ([#1292](https://github.com/YunoHost/yunohost/pull/1292)) + - [refactor] Rework the authentication system ([#1183](https://github.com/YunoHost/yunohost/pull/1183)) + - [enh] New config-panel mechanism ([#987](https://github.com/YunoHost/yunohost/pull/987)) + - [enh] Add backup for multimedia files (88063dc7) + - [enh] Configure automatically the DNS records using lexicon ([#1315](https://github.com/YunoHost/yunohost/pull/1315)) + - also brings domain settings, domain config panel, subdomain awareness, improvements in dns recommended conf + - [i18n] Translations updated for Catalan, Chinese (Simplified), Czech, Esperanto, French, Galician, German, Italian, Occitan, Persian, Portuguese, Spanish, Ukrainian + + Thanks to all contributors <3 ! (Corentin Mercier, Daniel, Éric Gaspar, Flavio Cristoforetti, Gregor Lenz, José M, Kay0u, ljf, MercierCorentin, mifegui, Paco, Parviz Homayun, ppr, tituspijean, Tymofii-Lytvynenko) + + -- Alexandre Aubin Sun, 19 Sep 2021 23:55:21 +0200 + +yunohost (4.2.8.3) stable; urgency=low + + - [fix] mysql: Another bump for sort_buffer_size to make Nextcloud 22 work (34e9246b) + + Thanks to all contributors <3 ! (ljf (zamentur)) + + -- Kay0u Fri, 10 Sep 2021 10:40:38 +0200 + +yunohost (4.2.8.2) stable; urgency=low + + - [fix] mysql: Bump sort_buffer_size to 256K to fix Nextcloud 22 installation (d8c49619) + + Thanks to all contributors <3 ! (ericg) + + -- Alexandre Aubin Tue, 07 Sep 2021 23:23:18 +0200 + yunohost (4.2.8.1) stable; urgency=low - [fix] Safer location for slapd backup during hdb/mdb migration (3c646b3d) diff --git a/debian/control b/debian/control index b21fb2c84..a896bb1eb 100644 --- a/debian/control +++ b/debian/control @@ -14,7 +14,7 @@ Depends: ${python3:Depends}, ${misc:Depends} , python3-psutil, python3-requests, python3-dnspython, python3-openssl , python3-miniupnpc, python3-dbus, python3-jinja2 , python3-toml, python3-packaging, python3-publicsuffix2 - , python3-ldap, python3-zeroconf + , python3-ldap, python3-zeroconf, python3-lexicon, , python-is-python3 , nginx, nginx-extras (>=1.18) , apt, apt-transport-https, apt-utils, dirmngr diff --git a/debian/install b/debian/install index bc3cc1f48..8c6ba01dd 100644 --- a/debian/install +++ b/debian/install @@ -1,17 +1,8 @@ bin/* /usr/bin/ +sbin/* /usr/sbin/ +data/* /usr/share/yunohost/ data/bash-completion.d/yunohost /etc/bash_completion.d/ doc/yunohost.8.gz /usr/share/man/man8/ -data/actionsmap/* /usr/share/moulinette/actionsmap/ -data/hooks/* /usr/share/yunohost/hooks/ -data/other/yunoprompt.service /etc/systemd/system/ -data/other/password/* /usr/share/yunohost/other/password/ -data/other/dpkg-origins/yunohost /etc/dpkg/origins -data/other/dnsbl_list.yml /usr/share/yunohost/other/ -data/other/ffdhe2048.pem /usr/share/yunohost/other/ -data/other/* /usr/share/yunohost/yunohost-config/moulinette/ -data/templates/* /usr/share/yunohost/templates/ -data/helpers /usr/share/yunohost/ -data/helpers.d/* /usr/share/yunohost/helpers.d/ lib/metronome/modules/* /usr/lib/metronome/modules/ locales/* /usr/lib/moulinette/yunohost/locales/ src/yunohost /usr/lib/moulinette diff --git a/debian/postinst b/debian/postinst index 8fc00bbe2..ceeed3cdf 100644 --- a/debian/postinst +++ b/debian/postinst @@ -5,6 +5,9 @@ set -e do_configure() { rm -rf /var/cache/moulinette/* + mkdir -p /usr/share/moulinette/actionsmap/ + ln -sf /usr/share/yunohost/actionsmap/yunohost.yml /usr/share/moulinette/actionsmap/yunohost.yml + if [ ! -f /etc/yunohost/installed ]; then # If apps/ is not empty, we're probably already installed in the past and # something funky happened ... @@ -31,14 +34,6 @@ do_configure() { yunohost diagnosis run --force fi - # Change dpkg vendor - # see https://wiki.debian.org/Derivatives/Guidelines#Vendor - readlink -f /etc/dpkg/origins/default | grep -q debian \ - && rm -f /etc/dpkg/origins/default \ - && ln -s /etc/dpkg/origins/yunohost /etc/dpkg/origins/default - - # Yunoprompt - systemctl enable yunoprompt.service } # summary of how this script can be called: diff --git a/doc/helper_doc_template.md b/doc/helper_doc_template.md index cf88e10ac..d41c0b6e9 100644 --- a/doc/helper_doc_template.md +++ b/doc/helper_doc_template.md @@ -10,11 +10,10 @@ routes: Doc auto-generated by [this script](https://github.com/YunoHost/yunohost/blob/{{ current_commit }}/doc/generate_helper_doc.py) on {{data.date}} (YunoHost version {{data.version}}) {% for category, helpers in data.helpers %} -### {{ category.upper() }} +## {{ category.upper() }} {% for h in helpers %} -**{{ h.name }}**
+#### {{ h.name }} [details summary="{{ h.brief }}" class="helper-card-subtitle text-muted"] -

**Usage**: `{{ h.usage }}` {%- if h.args %} diff --git a/locales/ar.json b/locales/ar.json index 3e5248917..487091995 100644 --- a/locales/ar.json +++ b/locales/ar.json @@ -6,7 +6,6 @@ "app_already_installed": "{app} تم تنصيبه مِن قبل", "app_already_up_to_date": "{app} تم تحديثه مِن قَبل", "app_argument_required": "المُعامِل '{name}' مطلوب", - "app_change_url_failed_nginx_reload": "فشلت عملية إعادة تشغيل NGINX. ها هي نتيجة الأمر 'nginx -t':\n{nginx_errors}", "app_extraction_failed": "تعذر فك الضغط عن ملفات التنصيب", "app_install_files_invalid": "ملفات التنصيب خاطئة", "app_not_correctly_installed": "يبدو أن التطبيق {app} لم يتم تنصيبه بشكل صحيح", @@ -40,7 +39,6 @@ "domain_creation_failed": "تعذرت عملية إنشاء النطاق", "domain_deleted": "تم حذف النطاق", "domain_exists": "اسم النطاق موجود مِن قبل", - "domain_unknown": "النطاق مجهول", "domains_available": "النطاقات المتوفرة :", "done": "تم", "downloading": "عملية التنزيل جارية …", @@ -55,7 +53,6 @@ "pattern_domain": "يتوجب أن يكون إسم نطاق صالح (مثل my-domain.org)", "pattern_email": "يتوجب أن يكون عنوان بريد إلكتروني صالح (مثل someone@domain.org)", "pattern_password": "يتوجب أن تكون مكونة من 3 حروف على الأقل", - "pattern_positive_number": "يجب أن يكون عددا إيجابيا", "restore_extracting": "جارٍ فك الضغط عن الملفات التي نحتاجها من النسخة الاحتياطية…", "server_shutdown": "سوف ينطفئ الخادوم", "server_shutdown_confirm": "سوف ينطفئ الخادوم حالا. متأكد ؟ [{answers}]", diff --git a/locales/ca.json b/locales/ca.json index 8efa1be12..00e3f65ad 100644 --- a/locales/ca.json +++ b/locales/ca.json @@ -6,10 +6,9 @@ "app_already_installed": "{app} ja està instal·lada", "app_already_installed_cant_change_url": "Aquesta aplicació ja està instal·lada. La URL no és pot canviar únicament amb aquesta funció. Mireu a `app changeurl` si està disponible.", "app_already_up_to_date": "{app} ja està actualitzada", - "app_argument_choice_invalid": "Utilitzeu una de les opcions «{choices}» per l'argument «{name}»", + "app_argument_choice_invalid": "Utilitzeu una de les opcions «{choices}» per l'argument «{name}» en lloc de «{value}»", "app_argument_invalid": "Escolliu un valor vàlid per l'argument «{name}»: {error}", "app_argument_required": "Es necessita l'argument '{name}'", - "app_change_url_failed_nginx_reload": "No s'ha pogut tornar a carregar NGINX. Aquí teniu el resultat de \"nginx -t\":\n{nginx_errors}", "app_change_url_identical_domains": "L'antic i el nou domini/camí són idèntics ('{domain}{path}'), no hi ha res per fer.", "app_change_url_no_script": "L'aplicació '{app_name}' encara no permet modificar la URL. Potser s'ha d'actualitzar.", "app_change_url_success": "La URL de {app} ara és {domain}{path}", @@ -112,11 +111,11 @@ "certmanager_self_ca_conf_file_not_found": "No s'ha trobat el fitxer de configuració per l'autoritat del certificat auto-signat (fitxer: {file})", "certmanager_unable_to_parse_self_CA_name": "No s'ha pogut analitzar el nom de l'autoritat del certificat auto-signat (fitxer: {file})", "confirm_app_install_warning": "Atenció: Aquesta aplicació funciona, però no està ben integrada amb YunoHost. Algunes característiques com la autenticació única i la còpia de seguretat/restauració poden no estar disponibles. Voleu instal·lar-la de totes maneres? [{answers}] ", - "confirm_app_install_danger": "PERILL! Aquesta aplicació encara és experimental (si no és que no funciona directament)! No hauríeu d'instal·lar-la a no ser que sapigueu el que feu. No obtindreu CAP AJUDA si l'aplicació no funciona o trenca el sistema… Si accepteu el risc, escriviu «{answers}»", + "confirm_app_install_danger": "PERILL! Aquesta aplicació encara és experimental (si no és que no funciona directament)! No hauríeu d'instal·lar-la a no ser que sapigueu el que feu. No obtindreu CAP AJUDA si l'aplicació no funciona o trenca el sistema... Si accepteu el risc, escriviu «{answers}»", "confirm_app_install_thirdparty": "PERILL! Aquesta aplicació no es part del catàleg d'aplicacions de YunoHost. La instal·lació d'aplicacions de terceres parts pot comprometre la integritat i seguretat del seu sistema. No hauríeu d'instal·lar-ne a no ser que sapigueu el que feu. No obtindreu CAP AJUDA si l'aplicació no funciona o trenca el sistema… Si accepteu el risc, escriviu «{answers}»", "custom_app_url_required": "Heu de especificar una URL per actualitzar la vostra aplicació personalitzada {app}", "admin_password_too_long": "Trieu una contrasenya de menys de 127 caràcters", - "dpkg_is_broken": "No es pot fer això en aquest instant perquè dpkg/APT (els gestors de paquets del sistema) sembla estar mal configurat… Podeu intentar solucionar-ho connectant-vos per SSH i executant «sudo apt install --fix-broken» i/o «sudo dpkg --configure -a».", + "dpkg_is_broken": "No es pot fer això en aquest instant perquè dpkg/APT (els gestors de paquets del sistema) sembla estar mal configurat... Podeu intentar solucionar-ho connectant-vos per SSH i executant «sudo apt install --fix-broken» i/o «sudo dpkg --configure -a».", "domain_cannot_remove_main": "No es pot eliminar «{domain}» ja que és el domini principal, primer s'ha d'establir un nou domini principal utilitzant «yunohost domain main-domain -n »; aquí hi ha una llista dels possibles dominis: {other_domains}", "domain_cert_gen_failed": "No s'ha pogut generar el certificat", "domain_created": "S'ha creat el domini", @@ -130,10 +129,9 @@ "domain_dyndns_root_unknown": "Domini DynDNS principal desconegut", "domain_hostname_failed": "No s'ha pogut establir un nou nom d'amfitrió. Això podria causar problemes més tard (podria no passar res).", "domain_uninstall_app_first": "Aquestes aplicacions encara estan instal·lades en el vostre domini:\n{apps}\n\nDesinstal·leu-les utilitzant l'ordre «yunohost app remove id_de_lapplicació» o moveu-les a un altre domini amb «yunohost app change-url id_de_lapplicació» abans d'eliminar el domini", - "domain_unknown": "Domini desconegut", "domains_available": "Dominis disponibles:", "done": "Fet", - "downloading": "Descarregant…", + "downloading": "Descarregant...", "dyndns_could_not_check_provide": "No s'ha pogut verificar si {provider} pot oferir {domain}.", "dyndns_could_not_check_available": "No s'ha pogut verificar la disponibilitat de {domain} a {provider}.", "dyndns_ip_update_failed": "No s'ha pogut actualitzar l'adreça IP al DynDNS", @@ -177,7 +175,7 @@ "iptables_unavailable": "No podeu modificar les iptables aquí. O bé sou en un contenidor o bé el vostre nucli no és compatible amb aquesta opció", "log_corrupted_md_file": "El fitxer de metadades YAML associat amb els registres està malmès: « {md_file} »\nError: {error}", "log_link_to_log": "El registre complet d'aquesta operació: «{desc}»", - "log_help_to_get_log": "Per veure el registre de l'operació « {desc} », utilitzeu l'ordre « yunohost log show {name}{name} »", + "log_help_to_get_log": "Per veure el registre de l'operació « {desc} », utilitzeu l'ordre « yunohost log show {name} »", "log_link_to_failed_log": "No s'ha pogut completar l'operació « {desc} ». Per obtenir ajuda, proveïu el registre complete de l'operació clicant aquí", "log_help_to_get_failed_log": "No s'ha pogut completar l'operació « {desc} ». Per obtenir ajuda, compartiu el registre complete de l'operació utilitzant l'ordre « yunohost log share {name} »", "log_does_exists": "No hi ha cap registre per l'operació amb el nom« {log} », utilitzeu « yunohost log list » per veure tots els registre d'operació disponibles", @@ -237,7 +235,6 @@ "pattern_mailbox_quota": "Ha de ser una mida amb el sufix b/k/M/G/T o 0 per no tenir quota", "pattern_password": "Ha de tenir un mínim de 3 caràcters", "pattern_port_or_range": "Ha de ser un número de port vàlid (i.e. 0-65535) o un interval de ports (ex. 100:200)", - "pattern_positive_number": "Ha de ser un nombre positiu", "pattern_username": "Ha d'estar compost per caràcters alfanumèrics en minúscula i guió baix exclusivament", "pattern_password_app": "Les contrasenyes no poden de tenir els següents caràcters: {forbidden_chars}", "port_already_closed": "El port {port} ja està tancat per les connexions {ip_version}", @@ -254,7 +251,7 @@ "regenconf_up_to_date": "La configuració ja està al dia per la categoria «{category}»", "regenconf_updated": "S'ha actualitzat la configuració per la categoria «{category}»", "regenconf_would_be_updated": "La configuració hagués estat actualitzada per la categoria «{category}»", - "regenconf_dry_pending_applying": "Verificació de la configuració pendent que s'hauria d'haver aplicat per la categoria «{category}»…", + "regenconf_dry_pending_applying": "Verificació de la configuració pendent que s'hauria d'haver aplicat per la categoria «{category}»...", "regenconf_failed": "No s'ha pogut regenerar la configuració per la/les categoria/es : {categories}", "regenconf_pending_applying": "Aplicació de la configuració pendent per la categoria «{category}»...", "restore_already_installed_app": "Una aplicació amb la ID «{app}» ja està instal·lada", @@ -262,15 +259,15 @@ "restore_cleaning_failed": "No s'ha pogut netejar el directori temporal de restauració", "restore_complete": "Restauració completada", "restore_confirm_yunohost_installed": "Esteu segur de voler restaurar un sistema ja instal·lat? [{answers}]", - "restore_extracting": "Extracció dels fitxers necessaris de l'arxiu…", + "restore_extracting": "Extracció dels fitxers necessaris de l'arxiu...", "restore_failed": "No s'ha pogut restaurar el sistema", "restore_hook_unavailable": "El script de restauració «{part}» no està disponible en el sistema i tampoc és en l'arxiu", "restore_may_be_not_enough_disk_space": "Sembla que no hi ha prou espai disponible en el sistema (lliure: {free_space} B, espai necessari: {needed_space} B, marge de seguretat: {margin} B)", "restore_not_enough_disk_space": "No hi ha prou espai disponible (espai: {free_space} B, espai necessari: {needed_space} B, marge de seguretat: {margin} B)", "restore_nothings_done": "No s'ha restaurat res", "restore_removing_tmp_dir_failed": "No s'ha pogut eliminar un directori temporal antic", - "restore_running_app_script": "Restaurant l'aplicació «{app}»…", - "restore_running_hooks": "Execució dels hooks de restauració…", + "restore_running_app_script": "Restaurant l'aplicació «{app}»...", + "restore_running_hooks": "Execució dels hooks de restauració...", "restore_system_part_failed": "No s'ha pogut restaurar la part «{part}» del sistema", "root_password_desynchronized": "S'ha canviat la contrasenya d'administració, però YunoHost no ha pogut propagar-ho cap a la contrasenya root!", "root_password_replaced_by_admin_password": "La contrasenya root s'ha substituït per la contrasenya d'administració.", @@ -319,13 +316,13 @@ "system_upgraded": "S'ha actualitzat el sistema", "system_username_exists": "El nom d'usuari ja existeix en la llista d'usuaris de sistema", "this_action_broke_dpkg": "Aquesta acció a trencat dpkg/APT (els gestors de paquets del sistema)... Podeu intentar resoldre el problema connectant-vos amb SSH i executant «sudo apt install --fix-broken» i/o «sudo dpkg --configure -a».", - "tools_upgrade_at_least_one": "Especifiqueu «--apps», o «--system»", + "tools_upgrade_at_least_one": "Especifiqueu «apps», o «system»", "tools_upgrade_cant_both": "No es poden actualitzar tant el sistema com les aplicacions al mateix temps", - "tools_upgrade_cant_hold_critical_packages": "No es poden mantenir els paquets crítics…", - "tools_upgrade_cant_unhold_critical_packages": "No es poden deixar de mantenir els paquets crítics…", - "tools_upgrade_regular_packages": "Actualitzant els paquets «normals» (no relacionats amb YunoHost)…", + "tools_upgrade_cant_hold_critical_packages": "No es poden mantenir els paquets crítics...", + "tools_upgrade_cant_unhold_critical_packages": "No es poden deixar de mantenir els paquets crítics...", + "tools_upgrade_regular_packages": "Actualitzant els paquets «normals» (no relacionats amb YunoHost)...", "tools_upgrade_regular_packages_failed": "No s'han pogut actualitzar els paquets següents: {packages_list}", - "tools_upgrade_special_packages": "Actualitzant els paquets «especials» (relacionats amb YunoHost)…", + "tools_upgrade_special_packages": "Actualitzant els paquets «especials» (relacionats amb YunoHost)...", "tools_upgrade_special_packages_explanation": "Aquesta actualització especial continuarà en segon pla. No comenceu cap altra acció al servidor en els pròxims ~10 minuts (depèn de la velocitat del maquinari). Després d'això, pot ser que us hagueu de tornar a connectar a la interfície d'administració. Els registres de l'actualització estaran disponibles a Eines → Registres (a la interfície d'administració) o utilitzant «yunohost log list» (des de la línia d'ordres).", "tools_upgrade_special_packages_completed": "Actualització dels paquets YunoHost acabada.\nPremeu [Enter] per tornar a la línia d'ordres", "unbackup_app": "{app} no es guardarà", @@ -345,7 +342,7 @@ "user_creation_failed": "No s'ha pogut crear l'usuari {user}: {error}", "user_deleted": "S'ha suprimit l'usuari", "user_deletion_failed": "No s'ha pogut suprimir l'usuari {user}: {error}", - "user_home_creation_failed": "No s'ha pogut crear la carpeta personal «home» per l'usuari", + "user_home_creation_failed": "No s'ha pogut crear la carpeta personal «{home}» per l'usuari", "user_unknown": "Usuari desconegut: {user}", "user_update_failed": "No s'ha pogut actualitzar l'usuari {user}: {error}", "user_updated": "S'ha canviat la informació de l'usuari", @@ -472,7 +469,7 @@ "diagnosis_http_unreachable": "Sembla que el domini {domain} no és accessible a través de HTTP des de fora de la xarxa local.", "diagnosis_unknown_categories": "Les següents categories són desconegudes: {categories}", "apps_catalog_init_success": "S'ha iniciat el sistema de catàleg d'aplicacions!", - "apps_catalog_updating": "S'està actualitzant el catàleg d'aplicacions…", + "apps_catalog_updating": "S'està actualitzant el catàleg d'aplicacions...", "apps_catalog_failed_to_download": "No s'ha pogut descarregar el catàleg d'aplicacions {apps_catalog}: {error}", "apps_catalog_obsolete_cache": "La memòria cau del catàleg d'aplicacions és buida o obsoleta.", "apps_catalog_update_success": "S'ha actualitzat el catàleg d'aplicacions!", @@ -485,14 +482,12 @@ "diagnosis_no_cache": "Encara no hi ha memòria cau pel diagnòstic de la categoria «{category}»", "diagnosis_http_timeout": "S'ha exhaurit el temps d'esperar intentant connectar amb el servidor des de l'exterior.
1. La causa més probable per a aquest problema és que el port 80 (i 443) no reenvien correctament cap al vostre servidor.
2. També us hauríeu d'assegurar que el servei nginx estigui funcionant
3. En configuracions més complexes: assegureu-vos que no hi ha cap tallafoc o reverse-proxy interferint.", "diagnosis_http_connection_error": "Error de connexió: no s'ha pogut connectar amb el domini demanat, segurament és inaccessible.", - "yunohost_postinstall_end_tip": "S'ha completat la post-instal·lació. Per acabar la configuració, considereu:\n - afegir un primer usuari a través de la secció «Usuaris» a la pàgina web d'administració (o emprant «yunohost user create » a la línia d'ordres);\n - diagnosticar possibles problemes a través de la secció «Diagnòstics» a la pàgina web d'administració (o emprant «yunohost diagnosis run» a la línia d'ordres);\n - llegir les seccions «Finalizing your setup» i «Getting to know Yunohost» a la documentació per administradors: https://yunohost.org/admindoc.", + "yunohost_postinstall_end_tip": "S'ha completat la post-instal·lació. Per acabar la configuració, considereu:\n - afegir un primer usuari a través de la secció «Usuaris» a la pàgina web d'administració (o emprant «yunohost user create » a la línia d'ordres);\n - diagnosticar possibles problemes a través de la secció «Diagnòstics» a la pàgina web d'administració (o emprant «yunohost diagnosis run» a la línia d'ordres);\n - llegir les seccions «Finalizing your setup» i «Getting to know YunoHost» a la documentació per administradors: https://yunohost.org/admindoc.", "diagnosis_services_running": "El servei {service} s'està executant!", "diagnosis_services_conf_broken": "La configuració pel servei {service} està trencada!", "diagnosis_ports_needed_by": "És necessari exposar aquest port per a les funcions {category} (servei {service})", "global_settings_setting_pop3_enabled": "Activa el protocol POP3 per al servidor de correu", "log_app_action_run": "Executa l'acció de l'aplicació «{}»", - "log_app_config_show_panel": "Mostra el taulell de configuració de l'aplicació «{}»", - "log_app_config_apply": "Afegeix la configuració a l'aplicació «{}»", "diagnosis_never_ran_yet": "Sembla que el servidor s'ha configurat recentment i encara no hi cap informe de diagnòstic per mostrar. S'ha d'executar un diagnòstic complet primer, ja sigui des de la pàgina web d'administració o utilitzant la comanda «yunohost diagnosis run» al terminal.", "diagnosis_description_web": "Web", "diagnosis_basesystem_hardware": "L'arquitectura del maquinari del servidor és {virt} {arch}", diff --git a/locales/ckb.json b/locales/ckb.json index 0967ef424..9e26dfeeb 100644 --- a/locales/ckb.json +++ b/locales/ckb.json @@ -1 +1 @@ -{} +{} \ No newline at end of file diff --git a/locales/cs.json b/locales/cs.json index cd1e9f7ae..47262064e 100644 --- a/locales/cs.json +++ b/locales/cs.json @@ -11,7 +11,6 @@ "action_invalid": "Nesprávné akce '{action}'", "aborting": "Zrušeno.", "app_change_url_identical_domains": "Stará a nová doména/url_cesta jsou totožné ('{domain}{path}'), nebudou provedeny žádné změny.", - "app_change_url_failed_nginx_reload": "Nepodařilo se znovunačís NGINX. Následuje výpis příkazu 'nginx -t':\n{nginx_errors}", "app_argument_invalid": "Vyberte správnou hodnotu pro argument '{name}': {error}", "app_argument_choice_invalid": "Vyberte jednu z možností '{choices}' pro argument'{name}'", "app_already_up_to_date": "{app} aplikace je/jsou aktuální", @@ -55,7 +54,7 @@ "global_settings_setting_smtp_relay_password": "SMTP relay heslo uživatele/hostitele", "global_settings_setting_smtp_relay_user": "SMTP relay uživatelské jméno/účet", "global_settings_setting_smtp_relay_port": "SMTP relay port", - "global_settings_setting_smtp_relay_host": "Použít SMTP relay hostitele pro odesílání emailů místo této Yunohost instance. Užitečné v různých situacích: port 25 je blokován vaším ISP nebo VPS poskytovatelem, IP adresa je na blacklistu (např. DUHL), nemůžete nastavit reverzní DNS záznam nebo tento server není přímo připojen do internetu a vy chcete použít jiný server k odesílání emailů.", + "global_settings_setting_smtp_relay_host": "Použít SMTP relay hostitele pro odesílání emailů místo této YunoHost instance. Užitečné v různých situacích: port 25 je blokován vaším ISP nebo VPS poskytovatelem, IP adresa je na blacklistu (např. DUHL), nemůžete nastavit reverzní DNS záznam nebo tento server není přímo připojen do internetu a vy chcete použít jiný server k odesílání emailů.", "global_settings_setting_smtp_allow_ipv6": "Povolit použití IPv6 pro příjem a odesílání emailů", "global_settings_setting_ssowat_panel_overlay_enabled": "Povolit SSOwat překryvný panel", "global_settings_setting_service_ssh_allow_deprecated_dsa_hostkey": "Povolit použití (zastaralého) DSA klíče hostitele pro konfiguraci SSH služby", diff --git a/locales/de.json b/locales/de.json index fe4112934..199718c2b 100644 --- a/locales/de.json +++ b/locales/de.json @@ -47,9 +47,8 @@ "domain_dyndns_root_unknown": "Unbekannte DynDNS Hauptdomain", "domain_exists": "Die Domäne existiert bereits", "domain_uninstall_app_first": "Diese Applikationen sind noch auf Ihrer Domäne installiert; \n{apps}\n\nBitte deinstallieren Sie sie mit dem Befehl 'yunohost app remove the_app_id' oder verschieben Sie sie mit 'yunohost app change-url the_app_id'", - "domain_unknown": "Unbekannte Domain", "done": "Erledigt", - "downloading": "Wird heruntergeladen…", + "downloading": "Wird heruntergeladen...", "dyndns_ip_update_failed": "Konnte die IP-Adresse für DynDNS nicht aktualisieren", "dyndns_ip_updated": "Aktualisierung Ihrer IP-Adresse bei DynDNS", "dyndns_key_generating": "Generierung des DNS-Schlüssels..., das könnte eine Weile dauern.", @@ -92,8 +91,8 @@ "restore_failed": "Das System konnte nicht wiederhergestellt werden", "restore_hook_unavailable": "Das Wiederherstellungsskript für '{part}' steht weder in Ihrem System noch im Archiv zur Verfügung", "restore_nothings_done": "Nichts wurde wiederhergestellt", - "restore_running_app_script": "App '{app}' wird wiederhergestellt…", - "restore_running_hooks": "Wiederherstellung wird gestartet…", + "restore_running_app_script": "App '{app}' wird wiederhergestellt...", + "restore_running_hooks": "Wiederherstellung wird gestartet...", "service_add_failed": "Der Dienst '{service}' konnte nicht hinzugefügt werden", "service_added": "Der Dienst '{service}' wurde erfolgreich hinzugefügt", "service_already_started": "Der Dienst '{service}' läuft bereits", @@ -140,7 +139,6 @@ "app_not_properly_removed": "{app} wurde nicht ordnungsgemäß entfernt", "not_enough_disk_space": "Nicht genügend Speicherplatz auf '{path}' frei", "backup_creation_failed": "Konnte Backup-Archiv nicht erstellen", - "pattern_positive_number": "Muss eine positive Zahl sein", "app_not_correctly_installed": "{app} scheint nicht korrekt installiert zu sein", "app_requirements_checking": "Überprüfe notwendige Pakete für {app}...", "app_requirements_unmeet": "Anforderungen für {app} werden nicht erfüllt, das Paket {pkgname} ({version}) muss {spec} sein", @@ -170,7 +168,6 @@ "certmanager_unable_to_parse_self_CA_name": "Der Name der Zertifizierungsstelle für selbstsignierte Zertifikate konnte nicht aufgelöst werden (Datei: {file})", "domain_hostname_failed": "Sie können keinen neuen Hostnamen verwenden. Das kann zukünftige Probleme verursachen (es kann auch sein, dass es funktioniert).", "app_already_installed_cant_change_url": "Diese Applikation ist bereits installiert. Die URL kann durch diese Funktion nicht modifiziert werden. Überprüfe ob `app changeurl` verfügbar ist.", - "app_change_url_failed_nginx_reload": "NGINX konnte nicht neu gestartet werden. Hier ist der Output von 'nginx -t':\n{nginx_errors}", "app_change_url_identical_domains": "Die alte und neue domain/url_path sind identisch: ('{domain} {path}'). Es gibt nichts zu tun.", "app_already_up_to_date": "{app} ist bereits aktuell", "backup_abstract_method": "Diese Backup-Methode wird noch nicht unterstützt", @@ -241,10 +238,10 @@ "log_operation_unit_unclosed_properly": "Die Operationseinheit wurde nicht richtig geschlossen", "global_settings_setting_security_postfix_compatibility": "Kompatibilitäts- vs. Sicherheits-Kompromiss für den Postfix-Server. Betrifft die Ciphers (und andere sicherheitsrelevante Aspekte)", "global_settings_unknown_type": "Unerwartete Situation, die Einstellung {setting} scheint den Typ {unknown_type} zu haben, ist aber kein vom System unterstützter Typ.", - "dpkg_is_broken": "Du kannst das gerade nicht tun, weil dpkg/APT (der Systempaketmanager) in einem defekten Zustand zu sein scheint.... Du kannst versuchen, dieses Problem zu lösen, indem du dich über SSH verbindest und `sudo apt install --fix-broken` sowie/oder `sudo dpkg --configure -a` ausführst.", + "dpkg_is_broken": "Du kannst das gerade nicht tun, weil dpkg/APT (der Systempaketmanager) in einem defekten Zustand zu sein scheint... Du kannst versuchen, dieses Problem zu lösen, indem du dich über SSH verbindest und `sudo apt install --fix-broken` sowie/oder `sudo dpkg --configure -a` ausführst.", "global_settings_unknown_setting_from_settings_file": "Unbekannter Schlüssel in den Einstellungen: '{setting_key}', verwerfen und speichern in /etc/yunohost/settings-unknown.json", "log_link_to_log": "Vollständiges Log dieser Operation: '{desc}'", - "log_help_to_get_log": "Um das Protokoll der Operation '{desc}' anzuzeigen, verwenden Sie den Befehl 'yunohost log show {name}{name}'", + "log_help_to_get_log": "Um das Protokoll der Operation '{desc}' anzuzeigen, verwenden Sie den Befehl 'yunohost log show {name}'", "global_settings_setting_security_nginx_compatibility": "Kompatibilitäts- vs. Sicherheits-Kompromiss für den Webserver NGINX. Betrifft die Ciphers (und andere sicherheitsrelevante Aspekte)", "global_settings_setting_service_ssh_allow_deprecated_dsa_hostkey": "Erlaubt die Verwendung eines (veralteten) DSA-Hostkeys für die SSH-Daemon-Konfiguration", "log_app_remove": "Entferne die Applikation '{}'", @@ -279,7 +276,7 @@ "diagnosis_basesystem_ynh_main_version": "Server läuft YunoHost {main_version} ({repo})", "diagnosis_basesystem_ynh_inconsistent_versions": "Sie verwenden inkonsistente Versionen der YunoHost-Pakete... wahrscheinlich wegen eines fehlgeschlagenen oder teilweisen Upgrades.", "apps_catalog_init_success": "App-Katalogsystem initialisiert!", - "apps_catalog_updating": "Aktualisierung des Applikationskatalogs…", + "apps_catalog_updating": "Aktualisierung des Applikationskatalogs...", "apps_catalog_failed_to_download": "Der {apps_catalog} App-Katalog kann nicht heruntergeladen werden: {error}", "apps_catalog_obsolete_cache": "Der Cache des App-Katalogs ist leer oder veraltet.", "apps_catalog_update_success": "Der Apps-Katalog wurde aktualisiert!", @@ -432,9 +429,9 @@ "additional_urls_already_removed": "Zusätzliche URL '{url}' bereits entfernt in der zusätzlichen URL für Berechtigung '{permission}'", "app_label_deprecated": "Dieser Befehl ist veraltet! Bitte nutzen Sie den neuen Befehl 'yunohost user permission update' um das Applabel zu verwalten.", "diagnosis_http_hairpinning_issue_details": "Das ist wahrscheinlich aufgrund Ihrer ISP Box / Router. Als Konsequenz können Personen von ausserhalb Ihres Netzwerkes aber nicht von innerhalb Ihres lokalen Netzwerkes (wie wahrscheinlich Sie selber?) wie gewohnt auf Ihren Server zugreifen, wenn Sie ihre Domäne oder Ihre öffentliche IP verwenden. Sie können die Situation wahrscheinlich verbessern, indem Sie ein einen Blick in https://yunohost.org/dns_local_network werfen", - "diagnosis_http_nginx_conf_not_up_to_date": "Jemand hat anscheinend die Konfiguration von Nginx manuell geändert. Diese Änderung verhindert, dass Yunohost eine Diagnose durchführen kann, wenn er via HTTP erreichbar ist.", + "diagnosis_http_nginx_conf_not_up_to_date": "Jemand hat anscheinend die Konfiguration von Nginx manuell geändert. Diese Änderung verhindert, dass YunoHost eine Diagnose durchführen kann, wenn er via HTTP erreichbar ist.", "diagnosis_http_bad_status_code": "Anscheinend beantwortet ein anderes Gerät als Ihr Server die Anfrage (Vielleicht ihr Internetrouter).
1. Die häufigste Ursache ist, dass Port 80 (und 443) nicht richtig auf Ihren Server weitergeleitet wird.
2. Bei komplexeren Setups: Vergewissern Sie sich, dass keine Firewall und keine Reverse-Proxy interferieren.", - "diagnosis_never_ran_yet": "Sie haben kürzlich einen neuen Yunohost-Server installiert aber es gibt davon noch keinen Diagnosereport. Sie sollten eine Diagnose anstossen. Sie können das entweder vom Webadmin aus oder in der Kommandozeile machen. In der Kommandozeile verwenden Sie dafür den Befehl 'yunohost diagnosis run'.", + "diagnosis_never_ran_yet": "Sie haben kürzlich einen neuen YunoHost-Server installiert aber es gibt davon noch keinen Diagnosereport. Sie sollten eine Diagnose anstossen. Sie können das entweder vom Webadmin aus oder in der Kommandozeile machen. In der Kommandozeile verwenden Sie dafür den Befehl 'yunohost diagnosis run'.", "diagnosis_http_nginx_conf_not_up_to_date_details": "Um dieses Problem zu beheben, geben Sie in der Kommandozeile yunohost tools regen-conf nginx --dry-run --with-diff ein. Dieses Tool zeigt ihnen den Unterschied an. Wenn Sie damit einverstanden sind, können Sie mit yunohost tools regen-conf nginx --force die Änderungen übernehmen.", "diagnosis_backports_in_sources_list": "Sie haben anscheinend apt (den Paketmanager) für das Backports-Repository konfiguriert. Wir raten strikte davon ab, Pakete aus dem Backports-Repository zu installieren. Diese würden wahrscheinlich zu Instabilitäten und Konflikten führen. Es sei denn, Sie wissen was Sie tun.", "diagnosis_basesystem_hardware_model": "Das Servermodell ist {model}", @@ -442,8 +439,8 @@ "group_user_not_in_group": "Der Benutzer {user} ist nicht in der Gruppe {group}", "group_user_already_in_group": "Der Benutzer {user} ist bereits in der Gruppe {group}", "group_cannot_edit_visitors": "Die Gruppe \"Besucher\" kann nicht manuell editiert werden. Sie ist eine Sondergruppe und repräsentiert anonyme Besucher", - "group_cannot_edit_all_users": "Die Gruppe \"all_users\" kann nicht manuell editiert werden. Sie ist eine Sondergruppe die dafür gedacht ist alle Benutzer in Yunohost zu halten", - "group_already_exist_on_system_but_removing_it": "Die Gruppe {group} existiert bereits in den Systemgruppen, aber Yunohost wird sie entfernen...", + "group_cannot_edit_all_users": "Die Gruppe \"all_users\" kann nicht manuell editiert werden. Sie ist eine Sondergruppe die dafür gedacht ist alle Benutzer in YunoHost zu halten", + "group_already_exist_on_system_but_removing_it": "Die Gruppe {group} existiert bereits in den Systemgruppen, aber YunoHost wird sie entfernen...", "group_already_exist_on_system": "Die Gruppe {group} existiert bereits in den Systemgruppen", "group_already_exist": "Die Gruppe {group} existiert bereits", "global_settings_setting_smtp_relay_password": "SMTP Relay Host Passwort", @@ -460,8 +457,6 @@ "log_backup_restore_app": "'{}' aus einem Backup-Archiv wiederherstellen", "log_backup_restore_system": "System aus einem Backup-Archiv wiederherstellen", "log_available_on_yunopaste": "Das Protokoll ist nun via {url} verfügbar", - "log_app_config_apply": "Wende die Konfiguration auf die Applikation '{}' an", - "log_app_config_show_panel": "Zeige das Konfigurations-Panel der Applikation '{}'", "log_app_action_run": "Führe Aktion der Applikation '{}' aus", "invalid_regex": "Ungültige Regex:'{regex}'", "migration_description_0016_php70_to_php73_pools": "Migrieren der php7.0-fpm-Konfigurationsdateien zu php7.3", @@ -566,13 +561,13 @@ "regenconf_updated": "Konfiguration aktualisiert für '{category}'", "regenconf_pending_applying": "Wende die anstehende Konfiguration für die Kategorie {category} an...", "regenconf_failed": "Konnte die Konfiguration für die Kategorie(n) {categories} nicht neu erstellen", - "regenconf_dry_pending_applying": "Überprüfe die anstehende Konfiguration, welche für die Kategorie {category}' aktualisiert worden wäre…", + "regenconf_dry_pending_applying": "Überprüfe die anstehende Konfiguration, welche für die Kategorie {category}' aktualisiert worden wäre...", "regenconf_would_be_updated": "Die Konfiguration wäre für die Kategorie '{category}' aktualisiert worden", "restore_system_part_failed": "Die Systemteile '{part}' konnten nicht wiederhergestellt werden", "restore_removing_tmp_dir_failed": "Ein altes, temporäres Directory konnte nicht entfernt werden", "restore_not_enough_disk_space": "Nicht genug Speicher (Speicher: {free_space} B, benötigter Speicher: {needed_space} B, Sicherheitspuffer: {margin} B)", "restore_may_be_not_enough_disk_space": "Ihr System scheint nicht genug Speicherplatz zu haben (frei: {free_space} B, benötigter Platz: {needed_space} B, Sicherheitspuffer: {margin} B)", - "restore_extracting": "Packe die benötigten Dateien aus dem Archiv aus…", + "restore_extracting": "Packe die benötigten Dateien aus dem Archiv aus...", "restore_already_installed_apps": "Folgende Apps können nicht wiederhergestellt werden, weil sie schon installiert sind: {apps}", "regex_with_only_domain": "Du kannst regex nicht als Domain verwenden, sondern nur als Pfad", "root_password_desynchronized": "Das Admin-Passwort wurde verändert, aber das Root-Passwort ist immer noch das alte!", @@ -617,16 +612,16 @@ "show_tile_cant_be_enabled_for_regex": "Momentan können Sie 'show_tile' nicht aktivieren, weil die URL für die Berechtigung '{permission}' ein regulärer Ausdruck ist", "show_tile_cant_be_enabled_for_url_not_defined": "Momentan können Sie 'show_tile' nicht aktivieren, weil Sie zuerst eine URL für die Berechtigung '{permission}' definieren müssen", "tools_upgrade_regular_packages_failed": "Konnte für die folgenden Pakete das Upgrade nicht durchführen: {packages_list}", - "tools_upgrade_regular_packages": "Momentan werden Upgrades für das System (YunoHost-unabhängige) Pakete durchgeführt…", - "tools_upgrade_cant_unhold_critical_packages": "Konnte für die kritischen Pakete das Flag 'hold' nicht aufheben…", - "tools_upgrade_cant_hold_critical_packages": "Konnte für die kritischen Pakete das Flag 'hold' nicht setzen…", + "tools_upgrade_regular_packages": "Momentan werden Upgrades für das System (YunoHost-unabhängige) Pakete durchgeführt...", + "tools_upgrade_cant_unhold_critical_packages": "Konnte für die kritischen Pakete das Flag 'hold' nicht aufheben...", + "tools_upgrade_cant_hold_critical_packages": "Konnte für die kritischen Pakete das Flag 'hold' nicht setzen...", "tools_upgrade_cant_both": "Kann das Upgrade für das System und die Applikation nicht gleichzeitig durchführen", "tools_upgrade_at_least_one": "Bitte geben Sie '--apps' oder '--system' an", "this_action_broke_dpkg": "Diese Aktion hat unkonfigurierte Pakete verursacht, welche durch dpkg/apt (die Paketverwaltungen dieses Systems) zurückgelassen wurden... Sie können versuchen dieses Problem zu lösen, indem Sie 'sudo apt install --fix-broken' und/oder 'sudo dpkg --configure -a' ausführen.", "update_apt_cache_failed": "Kann den Cache von APT (Debians Paketmanager) nicht aktualisieren. Hier ist ein Auszug aus den sources.list-Zeilen, die helfen könnten, das Problem zu identifizieren:\n{sourceslist}", "tools_upgrade_special_packages_completed": "YunoHost-Paketupdate beendet.\nDrücke [Enter], um zurück zur Kommandoziele zu kommen", "tools_upgrade_special_packages_explanation": "Das Upgrade \"special\" wird im Hintergrund ausgeführt. Bitte starten Sie keine anderen Aktionen auf Ihrem Server für die nächsten ~10 Minuten. Die Dauer ist abhängig von der Geschwindigkeit Ihres Servers. Nach dem Upgrade müssen Sie sich eventuell erneut in das Adminportal einloggen. Upgrade-Logs sind im Adminbereich unter Tools → Log verfügbar. Alternativ können Sie in der Befehlszeile 'yunohost log list' eingeben.", - "tools_upgrade_special_packages": "\"special\" (YunoHost-bezogene) Pakete werden jetzt aktualisiert…", + "tools_upgrade_special_packages": "\"special\" (YunoHost-bezogene) Pakete werden jetzt aktualisiert...", "unknown_main_domain_path": "Unbekannte:r Domain oder Pfad für '{app}'. Du musst eine Domain und einen Pfad setzen, um die URL für Berechtigungen zu setzen.", "yunohost_postinstall_end_tip": "Post-install ist fertig! Um das Setup abzuschliessen, wird empfohlen:\n - einen ersten Benutzer über den Bereich 'Benutzer*in' im Adminbereich hinzuzufügen (oder mit 'yunohost user create ' in der Kommandezeile);\n - mögliche Fehler zu diagnostizieren über den Bereich 'Diagnose' im Adminbereich (oder mit 'yunohost diagnosis run' in der Kommandozeile;\n - Die Abschnitte 'Install YunoHost' und 'Geführte Tour' im Administratorenhandbuch zu lesen: https://yunohost.org/admindoc.", "user_already_exists": "Der Benutzer '{user}' ist bereits vorhanden", @@ -634,5 +629,6 @@ "global_settings_setting_security_webadmin_allowlist": "IP-Adressen, die auf die Verwaltungsseite zugreifen dürfen. Kommasepariert.", "global_settings_setting_security_webadmin_allowlist_enabled": "Erlaube nur bestimmten IP-Adressen den Zugriff auf die Verwaltungsseite.", "disk_space_not_sufficient_update": "Es ist nicht genügend Speicherplatz frei, um diese Applikation zu aktuallisieren", - "disk_space_not_sufficient_install": "Es ist nicht genügend Speicherplatz frei, um diese Applikation zu installieren" -} + "disk_space_not_sufficient_install": "Es ist nicht genügend Speicherplatz frei, um diese Applikation zu installieren", + "danger": "Warnung:" +} \ No newline at end of file diff --git a/locales/en.json b/locales/en.json index 38b585821..4ed4decca 100644 --- a/locales/en.json +++ b/locales/en.json @@ -13,14 +13,17 @@ "app_already_installed": "{app} is already installed", "app_already_installed_cant_change_url": "This app is already installed. The URL cannot be changed just by this function. Check in `app changeurl` if it's available.", "app_already_up_to_date": "{app} is already up-to-date", - "app_argument_choice_invalid": "Use one of these choices '{choices}' for the argument '{name}'", + "app_argument_choice_invalid": "Use one of these choices '{choices}' for the argument '{name}' instead of '{value}'", "app_argument_invalid": "Pick a valid value for the argument '{name}': {error}", + "app_argument_password_help_keep": "Press Enter to keep the current value", + "app_argument_password_help_optional": "Type one space to empty the password", "app_argument_password_no_default": "Error while parsing password argument '{name}': password argument can't have a default value for security reason", "app_argument_required": "Argument '{name}' is required", - "app_change_url_failed_nginx_reload": "Could not reload NGINX. Here is the output of 'nginx -t':\n{nginx_errors}", "app_change_url_identical_domains": "The old and new domain/url_path are identical ('{domain}{path}'), nothing to do.", "app_change_url_no_script": "The app '{app_name}' doesn't support URL modification yet. Maybe you should upgrade it.", "app_change_url_success": "{app} URL is now {domain}{path}", + "app_config_unable_to_apply": "Failed to apply config panel values.", + "app_config_unable_to_read": "Failed to read config panel values.", "app_extraction_failed": "Could not extract the installation files", "app_full_domain_unavailable": "Sorry, this app must be installed on a domain of its own, but other apps are already installed on the domain '{domain}'. You could use a subdomain dedicated to this app instead.", "app_id_invalid": "Invalid app ID", @@ -139,10 +142,22 @@ "certmanager_self_ca_conf_file_not_found": "Could not find configuration file for self-signing authority (file: {file})", "certmanager_unable_to_parse_self_CA_name": "Could not parse name of self-signing authority (file: {file})", "certmanager_warning_subdomain_dns_record": "Subdomain '{subdomain}' does not resolve to the same IP address as '{domain}'. Some features will not be available until you fix this and regenerate the certificate.", + "config_apply_failed": "Applying the new configuration failed: {error}", + "config_cant_set_value_on_section": "You can't set a single value on an entire config section.", + "config_forbidden_keyword": "The keyword '{keyword}' is reserved, you can't create or use a config panel with a question with this id.", + "config_no_panel": "No config panel found.", + "config_unknown_filter_key": "The filter key '{filter_key}' is incorrect.", + "config_validate_color": "Should be a valid RGB hexadecimal color", + "config_validate_date": "Should be a valid date like in the format YYYY-MM-DD", + "config_validate_email": "Should be a valid email", + "config_validate_time": "Should be a valid time like HH:MM", + "config_validate_url": "Should be a valid web URL", + "config_version_not_supported": "Config panel versions '{version}' are not supported.", "confirm_app_install_danger": "DANGER! This app is known to be still experimental (if not explicitly not working)! You should probably NOT install it unless you know what you are doing. NO SUPPORT will be provided if this app doesn't work or breaks your system... If you are willing to take that risk anyway, type '{answers}'", "confirm_app_install_thirdparty": "DANGER! This app is not part of YunoHost's app catalog. Installing third-party apps may compromise the integrity and security of your system. You should probably NOT install it unless you know what you are doing. NO SUPPORT will be provided if this app doesn't work or breaks your system... If you are willing to take that risk anyway, type '{answers}'", "confirm_app_install_warning": "Warning: This app may work, but is not well-integrated in YunoHost. Some features such as single sign-on and backup/restore might not be available. Install anyway? [{answers}] ", "custom_app_url_required": "You must provide a URL to upgrade your custom app {app}", + "danger": "Danger:", "diagnosis_apps_allgood": "All installed apps respect basic packaging practices", "diagnosis_apps_bad_quality": "This application is currently flagged as broken on YunoHost's application catalog. This may be a temporary issue while the maintainers attempt to fix the issue. In the meantime, upgrading this app is disabled.", "diagnosis_apps_broken": "This application is currently flagged as broken on YunoHost's application catalog. This may be a temporary issue while the maintainers attempt to fix the issue. In the meantime, upgrading this app is disabled.", @@ -302,7 +317,34 @@ "domain_name_unknown": "Domain '{domain}' unknown", "domain_remove_confirm_apps_removal": "Removing this domain will remove those applications:\n{apps}\n\nAre you sure you want to do that? [{answers}]", "domain_uninstall_app_first": "Those applications are still installed on your domain:\n{apps}\n\nPlease uninstall them using 'yunohost app remove the_app_id' or move them to another domain using 'yunohost app change-url the_app_id' before proceeding to domain removal", - "domain_unknown": "Unknown domain", + "domain_registrar_is_not_configured": "The registrar is not yet configured for domain {domain}.", + "domain_dns_push_not_applicable": "The automatic DNS configuration feature is not applicable to domain {domain}. You should manually configure your DNS records following the documentation at https://yunohost.org/dns_config.", + "domain_dns_push_managed_in_parent_domain": "The automatic DNS configuration feature is managed in the parent domain {parent_domain}.", + "domain_dns_registrar_managed_in_parent_domain": "This domain is a subdomain of {parent_domain_link}. DNS registrar configuration should be managed in {parent_domain}'s configuration panel.", + "domain_dns_registrar_yunohost": "This domain is a nohost.me / nohost.st / ynh.fr and its DNS configuration is therefore automatically handled by YunoHost without any further configuration. (see the 'yunohost dyndns update' command)", + "domain_dns_registrar_not_supported": "YunoHost could not automatically detect the registrar handling this domain. You should manually configure your DNS records following the documentation at https://yunohost.org/dns.", + "domain_dns_registrar_supported": "YunoHost automatically detected that this domain is handled by the registrar **{registrar}**. If you want, YunoHost will automatically configure this DNS zone, if you provide it with the appropriate API credentials. You can find documentation on how to obtain your API credentials on this page: https://yunohost.org/registar_api_{registrar}. (You can also manually configure your DNS records following the documentation at https://yunohost.org/dns )", + "domain_dns_registrar_experimental": "So far, the interface with **{registrar}**'s API has not been properly tested and reviewed by the YunoHost community. Support is **very experimental** - be careful!", + "domain_dns_push_failed_to_authenticate": "Failed to authenticate on registrar's API for domain '{domain}'. Most probably the credentials are incorrect? (Error: {error})", + "domain_dns_push_failed_to_list": "Failed to list current records using the registrar's API: {error}", + "domain_dns_push_already_up_to_date": "Records already up to date, nothing to do.", + "domain_dns_pushing": "Pushing DNS records...", + "domain_dns_push_record_failed": "Failed to {action} record {type}/{name} : {error}", + "domain_dns_push_success": "DNS records updated!", + "domain_dns_push_failed": "Updating the DNS records failed miserably.", + "domain_dns_push_partial_failure": "DNS records partially updated: some warnings/errors were reported.", + "domain_config_features_disclaimer": "So far, enabling/disabling mail or XMPP features only impact the recommended and automatic DNS configuration, not system configurations!", + "domain_config_mail_in": "Incoming emails", + "domain_config_mail_out": "Outgoing emails", + "domain_config_xmpp": "Instant messaging (XMPP)", + "domain_config_auth_token": "Authentication token", + "domain_config_auth_key": "Authentication key", + "domain_config_auth_secret": "Authentication secret", + "domain_config_api_protocol": "API protocol", + "domain_config_auth_entrypoint": "API entry point", + "domain_config_auth_application_key": "Application key", + "domain_config_auth_application_secret": "Application secret key", + "domain_config_auth_consumer_key": "Consumer key", "domains_available": "Available domains:", "done": "Done", "downloading": "Downloading...", @@ -324,6 +366,7 @@ "extracting": "Extracting...", "field_invalid": "Invalid field '{}'", "file_does_not_exist": "The file {path} does not exist.", + "file_extension_not_accepted": "Refusing file '{path}' because its extension is not among the accepted extensions: {accept}", "firewall_reload_failed": "Could not reload the firewall", "firewall_reloaded": "Firewall reloaded", "firewall_rules_cmd_failed": "Some firewall rule commands have failed. More info in log.", @@ -337,8 +380,8 @@ "global_settings_setting_backup_compress_tar_archives": "When creating new backups, compress the archives (.tar.gz) instead of uncompressed archives (.tar). N.B. : enabling this option means create lighter backup archives, but the initial backup procedure will be significantly longer and heavy on CPU.", "global_settings_setting_pop3_enabled": "Enable the POP3 protocol for the mail server", "global_settings_setting_security_experimental_enabled": "Enable experimental security features (don't enable this if you don't know what you're doing!)", - "global_settings_setting_security_nginx_redirect_to_https": "Redirect HTTP requests to HTTPs by default (DO NOT TURN OFF unless you really know what you're doing!)", "global_settings_setting_security_nginx_compatibility": "Compatibility vs. security tradeoff for the web server NGINX. Affects the ciphers (and other security-related aspects)", + "global_settings_setting_security_nginx_redirect_to_https": "Redirect HTTP requests to HTTPs by default (DO NOT TURN OFF unless you really know what you're doing!)", "global_settings_setting_security_password_admin_strength": "Admin password strength", "global_settings_setting_security_password_user_strength": "User password strength", "global_settings_setting_security_postfix_compatibility": "Compatibility vs. security tradeoff for the Postfix server. Affects the ciphers (and other security-related aspects)", @@ -380,16 +423,18 @@ "hook_name_unknown": "Unknown hook name '{name}'", "installation_complete": "Installation completed", "invalid_number": "Must be a number", + "invalid_number_min": "Must be greater than {min}", + "invalid_number_max": "Must be lesser than {max}", "invalid_password": "Invalid password", "invalid_regex": "Invalid regex:'{regex}'", "ip6tables_unavailable": "You cannot play with ip6tables here. You are either in a container or your kernel does not support it", "iptables_unavailable": "You cannot play with iptables here. You are either in a container or your kernel does not support it", "ldap_server_down": "Unable to reach LDAP server", "ldap_server_is_down_restart_it": "The LDAP service is down, attempt to restart it...", + "ldap_attribute_already_exists": "LDAP attribute '{attribute}' already exists with value '{value}'", "log_app_action_run": "Run action of the '{}' app", "log_app_change_url": "Change the URL of the '{}' app", - "log_app_config_apply": "Apply config to the '{}' app", - "log_app_config_show_panel": "Show the config panel of the '{}' app", + "log_app_config_set": "Apply config to the '{}' app", "log_app_install": "Install the '{}' app", "log_app_makedefault": "Make '{}' the default app", "log_app_remove": "Remove the '{}' app", @@ -401,12 +446,14 @@ "log_corrupted_md_file": "The YAML metadata file associated with logs is damaged: '{md_file}\nError: {error}'", "log_does_exists": "There is no operation log with the name '{log}', use 'yunohost log list' to see all available operation logs", "log_domain_add": "Add '{}' domain into system configuration", + "log_domain_config_set": "Update configuration for domain '{}'", "log_domain_main_domain": "Make '{}' the main domain", "log_domain_remove": "Remove '{}' domain from system configuration", + "log_domain_dns_push": "Push DNS records for domain '{}'", "log_dyndns_subscribe": "Subscribe to a YunoHost subdomain '{}'", "log_dyndns_update": "Update the IP associated with your YunoHost subdomain '{}'", "log_help_to_get_failed_log": "The operation '{desc}' could not be completed. Please share the full log of this operation using the command 'yunohost log share {name}' to get help", - "log_help_to_get_log": "To view the log of the operation '{desc}', use the command 'yunohost log show {name}{name}'", + "log_help_to_get_log": "To view the log of the operation '{desc}', use the command 'yunohost log show {name}'", "log_letsencrypt_cert_install": "Install a Let's Encrypt certificate on '{}' domain", "log_letsencrypt_cert_renew": "Renew '{}' Let's Encrypt certificate", "log_link_to_failed_log": "Could not complete the operation '{desc}'. Please provide the full log of this operation by clicking here to get help", @@ -529,7 +576,6 @@ "pattern_password": "Must be at least 3 characters long", "pattern_password_app": "Sorry, passwords can not contain the following characters: {forbidden_chars}", "pattern_port_or_range": "Must be a valid port number (i.e. 0-65535) or range of ports (e.g. 100:200)", - "pattern_positive_number": "Must be a positive number", "pattern_username": "Must be lower-case alphanumeric and underscore characters only", "permission_already_allowed": "Group '{group}' already has permission '{permission}' enabled", "permission_already_disallowed": "Group '{group}' already has permission '{permission}' disabled", @@ -614,6 +660,7 @@ "service_disabled": "The service '{service}' will not be started anymore when system boots.", "service_enable_failed": "Could not make the service '{service}' automatically start at boot.\n\nRecent service logs:{logs}", "service_enabled": "The service '{service}' will now be automatically started during system boots.", + "service_not_reloading_because_conf_broken": "Not reloading/restarting service '{name}' because its configuration is broken: {errors}", "service_regen_conf_is_deprecated": "'yunohost service regen-conf' is deprecated! Please use 'yunohost tools regen-conf' instead.", "service_reload_failed": "Could not reload the service '{service}'\n\nRecent service logs:{logs}", "service_reload_or_restart_failed": "Could not reload or restart the service '{service}'\n\nRecent service logs:{logs}", @@ -677,4 +724,4 @@ "yunohost_installing": "Installing YunoHost...", "yunohost_not_installed": "YunoHost is not correctly installed. Please run 'yunohost tools postinstall'", "yunohost_postinstall_end_tip": "The post-install completed! To finalize your setup, please consider:\n - adding a first user through the 'Users' section of the webadmin (or 'yunohost user create ' in command-line);\n - diagnose potential issues through the 'Diagnosis' section of the webadmin (or 'yunohost diagnosis run' in command-line);\n - reading the 'Finalizing your setup' and 'Getting to know YunoHost' parts in the admin documentation: https://yunohost.org/admindoc." -} \ No newline at end of file +} diff --git a/locales/eo.json b/locales/eo.json index f40111f04..8973e6344 100644 --- a/locales/eo.json +++ b/locales/eo.json @@ -28,9 +28,8 @@ "admin_password": "Pasvorto de la estro", "admin_password_too_long": "Bonvolu elekti pasvorton pli mallonga ol 127 signoj", "already_up_to_date": "Nenio por fari. Ĉio estas jam ĝisdatigita.", - "app_argument_choice_invalid": "Uzu unu el ĉi tiuj elektoj '{choices}' por la argumento '{name}'", + "app_argument_choice_invalid": "Uzu unu el ĉi tiuj elektoj '{choices}' por la argumento '{name}' anstataŭ '{value}'", "app_argument_invalid": "Elektu validan valoron por la argumento '{name}': {error}", - "app_change_url_failed_nginx_reload": "Ne eblis reŝarĝi NGINX. Jen la eligo de 'nginx -t':\n{nginx_errors}", "ask_new_admin_password": "Nova administrada pasvorto", "app_action_broke_system": "Ĉi tiu ago ŝajne rompis ĉi tiujn gravajn servojn: {services}", "app_unsupported_remote_type": "Malkontrolita fora speco uzita por la apliko", @@ -121,7 +120,7 @@ "pattern_password": "Devas esti almenaŭ 3 signoj longaj", "root_password_desynchronized": "La pasvorta administranto estis ŝanĝita, sed YunoHost ne povis propagandi ĉi tion al la radika pasvorto!", "service_remove_failed": "Ne povis forigi la servon '{service}'", - "backup_permission": "Rezerva permeso por app {app}", + "backup_permission": "Rezerva permeso por {app}", "log_user_group_delete": "Forigi grupon '{}'", "log_user_group_update": "Ĝisdatigi grupon '{}'", "dyndns_provider_unreachable": "Ne povas atingi la provizanton DynDNS {provider}: ĉu via YunoHost ne estas ĝuste konektita al la interreto aŭ la dyneta servilo malŝaltiĝas.", @@ -197,7 +196,6 @@ "certmanager_acme_not_configured_for_domain": "Atestilo por la domajno '{domain}' ne ŝajnas esti ĝuste instalita. Bonvolu ekzekuti 'cert-instali' por ĉi tiu regado unue.", "user_update_failed": "Ne povis ĝisdatigi uzanton {user}: {error}", "restore_confirm_yunohost_installed": "Ĉu vi vere volas restarigi jam instalitan sistemon? [{answers}]", - "pattern_positive_number": "Devas esti pozitiva nombro", "update_apt_cache_failed": "Ne eblis ĝisdatigi la kaŝmemoron de APT (paka administranto de Debian). Jen rubujo de la sources.list-linioj, kiuj povus helpi identigi problemajn liniojn:\n{sourceslist}", "migrations_no_migrations_to_run": "Neniuj migradoj por funkcii", "certmanager_attempt_to_renew_nonLE_cert": "La atestilo por la domajno '{domain}' ne estas elsendita de Let's Encrypt. Ne eblas renovigi ĝin aŭtomate!", @@ -252,7 +250,6 @@ "dyndns_unavailable": "La domajno '{domain}' ne haveblas.", "experimental_feature": "Averto: Ĉi tiu funkcio estas eksperimenta kaj ne konsiderata stabila, vi ne uzu ĝin krom se vi scias kion vi faras.", "root_password_replaced_by_admin_password": "Via radika pasvorto estis anstataŭigita per via administra pasvorto.", - "domain_unknown": "Nekonata domajno", "global_settings_setting_security_password_user_strength": "Uzanto pasvorta forto", "restore_may_be_not_enough_disk_space": "Via sistemo ne ŝajnas havi sufiĉe da spaco (libera: {free_space} B, necesa spaco: {needed_space} B, sekureca marĝeno: {margin} B)", "log_corrupted_md_file": "La YAD-metadata dosiero asociita kun protokoloj estas damaĝita: '{md_file}\nEraro: {error} '", @@ -326,7 +323,7 @@ "log_dyndns_subscribe": "Aboni al YunoHost-subdominio '{}'", "password_too_simple_4": "La pasvorto bezonas almenaŭ 12 signojn kaj enhavas ciferon, majuskle, pli malaltan kaj specialajn signojn", "regenconf_file_updated": "Agordodosiero '{conf}' ĝisdatigita", - "log_help_to_get_log": "Por vidi la protokolon de la operacio '{desc}', uzu la komandon 'yunohost log show {name}{name}'", + "log_help_to_get_log": "Por vidi la protokolon de la operacio '{desc}', uzu la komandon 'yunohost log show {name}'", "global_settings_setting_security_nginx_compatibility": "Kongruo vs sekureca kompromiso por la TTT-servilo NGINX. Afektas la ĉifradojn (kaj aliajn aspektojn pri sekureco)", "restore_complete": "Restarigita", "hook_exec_failed": "Ne povis funkcii skripto: {path}", @@ -491,8 +488,6 @@ "diagnosis_description_ports": "Ekspoziciaj havenoj", "diagnosis_description_mail": "Retpoŝto", "log_app_action_run": "Funkciigu agon de la apliko '{}'", - "log_app_config_show_panel": "Montri la agordan panelon de la apliko '{}'", - "log_app_config_apply": "Apliki agordon al la apliko '{}'", "diagnosis_never_ran_yet": "Ŝajnas, ke ĉi tiu servilo estis instalita antaŭ nelonge kaj estas neniu diagnoza raporto por montri. Vi devas komenci kurante plenan diagnozon, ĉu de la retadministro aŭ uzante 'yunohost diagnosis run' el la komandlinio.", "certmanager_warning_subdomain_dns_record": "Subdominio '{subdomain}' ne solvas al la sama IP-adreso kiel '{domain}'. Iuj funkcioj ne estos haveblaj ĝis vi riparos ĉi tion kaj regeneros la atestilon.", "diagnosis_basesystem_hardware": "Arkitekturo de servila aparataro estas {virt} {arch}", diff --git a/locales/es.json b/locales/es.json index 9af875898..688db4546 100644 --- a/locales/es.json +++ b/locales/es.json @@ -53,7 +53,6 @@ "domain_dyndns_root_unknown": "Dominio raíz de DynDNS desconocido", "domain_exists": "El dominio ya existe", "domain_uninstall_app_first": "Estas aplicaciones están todavía instaladas en tu dominio:\n{apps}\n\nPor favor desinstálalas utilizando yunohost app remove the_app_id o cambialas a otro dominio usando yunohost app change-url the_app_id antes de continuar con el borrado del dominio.", - "domain_unknown": "Dominio desconocido", "done": "Hecho.", "downloading": "Descargando…", "dyndns_ip_update_failed": "No se pudo actualizar la dirección IP en DynDNS", @@ -91,7 +90,6 @@ "pattern_mailbox_quota": "Debe ser un tamaño con el sufijo «b/k/M/G/T» o «0» para no tener una cuota", "pattern_password": "Debe contener al menos 3 caracteres", "pattern_port_or_range": "Debe ser un número de puerto válido (es decir entre 0-65535) o un intervalo de puertos (por ejemplo 100:200)", - "pattern_positive_number": "Deber ser un número positivo", "pattern_username": "Solo puede contener caracteres alfanuméricos o el guión bajo", "port_already_closed": "El puerto {port} ya está cerrado para las conexiones {ip_version}", "port_already_opened": "El puerto {port} ya está abierto para las conexiones {ip_version}", @@ -171,7 +169,6 @@ "certmanager_acme_not_configured_for_domain": "El reto ACME no ha podido ser realizado para {domain} porque su configuración de nginx no tiene el el código correcto... Por favor, asegurate que la configuración de nginx es correcta ejecutando en el terminal `yunohost tools regen-conf nginx --dry-run --with-diff`.", "domain_hostname_failed": "No se pudo establecer un nuevo nombre de anfitrión («hostname»). Esto podría causar problemas más tarde (no es seguro... podría ir bien).", "app_already_installed_cant_change_url": "Esta aplicación ya está instalada. La URL no se puede cambiar solo con esta función. Marque `app changeurl` si está disponible.", - "app_change_url_failed_nginx_reload": "No se pudo recargar NGINX. Esta es la salida de «nginx -t»:\n{nginx_errors}", "app_change_url_identical_domains": "El antiguo y nuevo dominio/url_path son idénticos ('{domain} {path}'), no se realizarán cambios.", "app_change_url_no_script": "La aplicación «{app_name}» aún no permite la modificación de URLs. Quizás debería actualizarla.", "app_change_url_success": "El URL de la aplicación {app} es ahora {domain} {path}", @@ -325,7 +322,7 @@ "log_does_exists": "No existe ningún registro de actividades con el nombre '{log}', ejecute 'yunohost log list' para ver todos los registros de actividades disponibles", "log_help_to_get_failed_log": "No se pudo completar la operación «{desc}». Para obtener ayuda, comparta el registro completo de esta operación ejecutando la orden «yunohost log share {name}»", "log_link_to_failed_log": "No se pudo completar la operación «{desc}». Para obtener ayuda, proporcione el registro completo de esta operación pulsando aquí", - "log_help_to_get_log": "Para ver el registro de la operación «{desc}», ejecute la orden «yunohost log show {name}{name}»", + "log_help_to_get_log": "Para ver el registro de la operación «{desc}», ejecute la orden «yunohost log show {name}»", "log_link_to_log": "Registro completo de esta operación: «{desc}»", "log_corrupted_md_file": "El archivo de metadatos YAML asociado con el registro está dañado: «{md_file}\nError: {error}»", "hook_json_return_error": "No se pudo leer la respuesta del gancho {path}. Error: {msg}. Contenido sin procesar: {raw_content}", @@ -480,8 +477,6 @@ "diagnosis_description_web": "Web", "diagnosis_basesystem_hardware": "La arquitectura material del servidor es {virt} {arch}", "log_domain_main_domain": "Hacer de '{}' el dominio principal", - "log_app_config_apply": "Aplica la configuración de la aplicación '{}'", - "log_app_config_show_panel": "Muestra el panel de configuración de la aplicación '{}'", "log_app_action_run": "Inicializa la acción de la aplicación '{}'", "group_already_exist_on_system_but_removing_it": "El grupo {group} ya existe en los grupos del sistema, pero YunoHost lo suprimirá …", "global_settings_setting_pop3_enabled": "Habilita el protocolo POP3 para el servidor de correo electrónico", diff --git a/locales/fa.json b/locales/fa.json index 3e78c5de0..f566fed90 100644 --- a/locales/fa.json +++ b/locales/fa.json @@ -1,7 +1,6 @@ { "action_invalid": "اقدام نامعتبر '{action}'", "aborting": "رها کردن.", - "app_change_url_failed_nginx_reload": "NGINX بارگیری نشد. در اینجا خروجی 'nginx -t' است:\n{nginx_errors}", "app_argument_required": "استدلال '{name}' الزامی است", "app_argument_password_no_default": "خطا هنگام تجزیه گذرواژه '{name}': به دلایل امنیتی استدلال رمز عبور نمی تواند مقدار پیش فرض داشته باشد", "app_argument_invalid": "یک مقدار معتبر انتخاب کنید برای استدلال '{name}':{error}", @@ -152,7 +151,7 @@ "apps_catalog_update_success": "کاتالوگ برنامه به روز شد!", "apps_catalog_obsolete_cache": "حافظه پنهان کاتالوگ برنامه خالی یا منسوخ شده است.", "apps_catalog_failed_to_download": "بارگیری کاتالوگ برنامه {apps_catalog} امکان پذیر نیست: {error}", - "apps_catalog_updating": "در حال به روز رسانی کاتالوگ برنامه…", + "apps_catalog_updating": "در حال به روز رسانی کاتالوگ برنامه...", "apps_catalog_init_success": "سیستم کاتالوگ برنامه راه اندازی اولیه شد!", "apps_already_up_to_date": "همه برنامه ها در حال حاضر به روز هستند", "app_packaging_format_not_supported": "این برنامه قابل نصب نیست زیرا قالب بسته بندی آن توسط نسخه YunoHost شما پشتیبانی نمی شود. احتمالاً باید ارتقاء سیستم خود را در نظر بگیرید.", @@ -352,10 +351,9 @@ "dyndns_could_not_check_provide": "بررسی نشد که آیا {provider} می تواند {domain} را ارائه دهد یا خیر.", "dpkg_lock_not_available": "این دستور در حال حاضر قابل اجرا نیست زیرا به نظر می رسد برنامه دیگری از قفل dpkg (مدیر بسته سیستم) استفاده می کند", "dpkg_is_broken": "شما نمی توانید این کار را در حال حاضر انجام دهید زیرا dpkg/APT (اداره کنندگان سیستم بسته ها) به نظر می رسد در وضعیت خرابی است… می توانید با اتصال از طریق SSH و اجرا این فرمان `sudo apt install --fix-broken` and/or `sudo dpkg --configure -a` مشکل را حل کنید.", - "downloading": "در حال بارگیری…", + "downloading": "در حال بارگیری...", "done": "انجام شد", "domains_available": "دامنه های موجود:", - "domain_unknown": "دامنه ناشناخته", "domain_name_unknown": "دامنه '{domain}' ناشناخته است", "domain_uninstall_app_first": "این برنامه ها هنوز روی دامنه شما نصب هستند:\n{apps}\n\nلطفاً قبل از اقدام به حذف دامنه ، آنها را با استفاده از 'برنامه yunohost remove the_app_id' حذف کرده یا با استفاده از 'yunohost app change-url the_app_id' به دامنه دیگری منتقل کنید", "domain_remove_confirm_apps_removal": "حذف این دامنه برنامه های زیر را حذف می کند:\n{apps}\n\nآیا طمئن هستید که میخواهید انجام دهید؟ [{answers}]", @@ -378,7 +376,6 @@ "permission_already_allowed": "گروه '{group}' قبلاً مجوز '{permission}' را فعال کرده است", "pattern_password_app": "متأسفیم ، گذرواژه ها نمی توانند شامل کاراکترهای زیر باشند: {forbidden_chars}", "pattern_username": "باید فقط حروف الفبایی کوچک و خط زیر باشد", - "pattern_positive_number": "باید یک عدد مثبت باشد", "pattern_port_or_range": "باید یک شماره پورت معتبر (یعنی 0-65535) یا محدوده پورت (به عنوان مثال 100: 200) باشد", "pattern_password": "باید حداقل 3 کاراکتر داشته باشد", "pattern_mailbox_quota": "باید اندازه ای با پسوند b / k / M / G / T یا 0 داشته باشد تا سهمیه نداشته باشد", @@ -488,8 +485,6 @@ "log_backup_restore_system": "بازیابی سیستم بوسیله آرشیو پشتیبان", "log_backup_create": "بایگانی پشتیبان ایجاد کنید", "log_available_on_yunopaste": "این گزارش اکنون از طریق {url} در دسترس است", - "log_app_config_apply": "پیکربندی را در برنامه '{}' اعمال کنید", - "log_app_config_show_panel": "پانل پیکربندی برنامه '{}' را نشان دهید", "log_app_action_run": "عملکرد برنامه '{}' را اجرا کنید", "log_app_makedefault": "\"{}\" را برنامه پیش فرض قرار دهید", "log_app_upgrade": "برنامه '{}' را ارتقاء دهید", @@ -500,7 +495,7 @@ "log_does_exists": "هیچ گزارش عملیاتی با نام '{log}' وجود ندارد ، برای مشاهده همه گزارش عملیّات های موجود در خط فرمان از دستور 'yunohost log list' استفاده کنید", "log_help_to_get_failed_log": "عملیات '{desc}' کامل نشد. لطفاً برای دریافت راهنمایی و کمک ، گزارش کامل این عملیات را با استفاده از دستور 'yunohost log share {name}' به اشتراک بگذارید", "log_link_to_failed_log": "عملیّات '{desc}' کامل نشد. لطفاً گزارش کامل این عملیات را ارائه دهید بواسطه اینجا را کلیک کنید برای دریافت کمک", - "log_help_to_get_log": "برای مشاهده گزارش عملیات '{desc}'، از دستور 'yunohost log show {name}{name}' استفاده کنید", + "log_help_to_get_log": "برای مشاهده گزارش عملیات '{desc}'، از دستور 'yunohost log show {name}' استفاده کنید", "log_link_to_log": "گزارش کامل این عملیات: {desc}'", "log_corrupted_md_file": "فایل فوق داده YAML مربوط به گزارش ها آسیب دیده است: '{md_file}\nخطا: {error} '", "ldap_server_is_down_restart_it": "سرویس LDAP خاموش است ، سعی کنید آن را دوباره راه اندازی کنید...", @@ -538,11 +533,11 @@ "unbackup_app": "{app} ذخیره نمی شود", "tools_upgrade_special_packages_completed": "ارتقاء بسته YunoHost به پایان رسید\nبرای بازگرداندن خط فرمان [Enter] را فشار دهید", "tools_upgrade_special_packages_explanation": "ارتقاء ویژه در پس زمینه ادامه خواهد یافت. لطفاً تا 10 دقیقه دیگر (بسته به سرعت سخت افزار) هیچ اقدام دیگری را روی سرور خود شروع نکنید. پس از این کار ، ممکن است مجبور شوید دوباره وارد webadmin شوید. گزارش ارتقاء در Tools → Log (در webadmin) یا با استفاده از 'yunohost log list' (در خط فرمان) در دسترس خواهد بود.", - "tools_upgrade_special_packages": "در حال ارتقاء بسته های 'special' (مربوط به yunohost)…", + "tools_upgrade_special_packages": "در حال ارتقاء بسته های 'special' (مربوط به yunohost)...", "tools_upgrade_regular_packages_failed": "بسته ها را نمی توان ارتقا داد: {packages_list}", - "tools_upgrade_regular_packages": "در حال ارتقاء بسته های 'regular' (غیر مرتبط با yunohost)…", - "tools_upgrade_cant_unhold_critical_packages": "بسته های مهم و حیاتی را نمی توان نگه نداشت…", - "tools_upgrade_cant_hold_critical_packages": "بسته های مهم و حیاتی را نمی توان نگه داشت…", + "tools_upgrade_regular_packages": "در حال ارتقاء بسته های 'regular' (غیر مرتبط با yunohost)...", + "tools_upgrade_cant_unhold_critical_packages": "بسته های مهم و حیاتی را نمی توان نگه نداشت...", + "tools_upgrade_cant_hold_critical_packages": "بسته های مهم و حیاتی را نمی توان نگه داشت...", "tools_upgrade_cant_both": "نمی توان سیستم و برنامه ها را به طور همزمان ارتقا داد", "tools_upgrade_at_least_one": "لطفاً مشخص کنید 'apps' ، یا 'system'", "this_action_broke_dpkg": "این اقدام dpkg/APT (مدیران بسته های سیستم) را خراب کرد... می توانید با اتصال از طریق SSH و اجرای فرمان `sudo apt install --fix -break` و/یا` sudo dpkg --configure -a` این مشکل را حل کنید.", @@ -597,15 +592,15 @@ "root_password_replaced_by_admin_password": "گذرواژه ریشه شما با رمز مدیریت جایگزین شده است.", "root_password_desynchronized": "گذرواژه مدیریت تغییر کرد ، اما YunoHost نتوانست این را به رمز عبور ریشه منتقل کند!", "restore_system_part_failed": "بخش سیستم '{part}' بازیابی و ترمیم نشد", - "restore_running_hooks": "در حال اجرای قلاب های ترمیم و بازیابی…", - "restore_running_app_script": "ترمیم و بازیابی برنامه '{app}'…", + "restore_running_hooks": "در حال اجرای قلاب های ترمیم و بازیابی...", + "restore_running_app_script": "ترمیم و بازیابی برنامه '{app}'...", "restore_removing_tmp_dir_failed": "پوشه موقت قدیمی حذف نشد", "restore_nothings_done": "هیچ چیز ترمیم و بازسازی نشد", "restore_not_enough_disk_space": "فضای کافی موجود نیست (فضا: {free_space} B ، فضای مورد نیاز: {needed_space} B ، حاشیه امنیتی: {margin} B)", "restore_may_be_not_enough_disk_space": "به نظر می رسد سیستم شما فضای کافی ندارد (فضای آزاد: {free_space} B ، فضای مورد نیاز: {needed_space} B ، حاشیه امنیتی: {margin} B)", "restore_hook_unavailable": "اسکریپت ترمیم و بازسازی برای '{part}' در سیستم شما در دسترس نیست و همچنین در بایگانی نیز وجود ندارد", "restore_failed": "سیستم بازیابی نشد", - "restore_extracting": "استخراج فایل های مورد نیاز از بایگانی…", + "restore_extracting": "استخراج فایل های مورد نیاز از بایگانی...", "restore_confirm_yunohost_installed": "آیا واقعاً می خواهید سیستمی که هم اکنون نصب شده را بازیابی کنید؟ [{answers}]", "restore_complete": "مرمت به پایان رسید", "restore_cleaning_failed": "فهرست بازسازی موقت پاک نشد", @@ -617,7 +612,7 @@ "regenconf_need_to_explicitly_specify_ssh": "پیکربندی ssh به صورت دستی تغییر یافته است ، اما شما باید صراحتاً دسته \"ssh\" را با --force برای اعمال تغییرات در واقع مشخص کنید.", "regenconf_pending_applying": "در حال اعمال پیکربندی معلق برای دسته '{category}'...", "regenconf_failed": "پیکربندی برای دسته (ها) بازسازی نشد: {categories}", - "regenconf_dry_pending_applying": "در حال بررسی پیکربندی معلق که برای دسته '{category}' اعمال می شد…", + "regenconf_dry_pending_applying": "در حال بررسی پیکربندی معلق که برای دسته '{category}' اعمال می شد...", "regenconf_would_be_updated": "پیکربندی برای دسته '{category}' به روز می شد", "regenconf_updated": "پیکربندی برای دسته '{category}' به روز شد", "regenconf_up_to_date": "پیکربندی در حال حاضر برای دسته '{category}' به روز است", @@ -642,4 +637,4 @@ "permission_deleted": "مجوز '{permission}' حذف شد", "permission_cant_add_to_all_users": "مجوز {permission} را نمی توان به همه کاربران اضافه کرد.", "permission_currently_allowed_for_all_users": "این مجوز در حال حاضر به همه کاربران علاوه بر آن گروه های دیگر نیز اعطا شده. احتمالاً بخواهید مجوز 'all_users' را حذف کنید یا سایر گروه هایی را که در حال حاضر مجوز به آنها اعطا شده است را هم حذف کنید." -} +} \ No newline at end of file diff --git a/locales/fr.json b/locales/fr.json index becb2e91f..6de6beed6 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -4,7 +4,7 @@ "admin_password_change_failed": "Impossible de changer le mot de passe", "admin_password_changed": "Le mot de passe d'administration a été modifié", "app_already_installed": "{app} est déjà installé", - "app_argument_choice_invalid": "Choix invalide pour le paramètre '{name}', il doit être l'un de {choices}", + "app_argument_choice_invalid": "Choix invalide pour le paramètre '{name}'. Les valeurs acceptées sont {choices}, au lieu de '{value}'", "app_argument_invalid": "Valeur invalide pour le paramètre '{name}' : {error}", "app_argument_required": "Le paramètre '{name}' est requis", "app_extraction_failed": "Impossible d'extraire les fichiers d'installation", @@ -42,7 +42,7 @@ "backup_output_directory_forbidden": "Choisissez un répertoire de destination différent. Les sauvegardes ne peuvent pas être créées dans les sous-dossiers /bin, /boot, /dev, /etc, /lib, /root, /run, /sbin, /sys, /usr, /var ou /home/yunohost.backup/archives", "backup_output_directory_not_empty": "Le répertoire de destination n'est pas vide", "backup_output_directory_required": "Vous devez spécifier un dossier de destination pour la sauvegarde", - "backup_running_hooks": "Exécution des scripts de sauvegarde...", + "backup_running_hooks": "Exécution des scripts de sauvegarde ...", "custom_app_url_required": "Vous devez spécifier une URL pour mettre à jour votre application personnalisée {app}", "disk_space_not_sufficient_install": "Il ne reste pas assez d'espace disque pour installer cette application", "disk_space_not_sufficient_update": "Il ne reste pas assez d'espace disque pour mettre à jour cette application", @@ -55,7 +55,6 @@ "domain_dyndns_root_unknown": "Domaine DynDNS principal inconnu", "domain_exists": "Le domaine existe déjà", "domain_uninstall_app_first": "Ces applications sont toujours installées sur votre domaine :\n{apps}\n\nVeuillez les désinstaller avec la commande 'yunohost app remove nom-de-l-application' ou les déplacer vers un autre domaine avec la commande 'yunohost app change-url nom-de-l-application' avant de procéder à la suppression du domaine", - "domain_unknown": "Domaine inconnu", "done": "Terminé", "downloading": "Téléchargement en cours...", "dyndns_ip_update_failed": "Impossible de mettre à jour l'adresse IP sur le domaine DynDNS", @@ -93,7 +92,6 @@ "pattern_mailbox_quota": "Doit avoir une taille suffixée avec b/k/M/G/T ou 0 pour désactiver le quota", "pattern_password": "Doit être composé d'au moins 3 caractères", "pattern_port_or_range": "Doit être un numéro de port valide compris entre 0 et 65535, ou une gamme de ports (exemple : 100:200)", - "pattern_positive_number": "Doit être un nombre positif", "pattern_username": "Doit être composé uniquement de caractères alphanumériques minuscules et de tirets bas (aussi appelé tiret du 8 ou underscore)", "port_already_closed": "Le port {port} est déjà fermé pour les connexions {ip_version}", "port_already_opened": "Le port {port} est déjà ouvert pour les connexions {ip_version}", @@ -137,15 +135,15 @@ "upnp_dev_not_found": "Aucun périphérique compatible UPnP n'a été trouvé", "upnp_disabled": "L'UPnP est désactivé", "upnp_enabled": "L'UPnP est activé", - "upnp_port_open_failed": "Impossible d’ouvrir les ports UPnP", - "user_created": "L’utilisateur a été créé", - "user_creation_failed": "Impossible de créer l’utilisateur {user} : {error}", - "user_deleted": "L’utilisateur a été supprimé", - "user_deletion_failed": "Impossible de supprimer l’utilisateur {user} : {error}", - "user_home_creation_failed": "Impossible de créer le dossier personnel '{home}' de l’utilisateur", - "user_unknown": "L’utilisateur {user} est inconnu", - "user_update_failed": "Impossible de mettre à jour l’utilisateur {user} : {error}", - "user_updated": "L’utilisateur a été modifié", + "upnp_port_open_failed": "Impossible d'ouvrir les ports UPnP", + "user_created": "L'utilisateur a été créé", + "user_creation_failed": "Impossible de créer l'utilisateur {user} : {error}", + "user_deleted": "L'utilisateur a été supprimé", + "user_deletion_failed": "Impossible de supprimer l'utilisateur {user} : {error}", + "user_home_creation_failed": "Impossible de créer le dossier personnel '{home}' de l'utilisateur", + "user_unknown": "L'utilisateur {user} est inconnu", + "user_update_failed": "Impossible de mettre à jour l'utilisateur {user} : {error}", + "user_updated": "L'utilisateur a été modifié", "yunohost_already_installed": "YunoHost est déjà installé", "yunohost_configured": "YunoHost est maintenant configuré", "yunohost_installing": "L'installation de YunoHost est en cours...", @@ -169,11 +167,10 @@ "certmanager_unable_to_parse_self_CA_name": "Impossible d'analyser le nom de l'autorité du certificat auto-signé (fichier : {file})", "mailbox_used_space_dovecot_down": "Le service Dovecot doit être démarré si vous souhaitez voir l'espace disque occupé par la messagerie", "domains_available": "Domaines disponibles :", - "backup_archive_broken_link": "Impossible d’accéder à l’archive de sauvegarde (lien invalide vers {path})", + "backup_archive_broken_link": "Impossible d'accéder à l'archive de sauvegarde (lien invalide vers {path})", "certmanager_acme_not_configured_for_domain": "Le challenge ACME n'a pas pu être validé pour le domaine {domain} pour le moment car le code de la configuration NGINX est manquant... Merci de vérifier que votre configuration NGINX est à jour avec la commande : `yunohost tools regen-conf nginx --dry-run --with-diff`.", - "domain_hostname_failed": "Échec de l’utilisation d’un nouveau nom d'hôte. Cela pourrait causer des soucis plus tard (cela n'en causera peut-être pas).", - "app_already_installed_cant_change_url": "Cette application est déjà installée. L’URL ne peut pas être changé simplement par cette fonction. Vérifiez si cela est disponible avec `app changeurl`.", - "app_change_url_failed_nginx_reload": "Le redémarrage de NGINX a échoué. Voici la sortie de 'nginx -t' :\n{nginx_errors}", + "domain_hostname_failed": "Échec de l'utilisation d'un nouveau nom d'hôte. Cela pourrait causer des soucis plus tard (cela n'en causera peut-être pas).", + "app_already_installed_cant_change_url": "Cette application est déjà installée. L'URL ne peut pas être changé simplement par cette fonction. Vérifiez si cela est disponible avec `app changeurl`.", "app_change_url_identical_domains": "L'ancien et le nouveau couple domaine/chemin_de_l'URL sont identiques pour ('{domain}{path}'), rien à faire.", "app_change_url_no_script": "L'application '{app_name}' ne prend pas encore en charge le changement d'URL. Vous devriez peut-être la mettre à jour.", "app_change_url_success": "L'URL de l'application {app} a été changée en {domain}{path}", @@ -188,9 +185,9 @@ "global_settings_unknown_type": "Situation inattendue : la configuration {setting} semble avoir le type {unknown_type} mais celui-ci n'est pas pris en charge par le système.", "global_settings_unknown_setting_from_settings_file": "Clé inconnue dans les paramètres : '{setting_key}', rejet de cette clé et sauvegarde de celle-ci dans /etc/yunohost/unkown_settings.json", "backup_abstract_method": "Cette méthode de sauvegarde reste à implémenter", - "backup_applying_method_tar": "Création de l'archive TAR de la sauvegarde...", - "backup_applying_method_copy": "Copie de tous les fichiers à sauvegarder...", - "backup_applying_method_custom": "Appel de la méthode de sauvegarde personnalisée '{method}'...", + "backup_applying_method_tar": "Création de l'archive TAR de la sauvegarde ...", + "backup_applying_method_copy": "Copie de tous les fichiers à sauvegarder ...", + "backup_applying_method_custom": "Appel de la méthode de sauvegarde personnalisée '{method}' ...", "backup_archive_system_part_not_available": "La partie '{part}' du système n'est pas disponible dans cette sauvegarde", "backup_archive_writing_error": "Impossible d'ajouter des fichiers '{source}' (nommés dans l'archive : '{dest}') à sauvegarder dans l'archive compressée '{archive}'", "backup_ask_for_copying_if_needed": "Voulez-vous effectuer la sauvegarde en utilisant {size}Mo temporairement ? (Cette méthode est utilisée car certains fichiers n'ont pas pu être préparés avec une méthode plus efficace.)", @@ -251,7 +248,7 @@ "experimental_feature": "Attention : cette fonctionnalité est expérimentale et ne doit pas être considérée comme stable, vous ne devriez pas l'utiliser à moins que vous ne sachiez ce que vous faites.", "log_corrupted_md_file": "Le fichier YAML de métadonnées associé aux logs est corrompu : '{md_file}'\nErreur : {error}", "log_link_to_log": "Journal complet de cette opération : ' {desc} '", - "log_help_to_get_log": "Pour voir le journal de cette opération '{desc}', utilisez la commande 'yunohost log show {name}{name}'", + "log_help_to_get_log": "Pour voir le journal de cette opération '{desc}', utilisez la commande 'yunohost log show {name}'", "log_link_to_failed_log": "L'opération '{desc}' a échoué ! Pour obtenir de l'aide, merci de partager le journal de l'opération en cliquant ici", "log_help_to_get_failed_log": "L'opération '{desc}' a échoué ! Pour obtenir de l'aide, merci de partager le journal de l'opération en utilisant la commande 'yunohost log share {name}'", "log_does_exists": "Il n'y a pas de journal des opérations avec le nom '{log}', utilisez 'yunohost log list' pour voir tous les journaux d'opérations disponibles", @@ -300,7 +297,7 @@ "app_upgrade_several_apps": "Les applications suivantes seront mises à jour : {apps}", "ask_new_domain": "Nouveau domaine", "ask_new_path": "Nouveau chemin", - "backup_actually_backuping": "Création d'une archive de sauvegarde à partir des fichiers collectés...", + "backup_actually_backuping": "Création d'une archive de sauvegarde à partir des fichiers collectés ...", "backup_mount_archive_for_restore": "Préparation de l'archive pour restauration...", "confirm_app_install_warning": "Avertissement : cette application peut fonctionner mais n'est pas bien intégrée dans YunoHost. Certaines fonctionnalités telles que l'authentification unique et la sauvegarde/restauration peuvent ne pas être disponibles. L'installer quand même ? [{answers}] ", "confirm_app_install_danger": "DANGER ! Cette application est connue pour être encore expérimentale (si elle ne fonctionne pas explicitement) ! Vous ne devriez probablement PAS l'installer à moins de savoir ce que vous faites. AUCUN SUPPORT ne sera fourni si cette application ne fonctionne pas ou casse votre système... Si vous êtes prêt à prendre ce risque de toute façon, tapez '{answers}'", @@ -494,8 +491,6 @@ "diagnosis_http_timeout": "Expiration du délai en essayant de contacter votre serveur de l'extérieur. Il semble être inaccessible. Vérifiez que vous transférez correctement le port 80, que Nginx est en cours d'exécution et qu'un pare-feu n'interfère pas.", "global_settings_setting_pop3_enabled": "Activer le protocole POP3 pour le serveur de messagerie", "log_app_action_run": "Lancer l'action de l'application '{}'", - "log_app_config_show_panel": "Montrer le panneau de configuration de l'application '{}'", - "log_app_config_apply": "Appliquer la configuration à l'application '{}'", "diagnosis_never_ran_yet": "Il apparaît que le serveur a été installé récemment et qu'il n'y a pas encore eu de diagnostic. Vous devriez en lancer un depuis la webadmin ou en utilisant 'yunohost diagnosis run' depuis la ligne de commande.", "diagnosis_description_web": "Web", "diagnosis_basesystem_hardware": "L'architecture du serveur est {virt} {arch}", @@ -528,7 +523,7 @@ "diagnosis_mail_ehlo_ok": "Le serveur de messagerie SMTP est accessible de l'extérieur et peut donc recevoir des emails !", "diagnosis_mail_ehlo_unreachable": "Le serveur de messagerie SMTP est inaccessible de l'extérieur en IPv{ipversion}. Il ne pourra pas recevoir des emails.", "diagnosis_mail_ehlo_unreachable_details": "Impossible d'ouvrir une connexion sur le port 25 à votre serveur en IPv{ipversion}. Il semble inaccessible.
1. La cause la plus courante de ce problème est que le port 25 n'est pas correctement redirigé vers votre serveur.
2. Vous devez également vous assurer que le service postfix est en cours d'exécution.
3. Sur les configurations plus complexes: assurez-vous qu'aucun pare-feu ou proxy inversé n'interfère.", - "diagnosis_mail_ehlo_wrong_details": "Le EHLO reçu par le serveur de diagnostique distant en IPv{ipversion} est différent du domaine de votre serveur.
EHLO reçu: {wrong_ehlo}
Attendu : {right_ehlo}
La cause la plus courante ce problème est que le port 25 n'est pas correctement redirigé vers votre serveur . Vous pouvez également vous assurer qu'aucun pare-feu ou proxy inversé n'interfère.", + "diagnosis_mail_ehlo_wrong_details": "Le EHLO reçu par le serveur de diagnostique distant en IPv{ipversion} est différent du domaine de votre serveur.
EHLO reçu : {wrong_ehlo}
Attendu : {right_ehlo}
La cause la plus courante à ce problème est que le port 25 n'est pas correctement redirigé vers votre serveur. Vous pouvez également vous assurer qu'aucun pare-feu ou reverse-proxy n'interfère.", "diagnosis_mail_fcrdns_nok_alternatives_4": "Certains fournisseurs ne vous laisseront pas configurer votre DNS inversé (ou leur fonctionnalité pourrait être cassée...). Si vous rencontrez des problèmes à cause de cela, envisagez les solutions suivantes :
- Certains FAI fournissent l'alternative de à l'aide d'un relais de serveur de messagerie bien que cela implique que le relais pourra espionner votre trafic de messagerie.
- Une alternative respectueuse de la vie privée consiste à utiliser un VPN *avec une IP publique dédiée* pour contourner ce type de limites. Voir https://yunohost.org/#/vpn_advantage
- Enfin, il est également possible de changer de fournisseur", "diagnosis_mail_fcrdns_nok_alternatives_6": "Certains fournisseurs ne vous laisseront pas configurer votre DNS inversé (ou leur fonctionnalité pourrait être cassée...). Si votre DNS inversé est correctement configuré en IPv4, vous pouvez essayer de désactiver l'utilisation d'IPv6 lors de l'envoi d'emails en exécutant yunohost settings set smtp.allow_ipv6 -v off. Remarque : cette dernière solution signifie que vous ne pourrez pas envoyer ou recevoir de emails avec les quelques serveurs qui ont uniquement de l'IPv6.", "diagnosis_mail_fcrdns_different_from_ehlo_domain_details": "DNS inverse actuel : {rdns_domain}
Valeur attendue : {ehlo_domain}", @@ -657,5 +652,28 @@ "user_import_missing_columns": "Les colonnes suivantes sont manquantes : {columns}", "user_import_bad_file": "Votre fichier CSV n'est pas correctement formaté, il sera ignoré afin d'éviter une potentielle perte de données", "user_import_bad_line": "Ligne incorrecte {line} : {details}", - "log_user_import": "Importer des utilisateurs" -} + "log_user_import": "Importer des utilisateurs", + "diagnosis_high_number_auth_failures": "Il y a eu récemment un grand nombre d'échecs d'authentification. Assurez-vous que Fail2Ban est en cours d'exécution et est correctement configuré, ou utilisez un port personnalisé pour SSH comme expliqué dans https://yunohost.org/security.", + "global_settings_setting_security_nginx_redirect_to_https": "Rediriger les requêtes HTTP vers HTTPS par défaut (NE PAS DÉSACTIVER à moins de savoir vraiment ce que vous faites !)", + "config_validate_color": "Doit être une couleur hexadécimale RVB valide", + "app_config_unable_to_apply": "Échec de l'application des valeurs du panneau de configuration.", + "app_config_unable_to_read": "Échec de la lecture des valeurs du panneau de configuration.", + "config_apply_failed": "Échec de l'application de la nouvelle configuration : {error}", + "config_cant_set_value_on_section": "Vous ne pouvez pas définir une seule valeur sur une section de configuration entière.", + "config_forbidden_keyword": "Le mot-clé '{keyword}' est réservé, vous ne pouvez pas créer ou utiliser un panneau de configuration avec une question avec cet identifiant.", + "config_no_panel": "Aucun panneau de configuration trouvé.", + "config_unknown_filter_key": "La clé de filtre '{filter_key}' est incorrecte.", + "config_validate_date": "Doit être une date valide comme dans le format AAAA-MM-JJ", + "config_validate_email": "Doit être un email valide", + "config_validate_time": "Doit être une heure valide comme HH:MM", + "config_validate_url": "Doit être une URL Web valide", + "config_version_not_supported": "Les versions du panneau de configuration '{version}' ne sont pas prises en charge.", + "danger": "Danger :", + "file_extension_not_accepted": "Le fichier '{path}' est refusé car son extension ne fait pas partie des extensions acceptées : {accept}", + "invalid_number_min": "Doit être supérieur à {min}", + "invalid_number_max": "Doit être inférieur à {max}", + "log_app_config_set": "Appliquer la configuration à l'application '{}'", + "service_not_reloading_because_conf_broken": "Le service '{name}' n'a pas été rechargé/redémarré car sa configuration est cassée : {errors}", + "app_argument_password_help_keep": "Tapez sur Entrée pour conserver la valeur actuelle", + "app_argument_password_help_optional": "Tapez un espace pour vider le mot de passe" +} \ No newline at end of file diff --git a/locales/gl.json b/locales/gl.json index 0c06bcab8..ebb65be02 100644 --- a/locales/gl.json +++ b/locales/gl.json @@ -14,11 +14,10 @@ "additional_urls_already_removed": "URL adicional '{url}' xa foi eliminada das URL adicionais para o permiso '{permission}'", "additional_urls_already_added": "URL adicional '{url}' xa fora engadida ás URL adicionais para o permiso '{permission}'", "action_invalid": "Acción non válida '{action}'", - "app_change_url_failed_nginx_reload": "Non se recargou NGINX. Aquí tes a saída de 'nginx -t':\n{nginx_errors}", "app_argument_required": "Requírese o argumento '{name}'", "app_argument_password_no_default": "Erro ao procesar o argumento do contrasinal '{name}': o argumento do contrasinal non pode ter un valor por defecto por razón de seguridade", "app_argument_invalid": "Elixe un valor válido para o argumento '{name}': {error}", - "app_argument_choice_invalid": "Usa unha destas opcións '{choices}' para o argumento '{name}'", + "app_argument_choice_invalid": "Usa unha destas opcións '{choices}' para o argumento '{name}' no lugar de '{value}'", "backup_archive_writing_error": "Non se puideron engadir os ficheiros '{source}' (chamados no arquivo '{dest}' para ser copiados dentro do arquivo comprimido '{archive}'", "backup_archive_system_part_not_available": "A parte do sistema '{part}' non está dispoñible nesta copia", "backup_archive_corrupted": "Semella que o arquivo de copia '{archive}' está estragado : {error}", @@ -45,7 +44,7 @@ "apps_catalog_update_success": "O catálogo de aplicacións foi actualizado!", "apps_catalog_obsolete_cache": "A caché do catálogo de apps está baleiro ou obsoleto.", "apps_catalog_failed_to_download": "Non se puido descargar o catálogo de apps {apps_catalog}: {error}", - "apps_catalog_updating": "Actualizando o catálogo de aplicacións…", + "apps_catalog_updating": "Actualizando o catálogo de aplicacións...", "apps_catalog_init_success": "Sistema do catálogo de apps iniciado!", "apps_already_up_to_date": "Xa tes tódalas apps ao día", "app_packaging_format_not_supported": "Esta app non se pode instalar porque o formato de empaquetado non está soportado pola túa versión de YunoHost. Deberías considerar actualizar o teu sistema.", @@ -292,10 +291,9 @@ "dyndns_could_not_check_provide": "Non se comprobou se {provider} pode proporcionar {domain}.", "dpkg_lock_not_available": "Non se pode executar agora mesmo este comando porque semella que outro programa está a utilizar dpkg (o xestos de paquetes do sistema)", "dpkg_is_broken": "Non podes facer isto agora mesmo porque dpkg/APT (o xestor de paquetes do sistema) semella que non está a funcionar... Podes intentar solucionalo conectándote a través de SSH e executando `sudo apt install --fix-broken`e/ou `sudo dpkg --configure -a`.", - "downloading": "Descargando…", + "downloading": "Descargando...", "done": "Feito", "domains_available": "Dominios dispoñibles:", - "domain_unknown": "Dominio descoñecido", "domain_name_unknown": "Dominio '{domain}' descoñecido", "domain_uninstall_app_first": "Aínda están instaladas estas aplicacións no teu dominio:\n{apps}\n\nPrimeiro desinstalaas utilizando 'yunohost app remove id_da_app' ou móveas a outro dominio con 'yunohost app change-url id_da_app' antes de eliminar o dominio", "domain_remove_confirm_apps_removal": "Ao eliminar o dominio tamén vas eliminar estas aplicacións:\n{apps}\n\nTes a certeza de querer facelo? [{answers}]", @@ -358,7 +356,7 @@ "global_settings_setting_security_webadmin_allowlist_enabled": "Permitir que só algúns IPs accedan á webadmin.", "disk_space_not_sufficient_update": "Non hai espazo suficiente no disco para actualizar esta aplicación", "disk_space_not_sufficient_install": "Non queda espazo suficiente no disco para instalar esta aplicación", - "log_help_to_get_log": "Para ver o rexistro completo da operación '{desc}', usa o comando 'yunohost log show {name}{name}'", + "log_help_to_get_log": "Para ver o rexistro completo da operación '{desc}', usa o comando 'yunohost log show {name}'", "log_link_to_log": "Rexistro completo desta operación: '{desc}'", "log_corrupted_md_file": "O ficheiro YAML con metadatos asociado aos rexistros está danado: '{md_file}\nErro: {error}'", "iptables_unavailable": "Non podes andar remexendo en iptables aquí. Ou ben estás nun contedor ou o teu kernel non ten soporte para isto", @@ -386,8 +384,6 @@ "log_backup_restore_system": "Restablecer o sistema desde unha copia de apoio", "log_backup_create": "Crear copia de apoio", "log_available_on_yunopaste": "Este rexistro está dispoñible en {url}", - "log_app_config_apply": "Aplicar a configuración da app '{}'", - "log_app_config_show_panel": "Mostrar o panel de configuración da app '{}'", "log_app_action_run": "Executar acción da app '{}'", "log_app_makedefault": "Converter '{}' na app por defecto", "log_app_upgrade": "Actualizar a app '{}'", @@ -458,7 +454,7 @@ "migration_0015_specific_upgrade": "Iniciando a actualización dos paquetes do sistema que precisan ser actualizados de xeito independente...", "migration_0015_modified_files": "Ten en conta que os seguintes ficheiros semella que foron modificados manualmente e poderían ser sobrescritos na actualización: {manually_modified_files}", "migration_0015_problematic_apps_warning": "Ten en conta que se detectaron as seguintes apps que poderían ser problemáticas. Semella que non foron instaladas usando o catálogo de YunoHost, ou non están marcadas como 'funcionais'. En consecuencia, non se pode garantir que seguirán funcionando após a actualización: {problematic_apps}", - "diagnosis_http_localdomain": "O dominio {domain}, cun TLD .local, non é de agardar que sexa accesible desde o exterior da rede local.", + "diagnosis_http_localdomain": "O dominio {domain}, cun TLD .local, non é de agardar que esté exposto ao exterior da rede local.", "diagnosis_dns_specialusedomain": "O dominio {domain} baséase un dominio de nivel alto e uso especial (TLD) polo que non é de agardar que realmente teña rexistros DNS.", "upnp_enabled": "UPnP activado", "upnp_disabled": "UPnP desactivado", @@ -471,7 +467,6 @@ "permission_already_allowed": "O grupo '{group}' xa ten o permiso '{permission}' activado", "pattern_password_app": "Lamentámolo, os contrasinais non poden conter os seguintes caracteres: {forbidden_chars}", "pattern_username": "Só admite caracteres alfanuméricos en minúscula e trazo baixo", - "pattern_positive_number": "Ten que ser un número positivo", "pattern_port_or_range": "Debe ser un número válido de porto (entre 0-65535) ou rango de portos (ex. 100:200)", "pattern_password": "Ten que ter polo menos 3 caracteres", "pattern_mailbox_quota": "Ten que ser un tamaño co sufixo b/k/M/G/T ou 0 para non ter unha cota", @@ -524,7 +519,7 @@ "permission_cant_add_to_all_users": "O permiso {permission} non pode ser concecido a tódalas usuarias.", "permission_currently_allowed_for_all_users": "Este permiso está concedido actualmente a tódalas usuarias ademáis de a outros grupos. Probablemente queiras ben eliminar o permiso 'all_users' ou ben eliminar os outros grupos que teñen permiso.", "restore_failed": "Non se puido restablecer o sistema", - "restore_extracting": "Extraendo os ficheiros necesarios desde o arquivo…", + "restore_extracting": "Extraendo os ficheiros necesarios desde o arquivo...", "restore_confirm_yunohost_installed": "Tes a certeza de querer restablecer un sistema xa instalado? [{answers}]", "restore_complete": "Restablecemento completado", "restore_cleaning_failed": "Non se puido despexar o directorio temporal de restablecemento", @@ -536,7 +531,7 @@ "regenconf_need_to_explicitly_specify_ssh": "A configuración ssh foi modificada manualmente, pero tes que indicar explícitamente a categoría 'ssh' con --force para realmente aplicar os cambios.", "regenconf_pending_applying": "Aplicando a configuración pendente para categoría '{category}'...", "regenconf_failed": "Non se rexenerou a configuración para a categoría(s): {categories}", - "regenconf_dry_pending_applying": "Comprobando as configuracións pendentes que deberían aplicarse á categoría '{category}'…", + "regenconf_dry_pending_applying": "Comprobando as configuracións pendentes que deberían aplicarse á categoría '{category}'...", "regenconf_would_be_updated": "A configuración debería ser actualizada para a categoría '{category}'", "regenconf_updated": "Configuración actualizada para '{category}'", "regenconf_up_to_date": "A configuración xa está ao día para a categoría '{category}'", @@ -574,8 +569,8 @@ "root_password_replaced_by_admin_password": "O contrasinal root foi substituído polo teu contrasinal de administración.", "root_password_desynchronized": "Mudou o contrasinal de administración, pero YunoHost non puido transferir este cambio ao contrasinal root!", "restore_system_part_failed": "Non se restableceu a parte do sistema '{part}'", - "restore_running_hooks": "Executando os ganchos do restablecemento…", - "restore_running_app_script": "Restablecendo a app '{app}'…", + "restore_running_hooks": "Executando os ganchos do restablecemento...", + "restore_running_app_script": "Restablecendo a app '{app}'...", "restore_removing_tmp_dir_failed": "Non se puido eliminar o directorio temporal antigo", "restore_nothings_done": "Nada foi restablecido", "restore_not_enough_disk_space": "Non hai espazo abondo (espazo: {free_space.d} B, espazo necesario: {needed_space} B, marxe de seguridade: {margin} B)", @@ -593,7 +588,7 @@ "user_updated": "Cambiada a info da usuaria", "user_update_failed": "Non se actualizou usuaria {user}: {error}", "user_unknown": "Usuaria descoñecida: {user}", - "user_home_creation_failed": "Non se puido crear cartafol 'home' para a usuaria", + "user_home_creation_failed": "Non se puido crear cartafol home '{home}' para a usuaria", "user_deletion_failed": "Non se puido eliminar a usuaria {user}: {error}", "user_deleted": "Usuaria eliminada", "user_creation_failed": "Non se puido crear a usuaria {user}: {error}", @@ -613,11 +608,11 @@ "unbackup_app": "{app} non vai ser gardada", "tools_upgrade_special_packages_completed": "Completada a actualización dos paquetes YunoHost.\nPreme [Enter] para recuperar a liña de comandos", "tools_upgrade_special_packages_explanation": "A actualización especial continuará en segundo plano. Non inicies outras tarefas no servidor nos seguintes ~10 minutos (depende do hardware). Após isto, podes volver a conectar na webadmin. O rexistro da actualización estará dispoñible en Ferramentas → Rexistro (na webadmin) ou con 'yunohost log list' (na liña de comandos).", - "tools_upgrade_special_packages": "Actualizando paquetes 'special' (yunohost-related)…", + "tools_upgrade_special_packages": "Actualizando paquetes 'special' (yunohost-related)...", "tools_upgrade_regular_packages_failed": "Non se actualizaron os paquetes: {packages_list}", - "tools_upgrade_regular_packages": "Actualizando os paquetes 'regular' (non-yunohost-related)…", - "tools_upgrade_cant_unhold_critical_packages": "Non se desbloquearon os paquetes críticos…", - "tools_upgrade_cant_hold_critical_packages": "Non se puideron bloquear os paquetes críticos…", + "tools_upgrade_regular_packages": "Actualizando os paquetes 'regular' (non-yunohost-related)...", + "tools_upgrade_cant_unhold_critical_packages": "Non se desbloquearon os paquetes críticos...", + "tools_upgrade_cant_hold_critical_packages": "Non se puideron bloquear os paquetes críticos...", "tools_upgrade_cant_both": "Non se pode actualizar o sistema e as apps ao mesmo tempo", "tools_upgrade_at_least_one": "Por favor indica 'apps', ou 'system'", "this_action_broke_dpkg": "Esta acción rachou dpkg/APT (xestores de paquetes do sistema)... Podes intentar resolver o problema conectando a través de SSH e executando `sudo apt install --fix-broken`e/ou `sudo dpkg --configure -a`.", @@ -641,5 +636,44 @@ "service_removed": "Eliminado o servizo '{service}'", "service_remove_failed": "Non se eliminou o servizo '{service}'", "service_regen_conf_is_deprecated": "'yunohost service regen-conf' xa non se utiliza! Executa 'yunohost tools regen-conf' no seu lugar.", - "service_enabled": "O servizo '{service}' vai ser iniciado automáticamente no inicio do sistema." -} + "service_enabled": "O servizo '{service}' vai ser iniciado automáticamente no inicio do sistema.", + "diagnosis_apps_allgood": "Tódalas apps instaladas respectan as prácticas básicas de empaquetado", + "diagnosis_apps_bad_quality": "Esta aplicación está actualmente marcada como estragada no catálogo de aplicacións de YunoHost. Podería ser un problema temporal mentras as mantedoras intentan arranxar o problema. Ata ese momento a actualización desta app está desactivada.", + "global_settings_setting_security_nginx_redirect_to_https": "Redirixir peticións HTTP a HTTPs por defecto (NON DESACTIVAR ISTO a non ser que realmente saibas o que fas!)", + "log_user_import": "Importar usuarias", + "user_import_failed": "A operación de importación de usuarias fracasou", + "user_import_missing_columns": "Faltan as seguintes columnas: {columns}", + "user_import_nothing_to_do": "Ningunha usuaria precisa ser importada", + "user_import_partial_failed": "A operación de importación de usuarias fallou parcialmente", + "diagnosis_apps_deprecated_practices": "A versión instalada desta app aínda utiliza algunha das antigas prácticas de empaquetado xa abandonadas. Deberías considerar actualizala.", + "diagnosis_apps_outdated_ynh_requirement": "A versión instalada desta app só require yunohost >= 2.x, que normalmente indica que non está ao día coas prácticas recomendadas de empaquetado e asistentes. Deberías considerar actualizala.", + "user_import_success": "Usuarias importadas correctamente", + "diagnosis_high_number_auth_failures": "Hai un alto número sospeitoso de intentos fallidos de autenticación. Deberías comprobar que fail2ban está a executarse e que está correctamente configurado, ou utiliza un porto personalizado para SSH tal como se explica en https://yunohost.org/security.", + "user_import_bad_file": "O ficheiro CSV non ten o formato correcto e será ignorado para evitar unha potencial perda de datos", + "user_import_bad_line": "Liña incorrecta {line}: {details}", + "diagnosis_description_apps": "Aplicacións", + "diagnosis_apps_broken": "Actualmente esta aplicación está marcada como estragada no catálogo de aplicacións de YunoHost. Podería tratarse dun problema temporal mentras as mantedoras intentan arraxala. Entanto así a actualización da app está desactivada.", + "diagnosis_apps_issue": "Atopouse un problema na app {app}", + "diagnosis_apps_not_in_app_catalog": "Esta aplicación non está no catálgo de aplicacións de YunoHost. Se estivo no pasado e foi eliminada, deberías considerar desinstalala porque non recibirá actualizacións, e podería comprometer a integridade e seguridade do teu sistema.", + "app_argument_password_help_optional": "Escribe un espazo para limpar o contrasinal", + "config_validate_date": "Debe ser unha data válida co formato YYYY-MM-DD", + "config_validate_email": "Debe ser un email válido", + "config_validate_time": "Debe ser unha hora válida tal que HH:MM", + "config_validate_url": "Debe ser un URL válido", + "danger": "Perigo:", + "app_argument_password_help_keep": "Preme Enter para manter o valor actual", + "app_config_unable_to_read": "Fallou a lectura dos valores de configuración.", + "config_apply_failed": "Fallou a aplicación da nova configuración: {error}", + "config_forbidden_keyword": "O palabra chave '{keyword}' está reservada, non podes crear ou usar un panel de configuración cunha pregunta con este id.", + "config_no_panel": "Non se atopa panel configurado.", + "config_unknown_filter_key": "A chave do filtro '{filter_key}' non é correcta.", + "config_validate_color": "Debe ser un valor RGB hexadecimal válido", + "invalid_number_min": "Ten que ser maior que {min}", + "log_app_config_set": "Aplicar a configuración á app '{}'", + "app_config_unable_to_apply": "Fallou a aplicación dos valores de configuración.", + "config_cant_set_value_on_section": "Non podes establecer un valor único na sección completa de configuración.", + "config_version_not_supported": "A versión do panel de configuración '{version}' non está soportada.", + "file_extension_not_accepted": "Rexeitouse o ficheiro '{path}' porque a súa extensión non está entre as aceptadas: {accept}", + "invalid_number_max": "Ten que ser menor de {max}", + "service_not_reloading_because_conf_broken": "Non se recargou/reiniciou o servizo '{name}' porque a súa configuración está estragada: {errors}" +} \ No newline at end of file diff --git a/locales/id.json b/locales/id.json new file mode 100644 index 000000000..9e26dfeeb --- /dev/null +++ b/locales/id.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/locales/it.json b/locales/it.json index 7e0b9b420..382283e70 100644 --- a/locales/it.json +++ b/locales/it.json @@ -42,7 +42,7 @@ "ask_new_admin_password": "Nuova password dell'amministrazione", "backup_app_failed": "Non è possibile fare il backup {app}", "backup_archive_app_not_found": "{app} non è stata trovata nel archivio di backup", - "app_argument_choice_invalid": "Usa una delle seguenti scelte '{choices}' per il parametro '{name}'", + "app_argument_choice_invalid": "Usa una delle seguenti scelte '{choices}' per il parametro '{name}' invece di '{value}'", "app_argument_invalid": "Scegli un valore valido per il parametro '{name}': {error}", "app_argument_required": "L'argomento '{name}' è requisito", "app_id_invalid": "Identificativo dell'applicazione non valido", @@ -67,7 +67,6 @@ "domain_dyndns_root_unknown": "Dominio radice DynDNS sconosciuto", "domain_hostname_failed": "Impossibile impostare il nuovo hostname. Potrebbe causare problemi in futuro (o anche no).", "domain_uninstall_app_first": "Queste applicazioni sono già installate su questo dominio:\n{apps}\n\nDisinstallale eseguendo 'yunohost app remove app_id' o spostale in un altro dominio eseguendo 'yunohost app change-url app_id' prima di procedere alla cancellazione del dominio", - "domain_unknown": "Dominio sconosciuto", "done": "Terminato", "domains_available": "Domini disponibili:", "downloading": "Scaricamento…", @@ -104,7 +103,6 @@ "pattern_lastname": "Deve essere un cognome valido", "pattern_password": "Deve contenere almeno 3 caratteri", "pattern_port_or_range": "Deve essere un numero di porta valido (es. 0-65535) o una fascia di porte valida (es. 100:200)", - "pattern_positive_number": "Deve essere un numero positivo", "pattern_username": "Caratteri minuscoli alfanumerici o trattini bassi soli", "port_already_closed": "La porta {port} è già chiusa per le connessioni {ip_version}", "restore_already_installed_app": "Un'applicazione con l'ID '{app}' è già installata", @@ -116,8 +114,8 @@ "user_update_failed": "Impossibile aggiornare l'utente {user}: {error}", "restore_hook_unavailable": "Lo script di ripristino per '{part}' non è disponibile per il tuo sistema e non è nemmeno nell'archivio", "restore_nothings_done": "Nulla è stato ripristinato", - "restore_running_app_script": "Ripristino dell'app '{app}'…", - "restore_running_hooks": "Esecuzione degli hook di ripristino…", + "restore_running_app_script": "Ripristino dell'app '{app}'...", + "restore_running_hooks": "Esecuzione degli hook di ripristino...", "service_added": "Il servizio '{service}' è stato aggiunto", "service_already_started": "Il servizio '{service}' è già avviato", "service_already_stopped": "Il servizio '{service}' è già stato fermato", @@ -143,7 +141,7 @@ "user_created": "Utente creato", "user_creation_failed": "Impossibile creare l'utente {user}: {error}", "user_deletion_failed": "Impossibile cancellare l'utente {user}: {error}", - "user_home_creation_failed": "Impossibile creare la 'home' directory del utente", + "user_home_creation_failed": "Impossibile creare la home directory '{home}' del utente", "user_unknown": "Utente sconosciuto: {user}", "user_updated": "Info dell'utente cambiate", "yunohost_already_installed": "YunoHost è già installato", @@ -159,7 +157,6 @@ "certmanager_domain_http_not_working": "Il dominio {domain} non sembra accessibile attraverso HTTP. Verifica nella sezione 'Web' nella diagnosi per maggiori informazioni. (Se sai cosa stai facendo, usa '--no-checks' per disattivare i controlli.)", "app_already_installed_cant_change_url": "Questa applicazione è già installata. L'URL non può essere cambiato solo da questa funzione. Controlla se `app changeurl` è disponibile.", "app_already_up_to_date": "{app} è già aggiornata", - "app_change_url_failed_nginx_reload": "Non riesco a riavviare NGINX. Questo è il risultato di 'nginx -t':\n{nginx_errors}", "app_change_url_identical_domains": "Il vecchio ed il nuovo dominio/percorso_url sono identici ('{domain}{path}'), nessuna operazione necessaria.", "app_change_url_no_script": "L'applicazione '{app_name}' non supporta ancora la modifica dell'URL. Forse dovresti aggiornarla.", "app_change_url_success": "L'URL dell'applicazione {app} è stato cambiato in {domain}{path}", @@ -249,7 +246,7 @@ "good_practices_about_admin_password": "Stai per impostare una nuova password di amministratore. La password deve essere almeno di 8 caratteri - anche se è buona pratica utilizzare password più lunghe (es. una frase, una serie di parole) e/o utilizzare vari tipi di caratteri (maiuscole, minuscole, numeri e simboli).", "log_corrupted_md_file": "Il file dei metadati YAML associato con i registri è danneggiato: '{md_file}'\nErrore: {error}", "log_link_to_log": "Registro completo di questa operazione: '{desc}'", - "log_help_to_get_log": "Per vedere il registro dell'operazione '{desc}', usa il comando 'yunohost log show {name}{name}'", + "log_help_to_get_log": "Per vedere il registro dell'operazione '{desc}', usa il comando 'yunohost log show {name}'", "global_settings_setting_security_postfix_compatibility": "Bilanciamento tra compatibilità e sicurezza per il server Postfix. Riguarda gli algoritmi di cifratura (e altri aspetti legati alla sicurezza)", "log_link_to_failed_log": "Impossibile completare l'operazione '{desc}'! Per ricevere aiuto, per favore fornisci il registro completo dell'operazione cliccando qui", "log_help_to_get_failed_log": "L'operazione '{desc}' non può essere completata. Per ottenere aiuto, per favore condividi il registro completo dell'operazione utilizzando il comando 'yunohost log share {name}'", @@ -398,11 +395,11 @@ "unknown_main_domain_path": "Percorso o dominio sconosciuto per '{app}'. Devi specificare un dominio e un percorso per poter specificare un URL per il permesso.", "tools_upgrade_special_packages_completed": "Aggiornamento pacchetti YunoHost completato.\nPremi [Invio] per tornare al terminale", "tools_upgrade_special_packages_explanation": "L'aggiornamento speciale continuerà in background. Per favore non iniziare nessun'altra azione sul tuo server per i prossimi ~10 minuti (dipende dalla velocità hardware). Dopo questo, dovrai ri-loggarti nel webadmin. Il registro di aggiornamento sarà disponibile in Strumenti → Log/Registri (nel webadmin) o dalla linea di comando eseguendo 'yunohost log list'.", - "tools_upgrade_special_packages": "Adesso aggiorno i pacchetti 'speciali' (correlati a yunohost)…", + "tools_upgrade_special_packages": "Adesso aggiorno i pacchetti 'speciali' (correlati a yunohost)...", "tools_upgrade_regular_packages_failed": "Impossibile aggiornare i pacchetti: {packages_list}", - "tools_upgrade_regular_packages": "Adesso aggiorno i pacchetti 'normali' (non correlati a yunohost)…", - "tools_upgrade_cant_unhold_critical_packages": "Impossibile annullare il blocco dei pacchetti critici/importanti…", - "tools_upgrade_cant_hold_critical_packages": "Impossibile bloccare i pacchetti critici/importanti…", + "tools_upgrade_regular_packages": "Adesso aggiorno i pacchetti 'normali' (non correlati a yunohost)...", + "tools_upgrade_cant_unhold_critical_packages": "Impossibile annullare il blocco dei pacchetti critici/importanti...", + "tools_upgrade_cant_hold_critical_packages": "Impossibile bloccare i pacchetti critici/importanti...", "tools_upgrade_cant_both": "Impossibile aggiornare sia il sistema e le app nello stesso momento", "tools_upgrade_at_least_one": "Specifica 'apps', o 'system'", "show_tile_cant_be_enabled_for_regex": "Non puoi abilitare 'show_tile' in questo momento, perché l'URL del permesso '{permission}' è una regex", @@ -438,14 +435,14 @@ "restore_removing_tmp_dir_failed": "Impossibile rimuovere una vecchia directory temporanea", "restore_not_enough_disk_space": "Spazio libero insufficiente (spazio: {free_space}B, necessario: {needed_space}B, margine di sicurezza: {margin}B)", "restore_may_be_not_enough_disk_space": "Il tuo sistema non sembra avere abbastanza spazio (libero: {free_space}B, necessario: {needed_space}B, margine di sicurezza: {margin}B)", - "restore_extracting": "Sto estraendo i file necessari dall'archivio…", + "restore_extracting": "Sto estraendo i file necessari dall'archivio...", "restore_already_installed_apps": "Le seguenti app non possono essere ripristinate perché sono già installate: {apps}", "regex_with_only_domain": "Non puoi usare una regex per il dominio, solo per i percorsi", "regex_incompatible_with_tile": "/!\\ Packagers! Il permesso '{permission}' ha show_tile impostato su 'true' e perciò non è possibile definire un URL regex per l'URL principale", "regenconf_need_to_explicitly_specify_ssh": "La configurazione ssh è stata modificata manualmente, ma devi specificare la categoria 'ssh' con --force per applicare le modifiche.", "regenconf_pending_applying": "Applico le configurazioni in attesa per la categoria '{category}'...", "regenconf_failed": "Impossibile rigenerare la configurazione per le categorie: {categories}", - "regenconf_dry_pending_applying": "Controllo configurazioni in attesa che potrebbero essere applicate alla categoria '{category}'…", + "regenconf_dry_pending_applying": "Controllo configurazioni in attesa che potrebbero essere applicate alla categoria '{category}'...", "regenconf_would_be_updated": "La configurazione sarebbe stata aggiornata per la categoria '{category}'", "regenconf_updated": "Configurazione aggiornata per '{category}'", "regenconf_up_to_date": "Il file di configurazione è già aggiornato per la categoria '{category}'", @@ -531,8 +528,6 @@ "log_permission_url": "Aggiorna l'URL collegato al permesso '{}'", "log_permission_delete": "Cancella permesso '{}'", "log_permission_create": "Crea permesso '{}'", - "log_app_config_apply": "Applica la configurazione all'app '{}'", - "log_app_config_show_panel": "Mostra il pannello di configurazione dell'app '{}'", "log_app_action_run": "Esegui l'azione dell'app '{}'", "log_operation_unit_unclosed_properly": "Operazion unit non è stata chiusa correttamente", "invalid_regex": "Regex invalida:'{regex}'", diff --git a/locales/mk.json b/locales/mk.json new file mode 100644 index 000000000..9e26dfeeb --- /dev/null +++ b/locales/mk.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/locales/nb_NO.json b/locales/nb_NO.json index 037e09cb6..dc217d74e 100644 --- a/locales/nb_NO.json +++ b/locales/nb_NO.json @@ -27,7 +27,6 @@ "app_action_cannot_be_ran_because_required_services_down": "Dette programmet krever noen tjenester som ikke kjører. Før du fortsetter, du bør prøve å starte følgende tjenester på ny (og antagelig undersøke hvorfor de er nede): {services}", "app_already_installed_cant_change_url": "Dette programmet er allerede installert. Nettadressen kan ikke endres kun med denne funksjonen. Ta en titt på `app changeurl` hvis den er tilgjengelig.", "domain_exists": "Domenet finnes allerede", - "app_change_url_failed_nginx_reload": "Kunne ikke gjeninnlaste NGINX. Her har du utdataen for 'nginx -t'\n{nginx_errors}", "domains_available": "Tilgjengelige domener:", "done": "Ferdig", "downloading": "Laster ned…", @@ -83,7 +82,6 @@ "domain_created": "Domene opprettet", "domain_creation_failed": "Kunne ikke opprette domene", "domain_dyndns_root_unknown": "Ukjent DynDNS-rotdomene", - "domain_unknown": "Ukjent domene", "dyndns_ip_update_failed": "Kunne ikke oppdatere IP-adresse til DynDNS", "dyndns_ip_updated": "Oppdaterte din IP på DynDNS", "dyndns_key_generating": "Oppretter DNS-nøkkel… Dette kan ta en stund.", @@ -115,7 +113,7 @@ "domain_deletion_failed": "Kunne ikke slette domene", "domain_dyndns_already_subscribed": "Du har allerede abonnement på et DynDNS-domene", "log_link_to_log": "Full logg for denne operasjonen: '{desc}'", - "log_help_to_get_log": "For å vise loggen for operasjonen '{desc}', bruk kommandoen 'yunohost log show {name}{name}'", + "log_help_to_get_log": "For å vise loggen for operasjonen '{desc}', bruk kommandoen 'yunohost log show {name}'", "log_user_create": "Legg til '{}' bruker", "app_change_url_success": "{app} nettadressen er nå {domain}{path}", "app_install_failed": "Kunne ikke installere {app}: {error}" diff --git a/locales/nl.json b/locales/nl.json index 1995cbf62..5e612fc77 100644 --- a/locales/nl.json +++ b/locales/nl.json @@ -32,7 +32,6 @@ "domain_dyndns_root_unknown": "Onbekend DynDNS root domein", "domain_exists": "Domein bestaat al", "domain_uninstall_app_first": "Een of meerdere apps zijn geïnstalleerd op dit domein, verwijder deze voordat u het domein verwijdert", - "domain_unknown": "Onbekend domein", "done": "Voltooid", "downloading": "Downloaden...", "dyndns_ip_update_failed": "Kan het IP adres niet updaten bij DynDNS", @@ -102,7 +101,6 @@ "app_manifest_install_ask_domain": "Kies het domein waar deze app op geïnstalleerd moet worden", "app_manifest_install_ask_path": "Kies het pad waar deze app geïnstalleerd moet worden", "app_manifest_install_ask_admin": "Kies een administrator voor deze app", - "app_change_url_failed_nginx_reload": "Kon NGINX niet opnieuw laden. Hier is de output van 'nginx -t':\n{nginx_errors}", "app_change_url_success": "{app} URL is nu {domain}{path}", "app_full_domain_unavailable": "Sorry, deze app moet op haar eigen domein geïnstalleerd worden, maar andere apps zijn al geïnstalleerd op het domein '{domain}'. U kunt wel een subdomein aan deze app toewijden.", "app_install_script_failed": "Er is een fout opgetreden in het installatiescript van de app", diff --git a/locales/oc.json b/locales/oc.json index 995c61b16..a2a5bfe31 100644 --- a/locales/oc.json +++ b/locales/oc.json @@ -30,7 +30,6 @@ "app_argument_choice_invalid": "Utilizatz una de las opcions « {choices} » per l’argument « {name} »", "app_argument_invalid": "Causissètz una valor invalida pel paramètre « {name} » : {error}", "app_argument_required": "Lo paramètre « {name} » es requesit", - "app_change_url_failed_nginx_reload": "Reaviada de NGINX impossibla. Vaquí la sortida de « nginx -t » :\n{nginx_errors}", "app_change_url_identical_domains": "L’ancian e lo novèl coble domeni/camin son identics per {domain}{path}, pas res a far.", "app_change_url_success": "L’URL de l’aplicacion {app} es ara {domain}{path}", "app_extraction_failed": "Extraccion dels fichièrs d’installacion impossibla", @@ -108,7 +107,6 @@ "domain_dyndns_root_unknown": "Domeni DynDNS màger desconegut", "domain_exists": "Lo domeni existís ja", "domain_hostname_failed": "Fracàs de la creacion d’un nòu nom d’òst. Aquò poirà provocar de problèmas mai tard (mas es pas segur… benlèu que coparà pas res).", - "domain_unknown": "Domeni desconegut", "domains_available": "Domenis disponibles :", "done": "Acabat", "downloading": "Telecargament…", @@ -141,7 +139,6 @@ "pattern_lastname": "Deu èsser un nom valid", "pattern_password": "Deu conténer almens 3 caractèrs", "pattern_port_or_range": "Deu èsser un numèro de pòrt valid (ex : 0-65535) o un interval de pòrt (ex : 100:200)", - "pattern_positive_number": "Deu èsser un nombre positiu", "port_already_closed": "Lo pòrt {port} es ja tampat per las connexions {ip_version}", "port_already_opened": "Lo pòrt {port} es ja dubèrt per las connexions {ip_version}", "restore_already_installed_app": "Una aplicacion es ja installada amb l’id « {app} »", @@ -248,7 +245,7 @@ "experimental_feature": "Atencion : aquesta foncionalitat es experimentala e deu pas èsser considerada coma establa, deuriatz pas l’utilizar levat que sapiatz çò que fasètz.", "log_corrupted_md_file": "Lo fichièr YAML de metadonadas ligat als jornals d’audit es damatjat : « {md_file} »\nError : {error}", "log_link_to_log": "Jornal complèt d’aquesta operacion : {desc}", - "log_help_to_get_log": "Per veire lo jornal d’aquesta operacion « {desc} », utilizatz la comanda « yunohost log show {name}{name} »", + "log_help_to_get_log": "Per veire lo jornal d’aquesta operacion « {desc} », utilizatz la comanda « yunohost log show {name} »", "log_link_to_failed_log": "L’operacion « {desc} » a pas capitat ! Per obténer d’ajuda, mercés de fornir lo jornal complèt de l’operacion", "log_help_to_get_failed_log": "L’operacion « {desc} » a pas reüssit ! Per obténer d’ajuda, mercés de partejar lo jornal d’audit complèt d’aquesta operacion en utilizant la comanda « yunohost log share {name} »", "log_does_exists": "I a pas cap de jornal d’audit per l’operacion amb lo nom « {log} », utilizatz « yunohost log list » per veire totes los jornals d’operacion disponibles", @@ -448,7 +445,7 @@ "diagnosis_ports_could_not_diagnose_details": "Error : {error}", "diagnosis_http_could_not_diagnose": "Impossible de diagnosticar se lo domeni es accessible de l’exterior.", "diagnosis_http_could_not_diagnose_details": "Error : {error}", - "apps_catalog_updating": "Actualizacion del catalòg d’aplicacion…", + "apps_catalog_updating": "Actualizacion del catalòg d’aplicacion...", "apps_catalog_failed_to_download": "Telecargament impossible del catalòg d’aplicacions {apps_catalog} : {error}", "apps_catalog_obsolete_cache": "La memòria cache del catalòg d’aplicacion es voida o obsolèta.", "apps_catalog_update_success": "Lo catalòg d’aplicacions es a jorn !", @@ -504,8 +501,6 @@ "migration_description_0016_php70_to_php73_pools": "Migrar los fichièrs de configuracion php7.0 cap a php7.3", "migration_description_0015_migrate_to_buster": "Mesa a nivèl dels sistèmas Debian Buster e YunoHost 4.x", "migrating_legacy_permission_settings": "Migracion dels paramètres de permission ancians...", - "log_app_config_apply": "Aplicar la configuracion a l’aplicacion « {} »", - "log_app_config_show_panel": "Mostrar lo panèl de configuracion de l’aplicacion « {} »", "log_app_action_run": "Executar l’accion de l’aplicacion « {} »", "diagnosis_basesystem_hardware_model": "Lo modèl del servidor es {model}", "backup_archive_cant_retrieve_info_json": "Obtencion impossibla de las informacions de l’archiu « {archive} »... Se pòt pas recuperar lo fichièr info.json (o es pas un fichièr json valid).", diff --git a/locales/pt.json b/locales/pt.json index 4b4248f09..534e0cb27 100644 --- a/locales/pt.json +++ b/locales/pt.json @@ -20,26 +20,25 @@ "ask_new_admin_password": "Nova senha de administração", "ask_password": "Senha", "backup_created": "Backup completo", - "backup_output_directory_not_empty": "A pasta de destino não se encontra vazia", + "backup_output_directory_not_empty": "Você deve escolher um diretório de saída que esteja vazio", "custom_app_url_required": "Deve fornecer um link para atualizar a sua aplicação personalizada {app}", "domain_cert_gen_failed": "Não foi possível gerar o certificado", "domain_created": "Domínio criado com êxito", - "domain_creation_failed": "Não foi possível criar o domínio", + "domain_creation_failed": "Não foi possível criar o domínio {domain}: {error}", "domain_deleted": "Domínio removido com êxito", - "domain_deletion_failed": "Não foi possível eliminar o domínio", + "domain_deletion_failed": "Não foi possível eliminar o domínio {domain}: {error}", "domain_dyndns_already_subscribed": "Já subscreveu um domínio DynDNS", "domain_dyndns_root_unknown": "Domínio root (administrador) DynDNS desconhecido", "domain_exists": "O domínio já existe", "domain_uninstall_app_first": "Existem uma ou mais aplicações instaladas neste domínio. Por favor desinstale-as antes de proceder com a remoção do domínio.", - "domain_unknown": "Domínio desconhecido", "done": "Concluído.", "downloading": "Transferência em curso...", - "dyndns_ip_update_failed": "Não foi possível atualizar o endereço IP a partir de DynDNS", - "dyndns_ip_updated": "Endereço IP atualizado com êxito a partir de DynDNS", + "dyndns_ip_update_failed": "Não foi possível atualizar o endereço IP para DynDNS", + "dyndns_ip_updated": "Endereço IP atualizado com êxito para DynDNS", "dyndns_key_generating": "A chave DNS está a ser gerada, isto pode demorar um pouco...", "dyndns_registered": "Dom+inio DynDNS registado com êxito", "dyndns_registration_failed": "Não foi possível registar o domínio DynDNS: {error}", - "dyndns_unavailable": "Subdomínio DynDNS indisponível", + "dyndns_unavailable": "O domínio '{domain}' não está disponível.", "extracting": "Extração em curso...", "field_invalid": "Campo inválido '{}'", "firewall_reloaded": "Firewall recarregada com êxito", @@ -97,34 +96,33 @@ "app_not_properly_removed": "{app} não foi corretamente removido", "app_requirements_checking": "Verificando os pacotes necessários para {app}...", "app_unsupported_remote_type": "A aplicação não possui suporte ao tipo remoto utilizado", - "backup_archive_app_not_found": "A aplicação '{app}' não foi encontrada no arquivo de backup", - "backup_archive_broken_link": "Impossível acessar o arquivo de backup (link quebrado ao {path})", - "backup_archive_name_exists": "O nome do arquivo de backup já existe", - "backup_archive_open_failed": "Não é possível abrir o arquivo de backup", - "backup_cleaning_failed": "Não é possível limpar a pasta temporária de backups", - "backup_creation_failed": "A criação do backup falhou", - "backup_delete_error": "Impossível apagar '{path}'", - "backup_deleted": "O backup foi suprimido", - "backup_hook_unknown": "Gancho de backup '{hook}' desconhecido", - "backup_nothings_done": "Não há nada para guardar", - "backup_output_directory_forbidden": "Diretório de saída proibido. Os backups não podem ser criados em /boot, /dev, /etc, /lib, /root, /run, /sbin, /sys, /usr, /var ou /home/yunohost.backup/archives subpastas", + "backup_archive_app_not_found": "Não foi possível encontrar {app} no arquivo de backup", + "backup_archive_broken_link": "Não foi possível acessar o arquivo de backup (link quebrado ao {path})", + "backup_archive_name_exists": "Já existe um arquivo de backup com esse nome.", + "backup_archive_open_failed": "Não foi possível abrir o arquivo de backup", + "backup_cleaning_failed": "Não foi possível limpar o diretório temporário de backup", + "backup_creation_failed": "Não foi possível criar o arquivo de backup", + "backup_delete_error": "Não foi possível remover '{path}'", + "backup_deleted": "Backup removido", + "backup_hook_unknown": "O gancho de backup '{hook}' é desconhecido", + "backup_nothings_done": "Nada há se salvar", + "backup_output_directory_forbidden": "Escolha um diretório de saída diferente. Backups não podem ser criados nos subdiretórios /bin, /boot, /dev, /etc, /lib, /root, /run, /sbin, /sys, /usr, /var ou /home/yunohost.backup/archives", "app_already_installed_cant_change_url": "Este aplicativo já está instalado. A URL não pode ser alterada apenas por esta função. Confira em `app changeurl` se está disponível.", "app_already_up_to_date": "{app} já está atualizado", - "app_argument_choice_invalid": "Use uma das opções '{choices}' para o argumento '{name}'", + "app_argument_choice_invalid": "Use uma das opções '{choices}' para o argumento '{name}' em vez de '{value}'", "app_argument_invalid": "Escolha um valor válido para o argumento '{name}': {error}", "app_argument_required": "O argumento '{name}' é obrigatório", - "app_change_url_failed_nginx_reload": "Não foi possível reiniciar o nginx. Aqui está o retorno de 'nginx -t':\n{nginx_errors}", "app_location_unavailable": "Esta url ou não está disponível ou está em conflito com outra(s) aplicação(ões) já instalada(s):\n{apps}", "app_upgrade_app_name": "Atualizando {app}…", "app_upgrade_some_app_failed": "Não foi possível atualizar algumas aplicações", - "backup_abstract_method": "Este metodo de backup ainda não foi implementado", - "backup_app_failed": "Não foi possível fazer o backup dos aplicativos '{app}'", - "backup_applying_method_custom": "Chamando o metodo personalizado de backup '{method}'…", - "backup_applying_method_tar": "Criando o arquivo tar de backup…", + "backup_abstract_method": "Este método de backup ainda não foi implementado", + "backup_app_failed": "Não foi possível fazer o backup de '{app}'", + "backup_applying_method_custom": "Chamando o método personalizado de backup '{method}'…", + "backup_applying_method_tar": "Criando o arquivo TAR de backup…", "backup_archive_name_unknown": "Desconhece-se o arquivo local de backup de nome '{name}'", - "backup_archive_system_part_not_available": "A seção do sistema '{part}' está indisponivel neste backup", - "backup_ask_for_copying_if_needed": "Alguns arquivos não consiguiram ser preparados para backup utilizando o metodo que não gasta espaço de disco temporariamente. Para realizar o backup {size}MB precisam ser usados temporariamente. Você concorda?", - "backup_cant_mount_uncompress_archive": "Não foi possível montar em modo leitura o diretorio de arquivos não comprimido", + "backup_archive_system_part_not_available": "A seção do sistema '{part}' está indisponível neste backup", + "backup_ask_for_copying_if_needed": "Você quer efetuar o backup usando {size}MB temporariamente? (E necessário fazer dessa forma porque alguns arquivos não puderam ser preparados usando um método mais eficiente)", + "backup_cant_mount_uncompress_archive": "Não foi possível montar o arquivo descomprimido como protegido contra escrita", "backup_copying_to_organize_the_archive": "Copiando {size}MB para organizar o arquivo", "app_change_url_identical_domains": "O antigo e o novo domínio / url_path são idênticos ('{domain}{path}'), nada para fazer.", "password_too_simple_1": "A senha precisa ter pelo menos 8 caracteres", @@ -143,7 +141,7 @@ "app_change_url_success": "A URL agora é {domain}{path}", "apps_catalog_obsolete_cache": "O cache do catálogo de aplicações está vazio ou obsoleto.", "apps_catalog_failed_to_download": "Não foi possível fazer o download do catálogo de aplicações {apps_catalog}: {error}", - "apps_catalog_updating": "Atualizado o catálogo de aplicações…", + "apps_catalog_updating": "Atualizando o catálogo de aplicações...", "apps_catalog_init_success": "Catálogo de aplicações do sistema inicializado!", "apps_already_up_to_date": "Todas as aplicações já estão atualizadas", "app_packaging_format_not_supported": "Essa aplicação não pode ser instalada porque o formato dela não é suportado pela sua versão do YunoHost. Considere atualizar seu sistema.", @@ -164,5 +162,34 @@ "app_manifest_install_ask_path": "Escolha o caminho da url (depois do domínio) em que essa aplicação deve ser instalada", "app_manifest_install_ask_domain": "Escolha o domínio em que esta aplicação deve ser instalada", "app_label_deprecated": "Este comando está deprecado! Por favor use o novo comando 'yunohost user permission update' para gerenciar a etiqueta da aplicação.", - "app_make_default_location_already_used": "Não foi passível fazer a aplicação '{app}' ser a padrão no domínio, '{domain}' já está sendo usado por '{other_app}'" -} + "app_make_default_location_already_used": "Não foi passível fazer a aplicação '{app}' ser a padrão no domínio, '{domain}' já está sendo usado por '{other_app}'", + "backup_archive_writing_error": "Não foi possível adicionar os arquivos '{source}' (nomeados dentro do arquivo '{dest}') ao backup no arquivo comprimido '{archive}'", + "backup_archive_corrupted": "Parece que o arquivo de backup '{archive}' está corrompido: {error}", + "backup_archive_cant_retrieve_info_json": "Não foi possível carregar informações para o arquivo '{archive}'... Não foi possível carregar info.json (ou não é um JSON válido).", + "backup_applying_method_copy": "Copiando todos os arquivos para o backup...", + "backup_actually_backuping": "Criando cópia de backup dos arquivos obtidos...", + "ask_user_domain": "Domínio para usar para o endereço de email e conta XMPP do usuário", + "ask_new_path": "Novo caminho", + "ask_new_domain": "Novo domínio", + "apps_catalog_update_success": "O catálogo de aplicações foi atualizado!", + "backup_no_uncompress_archive_dir": "Não existe tal diretório de arquivo descomprimido", + "backup_mount_archive_for_restore": "Preparando o arquivo para restauração...", + "backup_method_tar_finished": "Arquivo de backup TAR criado", + "backup_method_custom_finished": "Método de backup personalizado '{method}' finalizado", + "backup_method_copy_finished": "Cópia de backup finalizada", + "backup_custom_mount_error": "O método personalizado de backup não pôde passar do passo de 'mount'", + "backup_custom_backup_error": "O método personalizado de backup não pôde passar do passo de 'backup'", + "backup_csv_creation_failed": "Não foi possível criar o arquivo CSV necessário para a restauração", + "backup_csv_addition_failed": "Não foi possível adicionar os arquivos que estarão no backup ao arquivo CSV", + "backup_create_size_estimation": "O arquivo irá conter cerca de {size} de dados.", + "backup_couldnt_bind": "Não foi possível vincular {src} ao {dest}", + "certmanager_attempt_to_replace_valid_cert": "Você está tentando sobrescrever um certificado bom e válido para o domínio {domain}! (Use --force para prosseguir mesmo assim)", + "backup_with_no_restore_script_for_app": "A aplicação {app} não tem um script de restauração, você não será capaz de automaticamente restaurar o backup dessa aplicação.", + "backup_with_no_backup_script_for_app": "A aplicação '{app}' não tem um script de backup. Ignorando.", + "backup_unable_to_organize_files": "Não foi possível usar o método rápido de organizar os arquivos no arquivo de backup", + "backup_system_part_failed": "Não foi possível fazer o backup da parte do sistema '{part}'", + "backup_running_hooks": "Executando os hooks de backup...", + "backup_permission": "Permissão de backup para {app}", + "backup_output_symlink_dir_broken": "O diretório de seu arquivo '{path}' é um link simbólico quebrado. Talvez você tenha esquecido de re/montar ou conectar o dispositivo de armazenamento para onde o link aponta.", + "backup_output_directory_required": "Você deve especificar um diretório de saída para o backup" +} \ No newline at end of file diff --git a/locales/uk.json b/locales/uk.json index bdbe8b0cd..35923908f 100644 --- a/locales/uk.json +++ b/locales/uk.json @@ -13,11 +13,10 @@ "app_change_url_success": "URL-адреса {app} тепер {domain}{path}", "app_change_url_no_script": "Застосунок '{app_name}' поки не підтримує зміну URL-адрес. Можливо, вам слід оновити його.", "app_change_url_identical_domains": "Старий і новий domain/url_path збігаються ('{domain}{path}'), нічого робити не треба.", - "app_change_url_failed_nginx_reload": "Не вдалося перезавантажити NGINX. Ось результат 'nginx -t':\n{nginx_errors}", "app_argument_required": "Аргумент '{name}' необхідний", "app_argument_password_no_default": "Помилка під час розбору аргументу пароля '{name}': аргумент пароля не може мати типове значення з причин безпеки", "app_argument_invalid": "Виберіть правильне значення для аргументу '{name}': {error}", - "app_argument_choice_invalid": "Використовуйте один з цих варіантів '{choices}' для аргументу '{name}'", + "app_argument_choice_invalid": "Використовуйте один з цих варіантів '{choices}' для аргументу '{name}' замість '{value}'", "app_already_up_to_date": "{app} має найостаннішу версію", "app_already_installed_cant_change_url": "Цей застосунок уже встановлено. URL-адреса не може бути змінена тільки цією функцією. Перевірте в `app changeurl`, якщо вона доступна.", "app_already_installed": "{app} уже встановлено", @@ -25,9 +24,9 @@ "app_action_cannot_be_ran_because_required_services_down": "Для виконання цієї дії повинні бути запущені наступні необхідні служби: {services}. Спробуйте перезапустити їх, щоб продовжити (і, можливо, з'ясувати, чому вони не працюють).", "already_up_to_date": "Нічого не потрібно робити. Все вже актуально.", "admin_password_too_long": "Будь ласка, виберіть пароль коротше 127 символів", - "admin_password_changed": "Пароль адміністратора було змінено", + "admin_password_changed": "Пароль адміністрації було змінено", "admin_password_change_failed": "Неможливо змінити пароль", - "admin_password": "Пароль адміністратора", + "admin_password": "Пароль адміністрації", "additional_urls_already_removed": "Додаткова URL-адреса '{url}' вже видалена в додатковій URL-адресі для дозволу '{permission}'", "additional_urls_already_added": "Додаткова URL-адреса '{url}' вже додана в додаткову URL-адресу для дозволу '{permission}'", "action_invalid": "Неприпустима дія '{action}'", @@ -87,7 +86,7 @@ "restore_cleaning_failed": "Не вдалося очистити тимчасовий каталог відновлення", "restore_backup_too_old": "Цей архів резервних копій не може бути відновлений, бо він отриманий з дуже старої версії YunoHost.", "restore_already_installed_apps": "Наступні програми не можуть бути відновлені, тому що вони вже встановлені: {apps}", - "restore_already_installed_app": "Застосунок з ID \"{app} 'вже встановлено", + "restore_already_installed_app": "Застосунок з ID «{app}» вже встановлено", "regex_with_only_domain": "Ви не можете використовувати regex для домену, тільки для шляху", "regex_incompatible_with_tile": "/! \\ Packagers! Дозвіл '{permission}' має значення show_tile 'true', тому ви не можете визначити regex URL в якості основної URL", "regenconf_need_to_explicitly_specify_ssh": "Конфігурація ssh була змінена вручну, але вам потрібно явно вказати категорію 'ssh' з --force, щоб застосувати зміни.", @@ -127,7 +126,6 @@ "permission_already_allowed": "Група '{group}' вже має увімкнений дозвіл '{permission}'", "pattern_password_app": "На жаль, паролі не можуть містити такі символи: {forbidden_chars}", "pattern_username": "Має складатися тільки з букв і цифр в нижньому регістрі і символів підкреслення", - "pattern_positive_number": "Має бути додатним числом", "pattern_port_or_range": "Має бути припустимий номер порту (наприклад, 0-65535) або діапазон портів (наприклад, 100:200)", "pattern_password": "Має бути довжиною не менше 3 символів", "pattern_mailbox_quota": "Має бути розмір з суфіксом b/k/M/G/T або 0, щоб не мати квоти", @@ -146,7 +144,7 @@ "operation_interrupted": "Операція була вручну перервана?", "invalid_number": "Має бути числом", "not_enough_disk_space": "Недостатньо вільного місця на '{path}'", - "migrations_to_be_ran_manually": "Міграція {id} повинна бути запущена вручну. Будь ласка, перейдіть в розділ Засоби → Міграції на сторінці вебадміністратора або виконайте команду `yunohost tools migrations run`.", + "migrations_to_be_ran_manually": "Міграція {id} повинна бути запущена вручну. Будь ласка, перейдіть в розділ Засоби → Міграції на сторінці вебадміністрації або виконайте команду `yunohost tools migrations run`.", "migrations_success_forward": "Міграцію {id} завершено", "migrations_skip_migration": "Пропускання міграції {id}...", "migrations_running_forward": "Виконання міграції {id}...", @@ -175,7 +173,7 @@ "migration_0015_cleaning_up": "Очищення кеш-пам'яті і пакетів, які більше не потрібні...", "migration_0015_specific_upgrade": "Початок оновлення системних пакетів, які повинні бути оновлені незалежно...", "migration_0015_modified_files": "Зверніть увагу, що такі файли були змінені вручну і можуть бути перезаписані після оновлення: {manually_modified_files}", - "migration_0015_problematic_apps_warning": "Зверніть увагу, що були виявлені наступні, можливо, проблемні встановлені застосунки. Схоже, що вони не були встановлені з каталогу застосунків YunoHost або не зазначені як \"робочі\". Отже, не можна гарантувати, що вони будуть працювати після оновлення: {problematic_apps}", + "migration_0015_problematic_apps_warning": "Зверніть увагу, що були виявлені наступні, можливо, проблемні встановлені застосунки. Схоже, що вони не були встановлені з каталогу застосунків YunoHost або не зазначені як «робочі». Отже, не можна гарантувати, що вони будуть працювати після оновлення: {problematic_apps}", "migration_0015_general_warning": "Будь ласка, зверніть увагу, що ця міграція є делікатною операцією. Команда YunoHost зробила все можливе, щоб перевірити і протестувати її, але міграція все ще може порушити частина системи або її застосунків.\n\nТому рекомендовано:\n - Виконати резервне копіювання всіх важливих даних або застосунків. Подробиці на сайті https://yunohost.org/backup; \n - Наберіться терпіння після запуску міграції: В залежності від вашого з'єднання з Інтернетом і апаратного забезпечення, оновлення може зайняти до декількох годин.", "migration_0015_system_not_fully_up_to_date": "Ваша система не повністю оновлена. Будь ласка, виконайте регулярне оновлення перед запуском міграції на Buster.", "migration_0015_not_enough_free_space": "Вільного місця в /var/ досить мало! У вас повинно бути не менше 1 ГБ вільного місця, щоб запустити цю міграцію.", @@ -211,11 +209,11 @@ "log_tools_postinstall": "Післявстановлення сервера YunoHost", "log_tools_migrations_migrate_forward": "Запущено міграції", "log_domain_main_domain": "Зроблено '{}' основним доменом", - "log_user_permission_reset": "Скинуто дозвіл \"{} '", + "log_user_permission_reset": "Скинуто дозвіл «{}»", "log_user_permission_update": "Оновлено доступи для дозволу '{}'", "log_user_update": "Оновлено відомості для користувача '{}'", "log_user_group_update": "Оновлено групу '{}'", - "log_user_group_delete": "Видалено групу \"{} '", + "log_user_group_delete": "Видалено групу «{}»", "log_user_group_create": "Створено групу '{}'", "log_user_delete": "Видалення користувача '{}'", "log_user_create": "Додавання користувача '{}'", @@ -236,19 +234,17 @@ "log_backup_restore_system": "Відновлення системи з резервного архіву", "log_backup_create": "Створення резервного архіву", "log_available_on_yunopaste": "Цей журнал тепер доступний за посиланням {url}", - "log_app_config_apply": "Застосування конфігурації до застосунку '{}'", - "log_app_config_show_panel": "Показ панелі конфігурації застосунку '{}'", - "log_app_action_run": "Запуск дії застосунку \"{} '", + "log_app_action_run": "Запуск дії застосунку «{}»", "log_app_makedefault": "Застосунок '{}' зроблено типовим", "log_app_upgrade": "Оновлення застосунку '{}'", "log_app_remove": "Вилучення застосунку '{}'", "log_app_install": "Установлення застосунку '{}'", - "log_app_change_url": "Змінення URL-адреси застосунку \"{} '", + "log_app_change_url": "Змінення URL-адреси застосунку «{}»", "log_operation_unit_unclosed_properly": "Блок операцій не був закритий належним чином", "log_does_exists": "Немає журналу операцій з назвою '{log}', використовуйте 'yunohost log list', щоб подивитися всі доступні журнали операцій", "log_help_to_get_failed_log": "Операція '{desc}' не може бути завершена. Будь ласка, поділіться повним журналом цієї операції, використовуючи команду 'yunohost log share {name}', щоб отримати допомогу", "log_link_to_failed_log": "Не вдалося завершити операцію '{desc}'. Будь ласка, надайте повний журнал цієї операції, натиснувши тут, щоб отримати допомогу", - "log_help_to_get_log": "Щоб переглянути журнал операції '{desc}', використовуйте команду 'yunohost log show {name}{name}'", + "log_help_to_get_log": "Щоб переглянути журнал операції '{desc}', використовуйте команду 'yunohost log show {name}'", "log_link_to_log": "Повний журнал цієї операції: '{desc}'", "log_corrupted_md_file": "Файл метаданих YAML, пов'язаний з журналами, пошкоджено: '{md_file}\nПомилка: {error}'", "iptables_unavailable": "Ви не можете грати з iptables тут. Ви перебуваєте або в контейнері, або ваше ядро не підтримує його", @@ -277,11 +273,11 @@ "group_already_exist_on_system": "Група {group} вже існує в групах системи", "group_already_exist": "Група {group} вже існує", "good_practices_about_user_password": "Зараз ви збираєтеся поставити новий пароль користувача. Пароль повинен складатися не менше ніж з 8 символів, але хорошою практикою є використання більш довгого пароля (тобто гасла) і/або використання різних символів (великих, малих, цифр і спеціальних символів).", - "good_practices_about_admin_password": "Зараз ви збираєтеся поставити новий пароль адміністратора. Пароль повинен складатися не менше ніж з 8 символів, але хорошою практикою є використання більш довгого пароля (тобто парольного гасла) і/або використання різних символів (великих, малих, цифр і спеціальних символів).", + "good_practices_about_admin_password": "Зараз ви збираєтеся поставити новий пароль адміністрації. Пароль повинен складатися не менше ніж з 8 символів, але хорошою практикою є використання більш довгого пароля (тобто парольного гасла) і/або використання різних символів (великих, малих, цифр і спеціальних символів).", "global_settings_unknown_type": "Несподівана ситуація, налаштування {setting} має тип {unknown_type}, але це не тип, підтримуваний системою.", "global_settings_setting_backup_compress_tar_archives": "При створенні нових резервних копій стискати архіви (.tar.gz) замість нестислих архівів (.tar). NB: вмикання цієї опції означає створення легших архівів резервних копій, але початкова процедура резервного копіювання буде значно довшою і важчою для CPU.", - "global_settings_setting_security_webadmin_allowlist": "IP-адреси, яким дозволений доступ до вебадміністратора. Через кому.", - "global_settings_setting_security_webadmin_allowlist_enabled": "Дозволити доступ до вебадміністратора тільки деяким IP-адресам.", + "global_settings_setting_security_webadmin_allowlist": "IP-адреси, яким дозволений доступ до вебадміністрації. Через кому.", + "global_settings_setting_security_webadmin_allowlist_enabled": "Дозволити доступ до вебадміністрації тільки деяким IP-адресам.", "global_settings_setting_smtp_relay_password": "Пароль хоста SMTP-ретрансляції", "global_settings_setting_smtp_relay_user": "Обліковий запис користувача SMTP-ретрансляції", "global_settings_setting_smtp_relay_port": "Порт SMTP-ретрансляції", @@ -328,7 +324,6 @@ "downloading": "Завантаження…", "done": "Готово", "domains_available": "Доступні домени:", - "domain_unknown": "Невідомий домен", "domain_name_unknown": "Домен '{domain}' невідомий", "domain_uninstall_app_first": "Ці застосунки все ще встановлені на вашому домені:\n{apps}\n\nВидаліть їх за допомогою 'yunohost app remove the_app_id' або перемістіть їх на інший домен за допомогою 'yunohost app change-url the_app_id', перш ніж приступити до вилучення домену", "domain_remove_confirm_apps_removal": "Вилучення цього домену призведе до вилучення таких застосунків:\n{apps}\n\nВи впевнені, що хочете це зробити? [{answers}]", @@ -351,7 +346,7 @@ "diagnosis_sshd_config_inconsistent": "Схоже, що порт SSH був уручну змінений в /etc/ssh/sshd_config. Починаючи з версії YunoHost 4.2, доступний новий глобальний параметр 'security.ssh.port', що дозволяє уникнути ручного редагування конфігурації.", "diagnosis_sshd_config_insecure": "Схоже, що конфігурація SSH була змінена вручну і є небезпечною, оскільки не містить директив 'AllowGroups' або 'AllowUsers' для обмеження доступу авторизованих користувачів.", "diagnosis_processes_killed_by_oom_reaper": "Деякі процеси було недавно вбито системою через брак пам'яті. Зазвичай це є симптомом нестачі пам'яті в системі або процесу, який з'їв дуже багато пам'яті. Зведення убитих процесів:\n{kills_summary}", - "diagnosis_never_ran_yet": "Схоже, що цей сервер був налаштований недавно, і поки немає звіту про діагностику. Вам слід почати з повної діагностики, або з вебадміністратора, або використовуючи 'yunohost diagnosis run' з командного рядка.", + "diagnosis_never_ran_yet": "Схоже, що цей сервер був налаштований недавно, і поки немає звіту про діагностику. Вам слід почати з повної діагностики, або з вебадміністрації, або використовуючи 'yunohost diagnosis run' з командного рядка.", "diagnosis_unknown_categories": "Наступні категорії невідомі: {categories}", "diagnosis_http_nginx_conf_not_up_to_date_details": "Щоб виправити становище, перевірте різницю за допомогою командного рядка, використовуючи yunohost tools regen-conf nginx --dry-run --with-diff, і якщо все в порядку, застосуйте зміни за допомогою команди yunohost tools regen-conf nginx --force.", "diagnosis_http_nginx_conf_not_up_to_date": "Схоже, що конфігурація nginx цього домену була змінена вручну, що не дозволяє YunoHost визначити, чи доступний він по HTTP.", @@ -416,7 +411,7 @@ "diagnosis_mail_outgoing_port_25_blocked_details": "Спочатку спробуйте розблокувати вихідний порт 25 в інтерфейсі вашого інтернет-маршрутизатора або в інтерфейсі вашого хостинг-провайдера. (Деякі хостинг-провайдери можуть вимагати, щоб ви відправили їм заявку в службу підтримки).", "diagnosis_mail_outgoing_port_25_blocked": "Поштовий сервер SMTP не може відправляти електронні листи на інші сервери, оскільки вихідний порт 25 заблоковано в IPv{ipversion}.", "app_manifest_install_ask_path": "Оберіть шлях URL (після домену), за яким має бути встановлено цей застосунок", - "yunohost_postinstall_end_tip": "Післявстановлення завершено! Щоб завершити доналаштування, будь ласка, розгляньте наступні варіанти:\n - додавання першого користувача через розділ 'Користувачі' вебадміністратора (або 'yunohost user create ' в командному рядку);\n - діагностика можливих проблем через розділ 'Діагностика' вебадміністратора (або 'yunohost diagnosis run' в командному рядку);\n - прочитання розділів 'Завершення встановлення' і 'Знайомство з YunoHost' у документації адміністратора: https://yunohost.org/admindoc.", + "yunohost_postinstall_end_tip": "Післявстановлення завершено! Щоб завершити доналаштування, будь ласка, розгляньте наступні варіанти:\n - додавання першого користувача через розділ 'Користувачі' вебадміністрації (або 'yunohost user create ' в командному рядку);\n - діагностика можливих проблем через розділ 'Діагностика' вебадміністрації (або 'yunohost diagnosis run' в командному рядку);\n - прочитання розділів 'Завершення встановлення' і 'Знайомство з YunoHost' у документації адміністратора: https://yunohost.org/admindoc.", "yunohost_not_installed": "YunoHost установлений неправильно. Будь ласка, запустіть 'yunohost tools postinstall'", "yunohost_installing": "Установлення YunoHost...", "yunohost_configured": "YunoHost вже налаштовано", @@ -445,7 +440,7 @@ "unexpected_error": "Щось пішло не так: {error}", "unbackup_app": "{app} НЕ буде збережено", "tools_upgrade_special_packages_completed": "Оновлення пакета YunoHost завершено.\nНатисніть [Enter] для повернення до командного рядка", - "tools_upgrade_special_packages_explanation": "Спеціальне оновлення триватиме у тлі. Будь ласка, не запускайте ніяких інших дій на вашому сервері протягом наступних ~ 10 хвилин (в залежності від швидкості обладнання). Після цього вам, можливо, доведеться заново увійти в вебадміністратора. Журнал оновлення буде доступний в Засоби → Журнал (в веб-адміністраторі) або за допомогою 'yunohost log list' (з командного рядка).", + "tools_upgrade_special_packages_explanation": "Спеціальне оновлення триватиме у тлі. Будь ласка, не запускайте ніяких інших дій на вашому сервері протягом наступних ~ 10 хвилин (в залежності від швидкості обладнання). Після цього вам, можливо, доведеться заново увійти в вебадміністрації. Журнал оновлення буде доступний в Засоби → Журнал (в вебадміністрації) або за допомогою 'yunohost log list' (з командного рядка).", "tools_upgrade_special_packages": "Тепер оновлюємо 'спеціальні' (пов'язані з yunohost) пакети…", "tools_upgrade_regular_packages_failed": "Не вдалося оновити пакети: {packages_list}", "tools_upgrade_regular_packages": "Тепер оновлюємо 'звичайні' (не пов'язані з yunohost) пакети…", @@ -476,7 +471,7 @@ "diagnosis_diskusage_ok": "У сховищі {mountpoint} (на пристрої {device}) залишилося {free} ({free_percent}%) вільного місця (з {total})!", "diagnosis_diskusage_low": "Сховище {mountpoint} (на пристрої {device}) має тільки {free} ({free_percent}%) вільного місця (з {total}). Будьте уважні.", "diagnosis_diskusage_verylow": "Сховище {mountpoint} (на пристрої {device}) має тільки {free} ({free_percent}%) вільного місця (з {total}). Вам дійсно варто подумати про очищення простору!", - "diagnosis_services_bad_status_tip": "Ви можете спробувати перезапустити службу, а якщо це не допоможе, подивіться журнали служби в вебадміністраторі (з командного рядка це можна зробити за допомогою yunohost service restart {service} і yunohost service log {service}).", + "diagnosis_services_bad_status_tip": "Ви можете спробувати перезапустити службу, а якщо це не допоможе, подивіться журнали служби в вебадміністрації (з командного рядка це можна зробити за допомогою yunohost service restart {service} і yunohost service log {service}).", "diagnosis_services_bad_status": "Служба {service} у стані {status} :(", "diagnosis_services_conf_broken": "Для служби {service} порушена конфігурація!", "diagnosis_services_running": "Службу {service} запущено!", @@ -507,7 +502,7 @@ "diagnosis_ip_connected_ipv6": "Сервер під'єднаний до Інтернету через IPv6!", "diagnosis_ip_no_ipv4": "Сервер не має робочого IPv4.", "diagnosis_ip_connected_ipv4": "Сервер під'єднаний до Інтернету через IPv4!", - "diagnosis_no_cache": "Для категорії \"{category} 'ще немає кеша діагностики", + "diagnosis_no_cache": "Для категорії «{category}» ще немає кеша діагностики", "diagnosis_failed": "Не вдалося отримати результат діагностики для категорії '{category}': {error}", "diagnosis_everything_ok": "Усе виглядає добре для {category}!", "diagnosis_found_warnings": "Знайдено {warnings} пунктів, які можна поліпшити для {category}.", @@ -517,7 +512,7 @@ "diagnosis_cant_run_because_of_dep": "Неможливо запустити діагностику для {category}, поки є важливі проблеми, пов'язані з {dep}.", "diagnosis_cache_still_valid": "(Кеш все ще дійсний для діагностики {category}. Повторна діагностика поки не проводиться!)", "diagnosis_failed_for_category": "Не вдалося провести діагностику для категорії '{category}': {error}", - "diagnosis_display_tip": "Щоб побачити знайдені проблеми, ви можете перейти в розділ Діагностика в вебадміністраторі або виконати команду 'yunohost diagnosis show --issues --human-readable' з командного рядка.", + "diagnosis_display_tip": "Щоб побачити знайдені проблеми, ви можете перейти в розділ Діагностика в вебадміністрації або виконати команду 'yunohost diagnosis show --issues --human-readable' з командного рядка.", "diagnosis_package_installed_from_sury_details": "Деякі пакети були ненавмисно встановлені зі стороннього репозиторію під назвою Sury. Команда YunoHost поліпшила стратегію роботи з цими пакетами, але очікується, що в деяких системах, які встановили застосунки PHP7.3 ще на Stretch, залишаться деякі невідповідності. Щоб виправити це становище, спробуйте виконати наступну команду: {cmd_to_fix}", "diagnosis_package_installed_from_sury": "Деякі системні пакети мають бути зістарені у версії", "diagnosis_backports_in_sources_list": "Схоже, що apt (менеджер пакетів) налаштований на використання репозиторія backports. Якщо ви не знаєте, що робите, ми наполегливо не радимо встановлювати пакети з backports, тому що це може привести до нестабільності або конфліктів у вашій системі.", @@ -600,7 +595,7 @@ "ask_password": "Пароль", "ask_new_path": "Новий шлях", "ask_new_domain": "Новий домен", - "ask_new_admin_password": "Новий пароль адміністратора", + "ask_new_admin_password": "Новий пароль адміністрації", "ask_main_domain": "Основний домен", "ask_lastname": "Прізвище", "ask_firstname": "Ім'я", @@ -637,7 +632,7 @@ "app_not_upgraded": "Застосунок '{failed_app}' не вдалося оновити, і, як наслідок, оновлення таких застосунків було скасовано: {apps}", "app_manifest_install_ask_is_public": "Чи має цей застосунок бути відкритим для анонімних відвідувачів?", "app_manifest_install_ask_admin": "Виберіть користувача-адміністратора для цього застосунку", - "app_manifest_install_ask_password": "Виберіть пароль адміністратора для цього застосунку", + "app_manifest_install_ask_password": "Виберіть пароль адміністрації для цього застосунку", "diagnosis_description_apps": "Застосунки", "user_import_success": "Користувачів успішно імпортовано", "user_import_nothing_to_do": "Не потрібно імпортувати жодного користувача", @@ -657,5 +652,28 @@ "diagnosis_apps_broken": "Цей застосунок наразі позначено як зламаний у каталозі застосунків YunoHost. Це може бути тимчасовою проблемою, поки організатори намагаються вирішити цю проблему. Тим часом оновлення цього застосунку вимкнено.", "diagnosis_apps_not_in_app_catalog": "Цей застосунок не міститься у каталозі застосунків YunoHost. Якщо він був у минулому і був видалений, вам слід подумати про видалення цього застосунку, оскільки він не отримає оновлення, і це може поставити під загрозу цілісність та безпеку вашої системи.", "diagnosis_apps_issue": "Виявлено проблему із застосунком {app}", - "diagnosis_apps_allgood": "Усі встановлені застосунки дотримуються основних способів упакування" -} + "diagnosis_apps_allgood": "Усі встановлені застосунки дотримуються основних способів упакування", + "diagnosis_high_number_auth_failures": "Останнім часом сталася підозріло велика кількість помилок автентифікації. Ви можете переконатися, що fail2ban працює і правильно налаштований, або скористатися власним портом для SSH, як описано в https://yunohost.org/security.", + "global_settings_setting_security_nginx_redirect_to_https": "Типово переспрямовувати HTTP-запити до HTTP (НЕ ВИМИКАЙТЕ, якщо ви дійсно не знаєте, що робите!)", + "app_config_unable_to_apply": "Не вдалося застосувати значення панелі конфігурації.", + "app_config_unable_to_read": "Не вдалося розпізнати значення панелі конфігурації.", + "config_apply_failed": "Не вдалося застосувати нову конфігурацію: {error}", + "config_cant_set_value_on_section": "Ви не можете встановити одне значення на весь розділ конфігурації.", + "config_forbidden_keyword": "Ключове слово '{keyword}' зарезервовано, ви не можете створити або використовувати панель конфігурації з запитом із таким ID.", + "config_no_panel": "Панель конфігурації не знайдено.", + "config_unknown_filter_key": "Ключ фільтра '{filter_key}' недійсний.", + "config_validate_color": "Колір RGB має бути дійсним шістнадцятковим кольоровим кодом", + "config_validate_date": "Дата має бути дійсною, наприклад, у форматі РРРР-ММ-ДД", + "config_validate_email": "Е-пошта має бути дійсною", + "config_validate_time": "Час має бути дійсним, наприклад ГГ:ХХ", + "config_validate_url": "Вебадреса має бути дійсною", + "config_version_not_supported": "Версії конфігураційної панелі '{version}' не підтримуються.", + "danger": "Небезпека:", + "file_extension_not_accepted": "Файл '{path}' відхиляється, бо його розширення не входить в число прийнятих розширень: {accept}", + "invalid_number_min": "Має бути більшим за {min}", + "invalid_number_max": "Має бути меншим за {max}", + "log_app_config_set": "Застосувати конфігурацію до застосунку '{}'", + "service_not_reloading_because_conf_broken": "Неможливо перезавантажити/перезапустити службу '{name}', тому що її конфігурацію порушено: {errors}", + "app_argument_password_help_optional": "Введіть один пробіл, щоб очистити пароль", + "app_argument_password_help_keep": "Натисніть Enter, щоб зберегти поточне значення" +} \ No newline at end of file diff --git a/locales/zh_Hans.json b/locales/zh_Hans.json index 560ee0db0..9176ebab9 100644 --- a/locales/zh_Hans.json +++ b/locales/zh_Hans.json @@ -150,7 +150,6 @@ "app_change_url_success": "{app} URL现在为 {domain}{path}", "app_change_url_no_script": "应用程序'{app_name}'尚不支持URL修改. 也许您应该升级它。", "app_change_url_identical_domains": "新旧domain / url_path是相同的('{domain}{path}'),无需执行任何操作。", - "app_change_url_failed_nginx_reload": "无法重新加载NGINX. 这是'nginx -t'的输出:\n{nginx_errors}", "app_argument_required": "参数'{name}'为必填项", "app_argument_password_no_default": "解析密码参数'{name}'时出错:出于安全原因,密码参数不能具有默认值", "app_argument_invalid": "为参数'{name}'选择一个有效值: {error}", @@ -360,7 +359,6 @@ "downloading": "下载中…", "done": "完成", "domains_available": "可用域:", - "domain_unknown": "未知网域", "domain_name_unknown": "域'{domain}'未知", "domain_uninstall_app_first": "这些应用程序仍安装在您的域中:\n{apps}\n\n请先使用 'yunohost app remove the_app_id' 将其卸载,或使用 'yunohost app change-url the_app_id'将其移至另一个域,然后再继续删除域", "domain_remove_confirm_apps_removal": "删除该域将删除这些应用程序:\n{apps}\n\n您确定要这样做吗? [{answers}]", @@ -499,8 +497,6 @@ "diagnosis_dns_discrepancy": "以下DNS记录似乎未遵循建议的配置:
类型: {type}
名称: {name}
代码> 当前值: {current}期望值: {value}", "log_backup_create": "创建备份档案", "log_available_on_yunopaste": "现在可以通过{url}使用此日志", - "log_app_config_apply": "将配置应用于 '{}' 应用", - "log_app_config_show_panel": "显示 '{}' 应用的配置面板", "log_app_action_run": "运行 '{}' 应用的操作", "log_app_makedefault": "将 '{}' 设为默认应用", "log_app_upgrade": "升级 '{}' 应用", @@ -511,7 +507,7 @@ "log_does_exists": "没有名称为'{log}'的操作日志,请使用 'yunohost log list' 查看所有可用的操作日志", "log_help_to_get_failed_log": "操作'{desc}'无法完成。请使用命令'yunohost log share {name}' 共享此操作的完整日志以获取帮助", "log_link_to_failed_log": "无法完成操作 '{desc}'。请通过单击此处提供此操作的完整日志以获取帮助", - "log_help_to_get_log": "要查看操作'{desc}'的日志,请使用命令'yunohost log show {name}{name}'", + "log_help_to_get_log": "要查看操作'{desc}'的日志,请使用命令'yunohost log show {name}'", "log_link_to_log": "此操作的完整日志: '{desc}'", "log_corrupted_md_file": "与日志关联的YAML元数据文件已损坏: '{md_file}\n错误: {error}'", "iptables_unavailable": "你不能在这里使用iptables。你要么在一个容器中,要么你的内核不支持它", @@ -541,7 +537,6 @@ "permission_already_allowed": "群组 '{group}' 已启用权限'{permission}'", "pattern_password_app": "抱歉,密码不能包含以下字符: {forbidden_chars}", "pattern_username": "只能为小写字母数字和下划线字符", - "pattern_positive_number": "必须为正数", "pattern_port_or_range": "必须是有效的端口号(即0-65535)或端口范围(例如100:200)", "pattern_password": "必须至少3个字符长", "pattern_mailbox_quota": "必须为带b/k/M/G/T 后缀的大小或0,才能没有配额", diff --git a/src/yunohost/.coveragerc b/src/yunohost/.coveragerc new file mode 100644 index 000000000..43e152271 --- /dev/null +++ b/src/yunohost/.coveragerc @@ -0,0 +1,2 @@ +[report] +omit=tests/*,vendor/*,/usr/lib/moulinette/yunohost/ diff --git a/src/yunohost/app.py b/src/yunohost/app.py index 0bc6110fa..926de6b1f 100644 --- a/src/yunohost/app.py +++ b/src/yunohost/app.py @@ -52,8 +52,12 @@ from moulinette.utils.filesystem import ( mkdir, ) -from yunohost.service import service_status, _run_service_command from yunohost.utils import packages +from yunohost.utils.config import ( + ConfigPanel, + parse_args_in_yunohost_format, +) +from yunohost.utils.i18n import _value_for_locale from yunohost.utils.error import YunohostError, YunohostValidationError from yunohost.utils.filesystem import free_space_in_directory from yunohost.log import is_unit_operation, OperationLogger @@ -162,10 +166,7 @@ def app_info(app, full=False): """ from yunohost.permission import user_permission_list - if not _is_installed(app): - raise YunohostValidationError( - "app_not_installed", app=app, all_apps=_get_all_installed_apps_id() - ) + _assert_is_installed(app) setting_path = os.path.join(APPS_SETTING_PATH, app) local_manifest = _get_manifest_of_app(setting_path) @@ -206,6 +207,9 @@ def app_info(app, full=False): ret["supports_multi_instance"] = is_true( local_manifest.get("multi_instance", False) ) + ret["supports_config_panel"] = os.path.exists( + os.path.join(setting_path, "config_panel.toml") + ) ret["permissions"] = permissions ret["label"] = permissions.get(app + ".main", {}).get("label") @@ -394,6 +398,7 @@ def app_change_url(operation_logger, app, domain, path): """ from yunohost.hook import hook_exec, hook_callback + from yunohost.service import service_reload_or_restart installed = _is_installed(app) if not installed: @@ -427,19 +432,21 @@ def app_change_url(operation_logger, app, domain, path): # TODO: Allow to specify arguments args_odict = _parse_args_from_manifest(manifest, "change_url") + tmp_workdir_for_app = _make_tmp_workdir_for_app(app=app) + # Prepare env. var. to pass to script env_dict = _make_environment_for_app_script(app, args=args_odict) env_dict["YNH_APP_OLD_DOMAIN"] = old_domain env_dict["YNH_APP_OLD_PATH"] = old_path env_dict["YNH_APP_NEW_DOMAIN"] = domain env_dict["YNH_APP_NEW_PATH"] = path + env_dict["YNH_APP_BASEDIR"] = tmp_workdir_for_app if domain != old_domain: operation_logger.related_to.append(("domain", old_domain)) operation_logger.extra.update({"env": env_dict}) operation_logger.start() - tmp_workdir_for_app = _make_tmp_workdir_for_app(app=app) change_url_script = os.path.join(tmp_workdir_for_app, "scripts/change_url") # Execute App change_url script @@ -462,15 +469,7 @@ def app_change_url(operation_logger, app, domain, path): app_ssowatconf() - # avoid common mistakes - if _run_service_command("reload", "nginx") is False: - # grab nginx errors - # the "exit 0" is here to avoid check_output to fail because 'nginx -t' - # will return != 0 since we are in a failed state - nginx_errors = check_output("nginx -t; exit 0") - raise YunohostError( - "app_change_url_failed_nginx_reload", nginx_errors=nginx_errors - ) + service_reload_or_restart("nginx") logger.success(m18n.n("app_change_url_success", app=app, domain=domain, path=path)) @@ -489,7 +488,12 @@ def app_upgrade(app=[], url=None, file=None, force=False, no_safety_backup=False """ from packaging import version - from yunohost.hook import hook_add, hook_remove, hook_exec, hook_callback + from yunohost.hook import ( + hook_add, + hook_remove, + hook_callback, + hook_exec_with_script_debug_if_failure, + ) from yunohost.permission import permission_sync_to_user from yunohost.regenconf import manually_modified_files @@ -509,10 +513,8 @@ def app_upgrade(app=[], url=None, file=None, force=False, no_safety_backup=False apps = [app_ for i, app_ in enumerate(apps) if app_ not in apps[:i]] # Abort if any of those app is in fact not installed.. - for app in [app_ for app_ in apps if not _is_installed(app_)]: - raise YunohostValidationError( - "app_not_installed", app=app, all_apps=_get_all_installed_apps_id() - ) + for app_ in apps: + _assert_is_installed(app_) if len(apps) == 0: raise YunohostValidationError("apps_already_up_to_date") @@ -595,6 +597,7 @@ def app_upgrade(app=[], url=None, file=None, force=False, no_safety_backup=False env_dict["YNH_APP_MANIFEST_VERSION"] = str(app_new_version) env_dict["YNH_APP_CURRENT_VERSION"] = str(app_current_version) env_dict["NO_BACKUP_UPGRADE"] = "1" if no_safety_backup else "0" + env_dict["YNH_APP_BASEDIR"] = extracted_app_folder # We'll check that the app didn't brutally edit some system configuration manually_modified_files_before_install = manually_modified_files() @@ -613,36 +616,18 @@ def app_upgrade(app=[], url=None, file=None, force=False, no_safety_backup=False # Execute the app upgrade script upgrade_failed = True try: - upgrade_retcode = hook_exec( - extracted_app_folder + "/scripts/upgrade", env=env_dict - )[0] - - upgrade_failed = True if upgrade_retcode != 0 else False - if upgrade_failed: - error = m18n.n("app_upgrade_script_failed") - logger.error( - m18n.n("app_upgrade_failed", app=app_instance_name, error=error) - ) - failure_message_with_debug_instructions = operation_logger.error(error) - if Moulinette.interface.type != "api": - dump_app_log_extract_for_debugging(operation_logger) - # Script got manually interrupted ... N.B. : KeyboardInterrupt does not inherit from Exception - except (KeyboardInterrupt, EOFError): - upgrade_retcode = -1 - error = m18n.n("operation_interrupted") - logger.error( - m18n.n("app_upgrade_failed", app=app_instance_name, error=error) + ( + upgrade_failed, + failure_message_with_debug_instructions, + ) = hook_exec_with_script_debug_if_failure( + extracted_app_folder + "/scripts/upgrade", + env=env_dict, + operation_logger=operation_logger, + error_message_if_script_failed=m18n.n("app_upgrade_script_failed"), + error_message_if_failed=lambda e: m18n.n( + "app_upgrade_failed", app=app_instance_name, error=e + ), ) - failure_message_with_debug_instructions = operation_logger.error(error) - # Something wrong happened in Yunohost's code (most probably hook_exec) - except Exception: - import traceback - - error = m18n.n("unexpected_error", error="\n" + traceback.format_exc()) - logger.error( - m18n.n("app_install_failed", app=app_instance_name, error=error) - ) - failure_message_with_debug_instructions = operation_logger.error(error) finally: # Whatever happened (install success or failure) we check if it broke the system # and warn the user about it @@ -726,7 +711,6 @@ def app_upgrade(app=[], url=None, file=None, force=False, no_safety_backup=False for file_to_copy in [ "actions.json", "actions.toml", - "config_panel.json", "config_panel.toml", "conf", ]: @@ -789,7 +773,13 @@ def app_install( force -- Do not ask for confirmation when installing experimental / low-quality apps """ - from yunohost.hook import hook_add, hook_remove, hook_exec, hook_callback + from yunohost.hook import ( + hook_add, + hook_remove, + hook_callback, + hook_exec, + hook_exec_with_script_debug_if_failure, + ) from yunohost.log import OperationLogger from yunohost.permission import ( user_permission_list, @@ -805,6 +795,10 @@ def app_install( if confirm is None or force or Moulinette.interface.type == "api": return + # i18n: confirm_app_install_warning + # i18n: confirm_app_install_danger + # i18n: confirm_app_install_thirdparty + if confirm in ["danger", "thirdparty"]: answer = Moulinette.prompt( m18n.n("confirm_app_install_" + confirm, answers="Yes, I understand"), @@ -901,19 +895,6 @@ def app_install( # We'll check that the app didn't brutally edit some system configuration manually_modified_files_before_install = manually_modified_files() - # Tell the operation_logger to redact all password-type args - # Also redact the % escaped version of the password that might appear in - # the 'args' section of metadata (relevant for password with non-alphanumeric char) - data_to_redact = [ - value[0] for value in args_odict.values() if value[1] == "password" - ] - data_to_redact += [ - urllib.parse.quote(data) - for data in data_to_redact - if urllib.parse.quote(data) != data - ] - operation_logger.data_to_redact.extend(data_to_redact) - operation_logger.related_to = [ s for s in operation_logger.related_to if s[0] != "app" ] @@ -946,7 +927,6 @@ def app_install( for file_to_copy in [ "actions.json", "actions.toml", - "config_panel.json", "config_panel.toml", "conf", ]: @@ -970,6 +950,7 @@ def app_install( # Prepare env. var. to pass to script env_dict = _make_environment_for_app_script(app_instance_name, args=args_odict) + env_dict["YNH_APP_BASEDIR"] = extracted_app_folder env_dict_for_logging = env_dict.copy() for arg_name, arg_value_and_type in args_odict.items(): @@ -981,29 +962,18 @@ def app_install( # Execute the app install script install_failed = True try: - install_retcode = hook_exec( - os.path.join(extracted_app_folder, "scripts/install"), env=env_dict - )[0] - # "Common" app install failure : the script failed and returned exit code != 0 - install_failed = True if install_retcode != 0 else False - if install_failed: - error = m18n.n("app_install_script_failed") - logger.error(m18n.n("app_install_failed", app=app_id, error=error)) - failure_message_with_debug_instructions = operation_logger.error(error) - if Moulinette.interface.type != "api": - dump_app_log_extract_for_debugging(operation_logger) - # Script got manually interrupted ... N.B. : KeyboardInterrupt does not inherit from Exception - except (KeyboardInterrupt, EOFError): - error = m18n.n("operation_interrupted") - logger.error(m18n.n("app_install_failed", app=app_id, error=error)) - failure_message_with_debug_instructions = operation_logger.error(error) - # Something wrong happened in Yunohost's code (most probably hook_exec) - except Exception: - import traceback - - error = m18n.n("unexpected_error", error="\n" + traceback.format_exc()) - logger.error(m18n.n("app_install_failed", app=app_id, error=error)) - failure_message_with_debug_instructions = operation_logger.error(error) + ( + install_failed, + failure_message_with_debug_instructions, + ) = hook_exec_with_script_debug_if_failure( + os.path.join(extracted_app_folder, "scripts/install"), + env=env_dict, + operation_logger=operation_logger, + error_message_if_script_failed=m18n.n("app_install_script_failed"), + error_message_if_failed=lambda e: m18n.n( + "app_install_failed", app=app_id, error=e + ), + ) finally: # If success so far, validate that app didn't break important stuff if not install_failed: @@ -1045,6 +1015,7 @@ def app_install( env_dict_remove["YNH_APP_INSTANCE_NAME"] = app_instance_name env_dict_remove["YNH_APP_INSTANCE_NUMBER"] = str(instance_number) env_dict_remove["YNH_APP_MANIFEST_VERSION"] = manifest.get("version", "?") + env_dict_remove["YNH_APP_BASEDIR"] = extracted_app_folder # Execute remove script operation_logger_remove = OperationLogger( @@ -1116,53 +1087,6 @@ def app_install( hook_callback("post_app_install", env=env_dict) -def dump_app_log_extract_for_debugging(operation_logger): - - with open(operation_logger.log_path, "r") as f: - lines = f.readlines() - - filters = [ - r"set [+-]x$", - r"set [+-]o xtrace$", - r"local \w+$", - r"local legacy_args=.*$", - r".*Helper used in legacy mode.*", - r"args_array=.*$", - r"local -A args_array$", - r"ynh_handle_getopts_args", - r"ynh_script_progression", - ] - - filters = [re.compile(f_) for f_ in filters] - - lines_to_display = [] - for line in lines: - - if ": " not in line.strip(): - continue - - # A line typically looks like - # 2019-10-19 16:10:27,611: DEBUG - + mysql -u piwigo --password=********** -B piwigo - # And we just want the part starting by "DEBUG - " - line = line.strip().split(": ", 1)[1] - - if any(filter_.search(line) for filter_ in filters): - continue - - lines_to_display.append(line) - - if line.endswith("+ ynh_exit_properly") or " + ynh_die " in line: - break - elif len(lines_to_display) > 20: - lines_to_display.pop(0) - - logger.warning( - "Here's an extract of the logs before the crash. It might help debugging the error:" - ) - for line in lines_to_display: - logger.info(line) - - @is_unit_operation() def app_remove(operation_logger, app, purge=False): """ @@ -1209,6 +1133,8 @@ def app_remove(operation_logger, app, purge=False): env_dict["YNH_APP_INSTANCE_NUMBER"] = str(app_instance_nb) env_dict["YNH_APP_MANIFEST_VERSION"] = manifest.get("version", "?") env_dict["YNH_APP_PURGE"] = str(purge) + env_dict["YNH_APP_BASEDIR"] = tmp_workdir_for_app + operation_logger.extra.update({"env": env_dict}) operation_logger.flush() @@ -1255,7 +1181,7 @@ def app_makedefault(operation_logger, app, domain=None): domain """ - from yunohost.domain import domain_list + from yunohost.domain import _assert_domain_exists app_settings = _get_app_settings(app) app_domain = app_settings["domain"] @@ -1263,9 +1189,10 @@ def app_makedefault(operation_logger, app, domain=None): if domain is None: domain = app_domain - operation_logger.related_to.append(("domain", domain)) - elif domain not in domain_list()["domains"]: - raise YunohostValidationError("domain_name_unknown", domain=domain) + + _assert_domain_exists(domain) + + operation_logger.related_to.append(("domain", domain)) if "/" in app_map(raw=True)[domain]: raise YunohostValidationError( @@ -1631,30 +1558,41 @@ def app_action_run(operation_logger, app, action, args=None): ) args_odict = _parse_args_for_action(actions[action], args=args_dict) + tmp_workdir_for_app = _make_tmp_workdir_for_app(app=app) + env_dict = _make_environment_for_app_script( app, args=args_odict, args_prefix="ACTION_" ) env_dict["YNH_ACTION"] = action + env_dict["YNH_APP_BASEDIR"] = tmp_workdir_for_app - _, path = tempfile.mkstemp() + _, action_script = tempfile.mkstemp(dir=tmp_workdir_for_app) - with open(path, "w") as script: + with open(action_script, "w") as script: script.write(action_declaration["command"]) - os.chmod(path, 700) - if action_declaration.get("cwd"): cwd = action_declaration["cwd"].replace("$app", app) else: - cwd = os.path.join(APPS_SETTING_PATH, app) + cwd = tmp_workdir_for_app - # FIXME: this should probably be ran in a tmp workdir... - retcode = hook_exec( - path, - env=env_dict, - chdir=cwd, - user=action_declaration.get("user", "root"), - )[0] + try: + retcode = hook_exec( + action_script, + env=env_dict, + chdir=cwd, + user=action_declaration.get("user", "root"), + )[0] + # Calling hook_exec could fail miserably, or get + # manually interrupted (by mistake or because script was stuck) + # In that case we still want to delete the tmp work dir + except (KeyboardInterrupt, EOFError, Exception): + retcode = -1 + import traceback + + logger.error(m18n.n("unexpected_error", error="\n" + traceback.format_exc())) + finally: + shutil.rmtree(tmp_workdir_for_app) if retcode not in action_declaration.get("accepted_return_codes", [0]): msg = "Error while executing action '%s' of app '%s': return code %s" % ( @@ -1665,177 +1603,106 @@ def app_action_run(operation_logger, app, action, args=None): operation_logger.error(msg) raise YunohostError(msg, raw_msg=True) - os.remove(path) - operation_logger.success() return logger.success("Action successed!") -# Config panel todo list: -# * docstrings -# * merge translations on the json once the workflow is in place -@is_unit_operation() -def app_config_show_panel(operation_logger, app): - logger.warning(m18n.n("experimental_feature")) - - from yunohost.hook import hook_exec - - # this will take care of checking if the app is installed - app_info_dict = app_info(app) - - operation_logger.start() - config_panel = _get_app_config_panel(app) - config_script = os.path.join(APPS_SETTING_PATH, app, "scripts", "config") - - app_id, app_instance_nb = _parse_app_instance_name(app) - - if not config_panel or not os.path.exists(config_script): - return { - "app_id": app_id, - "app": app, - "app_name": app_info_dict["name"], - "config_panel": [], - } - - env = { - "YNH_APP_ID": app_id, - "YNH_APP_INSTANCE_NAME": app, - "YNH_APP_INSTANCE_NUMBER": str(app_instance_nb), - } - - # FIXME: this should probably be ran in a tmp workdir... - return_code, parsed_values = hook_exec( - config_script, args=["show"], env=env, return_format="plain_dict" - ) - - if return_code != 0: - raise Exception( - "script/config show return value code: %s (considered as an error)", - return_code, +def app_config_get(app, key="", full=False, export=False): + """ + Display an app configuration in classic, full or export mode + """ + if full and export: + raise YunohostValidationError( + "You can't use --full and --export together.", raw_msg=True ) - logger.debug("Generating global variables:") - for tab in config_panel.get("panel", []): - tab_id = tab["id"] # this makes things easier to debug on crash - for section in tab.get("sections", []): - section_id = section["id"] - for option in section.get("options", []): - option_name = option["name"] - generated_name = ( - "YNH_CONFIG_%s_%s_%s" % (tab_id, section_id, option_name) - ).upper() - option["name"] = generated_name - logger.debug( - " * '%s'.'%s'.'%s' -> %s", - tab.get("name"), - section.get("name"), - option.get("name"), - generated_name, + if full: + mode = "full" + elif export: + mode = "export" + else: + mode = "classic" + + config_ = AppConfigPanel(app) + return config_.get(key, mode) + + +@is_unit_operation() +def app_config_set( + operation_logger, app, key=None, value=None, args=None, args_file=None +): + """ + Apply a new app configuration + """ + + config_ = AppConfigPanel(app) + + return config_.set(key, value, args, args_file, operation_logger=operation_logger) + + +class AppConfigPanel(ConfigPanel): + def __init__(self, app): + + # Check app is installed + _assert_is_installed(app) + + self.app = app + config_path = os.path.join(APPS_SETTING_PATH, app, "config_panel.toml") + super().__init__(config_path=config_path) + + def _load_current_values(self): + self.values = self._call_config_script("show") + + def _apply(self): + env = {key: str(value) for key, value in self.new_values.items()} + return_content = self._call_config_script("apply", env=env) + + # If the script returned validation error + # raise a ValidationError exception using + # the first key + if return_content: + for key, message in return_content.get("validation_errors").items(): + raise YunohostValidationError( + "app_argument_invalid", + name=key, + error=message, ) - if generated_name in parsed_values: - # code is not adapted for that so we have to mock expected format :/ - if option.get("type") == "boolean": - if parsed_values[generated_name].lower() in ("true", "1", "y"): - option["default"] = parsed_values[generated_name] - else: - del option["default"] - else: - option["default"] = parsed_values[generated_name] + def _call_config_script(self, action, env={}): + from yunohost.hook import hook_exec - args_dict = _parse_args_in_yunohost_format( - {option["name"]: parsed_values[generated_name]}, [option] - ) - option["default"] = args_dict[option["name"]][0] - else: - logger.debug( - "Variable '%s' is not declared by config script, using default", - generated_name, - ) - # do nothing, we'll use the default if present + # Add default config script if needed + config_script = os.path.join(APPS_SETTING_PATH, self.app, "scripts", "config") + if not os.path.exists(config_script): + logger.debug("Adding a default config script") + default_script = """#!/bin/bash +source /usr/share/yunohost/helpers +ynh_abort_if_errors +ynh_app_config_run $1 +""" + write_to_file(config_script, default_script) - return { - "app_id": app_id, - "app": app, - "app_name": app_info_dict["name"], - "config_panel": config_panel, - "logs": operation_logger.success(), - } - - -@is_unit_operation() -def app_config_apply(operation_logger, app, args): - logger.warning(m18n.n("experimental_feature")) - - from yunohost.hook import hook_exec - - installed = _is_installed(app) - if not installed: - raise YunohostValidationError( - "app_not_installed", app=app, all_apps=_get_all_installed_apps_id() + # Call config script to extract current values + logger.debug(f"Calling '{action}' action from config script") + app_id, app_instance_nb = _parse_app_instance_name(self.app) + settings = _get_app_settings(app_id) + env.update( + { + "app_id": app_id, + "app": self.app, + "app_instance_nb": str(app_instance_nb), + "final_path": settings.get("final_path", ""), + "YNH_APP_BASEDIR": os.path.join(APPS_SETTING_PATH, self.app), + } ) - config_panel = _get_app_config_panel(app) - config_script = os.path.join(APPS_SETTING_PATH, app, "scripts", "config") - - if not config_panel or not os.path.exists(config_script): - # XXX real exception - raise Exception("Not config-panel.json nor scripts/config") - - operation_logger.start() - app_id, app_instance_nb = _parse_app_instance_name(app) - env = { - "YNH_APP_ID": app_id, - "YNH_APP_INSTANCE_NAME": app, - "YNH_APP_INSTANCE_NUMBER": str(app_instance_nb), - } - args = dict(urllib.parse.parse_qsl(args, keep_blank_values=True)) if args else {} - - for tab in config_panel.get("panel", []): - tab_id = tab["id"] # this makes things easier to debug on crash - for section in tab.get("sections", []): - section_id = section["id"] - for option in section.get("options", []): - option_name = option["name"] - generated_name = ( - "YNH_CONFIG_%s_%s_%s" % (tab_id, section_id, option_name) - ).upper() - - if generated_name in args: - logger.debug( - "include into env %s=%s", generated_name, args[generated_name] - ) - env[generated_name] = args[generated_name] - else: - logger.debug("no value for key id %s", generated_name) - - # for debug purpose - for key in args: - if key not in env: - logger.warning( - "Ignore key '%s' from arguments because it is not in the config", key - ) - - # FIXME: this should probably be ran in a tmp workdir... - return_code = hook_exec( - config_script, - args=["apply"], - env=env, - )[0] - - if return_code != 0: - msg = ( - "'script/config apply' return value code: %s (considered as an error)" - % return_code - ) - operation_logger.error(msg) - raise Exception(msg) - - logger.success("Config updated as expected") - return { - "app": app, - "logs": operation_logger.success(), - } + ret, values = hook_exec(config_script, args=[action], env=env) + if ret != 0: + if action == "show": + raise YunohostError("app_config_unable_to_read") + else: + raise YunohostError("app_config_unable_to_apply") + return values def _get_all_installed_apps_id(): @@ -1939,145 +1806,6 @@ def _get_app_actions(app_id): return None -def _get_app_config_panel(app_id): - "Get app config panel stored in json or in toml" - config_panel_toml_path = os.path.join( - APPS_SETTING_PATH, app_id, "config_panel.toml" - ) - config_panel_json_path = os.path.join( - APPS_SETTING_PATH, app_id, "config_panel.json" - ) - - # sample data to get an idea of what is going on - # this toml extract: - # - # version = "0.1" - # name = "Unattended-upgrades configuration panel" - # - # [main] - # name = "Unattended-upgrades configuration" - # - # [main.unattended_configuration] - # name = "50unattended-upgrades configuration file" - # - # [main.unattended_configuration.upgrade_level] - # name = "Choose the sources of packages to automatically upgrade." - # default = "Security only" - # type = "text" - # help = "We can't use a choices field for now. In the meantime please choose between one of this values:
Security only, Security and updates." - # # choices = ["Security only", "Security and updates"] - - # [main.unattended_configuration.ynh_update] - # name = "Would you like to update YunoHost packages automatically ?" - # type = "bool" - # default = true - # - # will be parsed into this: - # - # OrderedDict([(u'version', u'0.1'), - # (u'name', u'Unattended-upgrades configuration panel'), - # (u'main', - # OrderedDict([(u'name', u'Unattended-upgrades configuration'), - # (u'unattended_configuration', - # OrderedDict([(u'name', - # u'50unattended-upgrades configuration file'), - # (u'upgrade_level', - # OrderedDict([(u'name', - # u'Choose the sources of packages to automatically upgrade.'), - # (u'default', - # u'Security only'), - # (u'type', u'text'), - # (u'help', - # u"We can't use a choices field for now. In the meantime please choose between one of this values:
Security only, Security and updates.")])), - # (u'ynh_update', - # OrderedDict([(u'name', - # u'Would you like to update YunoHost packages automatically ?'), - # (u'type', u'bool'), - # (u'default', True)])), - # - # and needs to be converted into this: - # - # {u'name': u'Unattended-upgrades configuration panel', - # u'panel': [{u'id': u'main', - # u'name': u'Unattended-upgrades configuration', - # u'sections': [{u'id': u'unattended_configuration', - # u'name': u'50unattended-upgrades configuration file', - # u'options': [{u'//': u'"choices" : ["Security only", "Security and updates"]', - # u'default': u'Security only', - # u'help': u"We can't use a choices field for now. In the meantime please choose between one of this values:
Security only, Security and updates.", - # u'id': u'upgrade_level', - # u'name': u'Choose the sources of packages to automatically upgrade.', - # u'type': u'text'}, - # {u'default': True, - # u'id': u'ynh_update', - # u'name': u'Would you like to update YunoHost packages automatically ?', - # u'type': u'bool'}, - - if os.path.exists(config_panel_toml_path): - toml_config_panel = toml.load( - open(config_panel_toml_path, "r"), _dict=OrderedDict - ) - - # transform toml format into json format - config_panel = { - "name": toml_config_panel["name"], - "version": toml_config_panel["version"], - "panel": [], - } - - panels = [ - key_value - for key_value in toml_config_panel.items() - if key_value[0] not in ("name", "version") - and isinstance(key_value[1], OrderedDict) - ] - - for key, value in panels: - panel = { - "id": key, - "name": value["name"], - "sections": [], - } - - sections = [ - k_v1 - for k_v1 in value.items() - if k_v1[0] not in ("name",) and isinstance(k_v1[1], OrderedDict) - ] - - for section_key, section_value in sections: - section = { - "id": section_key, - "name": section_value["name"], - "options": [], - } - - options = [ - k_v - for k_v in section_value.items() - if k_v[0] not in ("name",) and isinstance(k_v[1], OrderedDict) - ] - - for option_key, option_value in options: - option = dict(option_value) - option["name"] = option_key - option["ask"] = {"en": option["ask"]} - if "help" in option: - option["help"] = {"en": option["help"]} - section["options"].append(option) - - panel["sections"].append(section) - - config_panel["panel"].append(panel) - - return config_panel - - elif os.path.exists(config_panel_json_path): - return json.load(open(config_panel_json_path)) - - return None - - def _get_app_settings(app_id): """ Get settings of an installed app @@ -2365,6 +2093,13 @@ def _set_default_ask_questions(arguments): key = "app_manifest_%s_ask_%s" % (script_name, arg["name"]) arg["ask"] = m18n.n(key) + # Also it in fact doesn't make sense for any of those questions to have an example value nor a default value... + if arg.get("type") in ["domain", "user", "password"]: + if "example" in arg: + del arg["example"] + if "default" in arg: + del arg["domain"] + return arguments @@ -2518,34 +2253,17 @@ def _is_installed(app): return os.path.isdir(APPS_SETTING_PATH + app) +def _assert_is_installed(app): + if not _is_installed(app): + raise YunohostValidationError( + "app_not_installed", app=app, all_apps=_get_all_installed_apps_id() + ) + + def _installed_apps(): return os.listdir(APPS_SETTING_PATH) -def _value_for_locale(values): - """ - Return proper value for current locale - - Keyword arguments: - values -- A dict of values associated to their locale - - Returns: - An utf-8 encoded string - - """ - if not isinstance(values, dict): - return values - - for lang in [m18n.locale, m18n.default_locale]: - try: - return values[lang] - except KeyError: - continue - - # Fallback to first value - return list(values.values())[0] - - def _check_manifest_requirements(manifest, app_instance_name): """Check if required packages are met from the manifest""" @@ -2592,7 +2310,7 @@ def _parse_args_from_manifest(manifest, action, args={}): return OrderedDict() action_args = manifest["arguments"][action] - return _parse_args_in_yunohost_format(args, action_args) + return parse_args_in_yunohost_format(args, action_args) def _parse_args_for_action(action, args={}): @@ -2616,298 +2334,7 @@ def _parse_args_for_action(action, args={}): action_args = action["arguments"] - return _parse_args_in_yunohost_format(args, action_args) - - -class Question: - "empty class to store questions information" - - -class YunoHostArgumentFormatParser(object): - hide_user_input_in_prompt = False - - def parse_question(self, question, user_answers): - parsed_question = Question() - - parsed_question.name = question["name"] - parsed_question.default = question.get("default", None) - parsed_question.choices = question.get("choices", []) - parsed_question.optional = question.get("optional", False) - parsed_question.ask = question.get("ask") - parsed_question.value = user_answers.get(parsed_question.name) - - if parsed_question.ask is None: - parsed_question.ask = "Enter value for '%s':" % parsed_question.name - - # Empty value is parsed as empty string - if parsed_question.default == "": - parsed_question.default = None - - return parsed_question - - def parse(self, question, user_answers): - question = self.parse_question(question, user_answers) - - if question.value is None: - text_for_user_input_in_cli = self._format_text_for_user_input_in_cli( - question - ) - - try: - question.value = Moulinette.prompt( - text_for_user_input_in_cli, self.hide_user_input_in_prompt - ) - except NotImplementedError: - question.value = None - - # we don't have an answer, check optional and default_value - if question.value is None or question.value == "": - if not question.optional and question.default is None: - raise YunohostValidationError( - "app_argument_required", name=question.name - ) - else: - question.value = ( - getattr(self, "default_value", None) - if question.default is None - else question.default - ) - - # we have an answer, do some post checks - if question.value is not None: - if question.choices and question.value not in question.choices: - self._raise_invalid_answer(question) - - # this is done to enforce a certain formating like for boolean - # by default it doesn't do anything - question.value = self._post_parse_value(question) - - return (question.value, self.argument_type) - - def _raise_invalid_answer(self, question): - raise YunohostValidationError( - "app_argument_choice_invalid", - name=question.name, - choices=", ".join(question.choices), - ) - - def _format_text_for_user_input_in_cli(self, question): - text_for_user_input_in_cli = _value_for_locale(question.ask) - - if question.choices: - text_for_user_input_in_cli += " [{0}]".format(" | ".join(question.choices)) - - if question.default is not None: - text_for_user_input_in_cli += " (default: {0})".format(question.default) - - return text_for_user_input_in_cli - - def _post_parse_value(self, question): - return question.value - - -class StringArgumentParser(YunoHostArgumentFormatParser): - argument_type = "string" - default_value = "" - - -class PasswordArgumentParser(YunoHostArgumentFormatParser): - hide_user_input_in_prompt = True - argument_type = "password" - default_value = "" - forbidden_chars = "{}" - - def parse_question(self, question, user_answers): - question = super(PasswordArgumentParser, self).parse_question( - question, user_answers - ) - - if question.default is not None: - raise YunohostValidationError( - "app_argument_password_no_default", name=question.name - ) - - return question - - def _post_parse_value(self, question): - if any(char in question.value for char in self.forbidden_chars): - raise YunohostValidationError( - "pattern_password_app", forbidden_chars=self.forbidden_chars - ) - - # If it's an optional argument the value should be empty or strong enough - if not question.optional or question.value: - from yunohost.utils.password import assert_password_is_strong_enough - - assert_password_is_strong_enough("user", question.value) - - return super(PasswordArgumentParser, self)._post_parse_value(question) - - -class PathArgumentParser(YunoHostArgumentFormatParser): - argument_type = "path" - default_value = "" - - -class BooleanArgumentParser(YunoHostArgumentFormatParser): - argument_type = "boolean" - default_value = False - - def parse_question(self, question, user_answers): - question = super(BooleanArgumentParser, self).parse_question( - question, user_answers - ) - - if question.default is None: - question.default = False - - return question - - def _format_text_for_user_input_in_cli(self, question): - text_for_user_input_in_cli = _value_for_locale(question.ask) - - text_for_user_input_in_cli += " [yes | no]" - - if question.default is not None: - formatted_default = "yes" if question.default else "no" - text_for_user_input_in_cli += " (default: {0})".format(formatted_default) - - return text_for_user_input_in_cli - - def _post_parse_value(self, question): - if isinstance(question.value, bool): - return 1 if question.value else 0 - - if str(question.value).lower() in ["1", "yes", "y", "true"]: - return 1 - - if str(question.value).lower() in ["0", "no", "n", "false"]: - return 0 - - raise YunohostValidationError( - "app_argument_choice_invalid", - name=question.name, - choices="yes, no, y, n, 1, 0", - ) - - -class DomainArgumentParser(YunoHostArgumentFormatParser): - argument_type = "domain" - - def parse_question(self, question, user_answers): - from yunohost.domain import domain_list, _get_maindomain - - question = super(DomainArgumentParser, self).parse_question( - question, user_answers - ) - - if question.default is None: - question.default = _get_maindomain() - - question.choices = domain_list()["domains"] - - return question - - def _raise_invalid_answer(self, question): - raise YunohostValidationError( - "app_argument_invalid", name=question.name, error=m18n.n("domain_unknown") - ) - - -class UserArgumentParser(YunoHostArgumentFormatParser): - argument_type = "user" - - def parse_question(self, question, user_answers): - from yunohost.user import user_list, user_info - from yunohost.domain import _get_maindomain - - question = super(UserArgumentParser, self).parse_question( - question, user_answers - ) - question.choices = user_list()["users"] - if question.default is None: - root_mail = "root@%s" % _get_maindomain() - for user in question.choices.keys(): - if root_mail in user_info(user).get("mail-aliases", []): - question.default = user - break - - return question - - def _raise_invalid_answer(self, question): - raise YunohostValidationError( - "app_argument_invalid", - name=question.name, - error=m18n.n("user_unknown", user=question.value), - ) - - -class NumberArgumentParser(YunoHostArgumentFormatParser): - argument_type = "number" - default_value = "" - - def parse_question(self, question, user_answers): - question = super(NumberArgumentParser, self).parse_question( - question, user_answers - ) - - if question.default is None: - question.default = 0 - - return question - - def _post_parse_value(self, question): - if isinstance(question.value, int): - return super(NumberArgumentParser, self)._post_parse_value(question) - - if isinstance(question.value, str) and question.value.isdigit(): - return int(question.value) - - raise YunohostValidationError( - "app_argument_invalid", name=question.name, error=m18n.n("invalid_number") - ) - - -class DisplayTextArgumentParser(YunoHostArgumentFormatParser): - argument_type = "display_text" - - def parse(self, question, user_answers): - print(question["ask"]) - - -ARGUMENTS_TYPE_PARSERS = { - "string": StringArgumentParser, - "password": PasswordArgumentParser, - "path": PathArgumentParser, - "boolean": BooleanArgumentParser, - "domain": DomainArgumentParser, - "user": UserArgumentParser, - "number": NumberArgumentParser, - "display_text": DisplayTextArgumentParser, -} - - -def _parse_args_in_yunohost_format(user_answers, argument_questions): - """Parse arguments store in either manifest.json or actions.json or from a - config panel against the user answers when they are present. - - Keyword arguments: - user_answers -- a dictionnary of arguments from the user (generally - empty in CLI, filed from the admin interface) - argument_questions -- the arguments description store in yunohost - format from actions.json/toml, manifest.json/toml - or config_panel.json/toml - """ - parsed_answers_dict = OrderedDict() - - for question in argument_questions: - parser = ARGUMENTS_TYPE_PARSERS[question.get("type", "string")]() - - answer = parser.parse(question=question, user_answers=user_answers) - if answer is not None: - parsed_answers_dict[question["name"]] = answer - - return parsed_answers_dict + return parse_args_in_yunohost_format(args, action_args) def _validate_and_normalize_webpath(args_dict, app_folder): @@ -2988,13 +2415,12 @@ def _get_conflicting_apps(domain, path, ignore_app=None): ignore_app -- An optional app id to ignore (c.f. the change_url usecase) """ - from yunohost.domain import domain_list + from yunohost.domain import _assert_domain_exists domain, path = _normalize_domain_path(domain, path) # Abort if domain is unknown - if domain not in domain_list()["domains"]: - raise YunohostValidationError("domain_name_unknown", domain=domain) + _assert_domain_exists(domain) # Fetch apps map apps_map = app_map(raw=True) @@ -3331,6 +2757,8 @@ def unstable_apps(): def _assert_system_is_sane_for_app(manifest, when): + from yunohost.service import service_status + logger.debug("Checking that required services are up and running...") services = manifest.get("services", []) diff --git a/src/yunohost/backup.py b/src/yunohost/backup.py index 12ec405e9..dc5ddbc83 100644 --- a/src/yunohost/backup.py +++ b/src/yunohost/backup.py @@ -44,11 +44,11 @@ from moulinette.utils.log import getActionLogger from moulinette.utils.filesystem import read_file, mkdir, write_to_yaml, read_yaml from moulinette.utils.process import check_output +import yunohost.domain from yunohost.app import ( app_info, _is_installed, _make_environment_for_app_script, - dump_app_log_extract_for_debugging, _patch_legacy_helpers, _patch_legacy_php_versions, _patch_legacy_php_versions_in_settings, @@ -60,6 +60,7 @@ from yunohost.hook import ( hook_info, hook_callback, hook_exec, + hook_exec_with_script_debug_if_failure, CUSTOM_HOOK_FOLDER, ) from yunohost.tools import ( @@ -707,6 +708,9 @@ class BackupManager: # Prepare environment env_dict = self._get_env_var(app) + env_dict["YNH_APP_BASEDIR"] = os.path.join( + self.work_dir, "apps", app, "settings" + ) tmp_app_bkp_dir = env_dict["YNH_APP_BACKUP_DIR"] settings_dir = os.path.join(self.work_dir, "apps", app, "settings") @@ -1287,6 +1291,8 @@ class RestoreManager: else: operation_logger.success() + yunohost.domain.domain_list_cache = {} + regen_conf() _tools_migrations_run_after_system_restore( @@ -1491,6 +1497,9 @@ class RestoreManager: "YNH_APP_BACKUP_DIR": os.path.join( self.work_dir, "apps", app_instance_name, "backup" ), + "YNH_APP_BASEDIR": os.path.join( + self.work_dir, "apps", app_instance_name, "settings" + ), } ) @@ -1500,37 +1509,19 @@ class RestoreManager: # Execute the app install script restore_failed = True try: - restore_retcode = hook_exec( + ( + restore_failed, + failure_message_with_debug_instructions, + ) = hook_exec_with_script_debug_if_failure( restore_script, chdir=app_backup_in_archive, env=env_dict, - )[0] - # "Common" app restore failure : the script failed and returned exit code != 0 - restore_failed = True if restore_retcode != 0 else False - if restore_failed: - error = m18n.n("app_restore_script_failed") - logger.error( - m18n.n("app_restore_failed", app=app_instance_name, error=error) - ) - failure_message_with_debug_instructions = operation_logger.error(error) - if Moulinette.interface.type != "api": - dump_app_log_extract_for_debugging(operation_logger) - # Script got manually interrupted ... N.B. : KeyboardInterrupt does not inherit from Exception - except (KeyboardInterrupt, EOFError): - error = m18n.n("operation_interrupted") - logger.error( - m18n.n("app_restore_failed", app=app_instance_name, error=error) + operation_logger=operation_logger, + error_message_if_script_failed=m18n.n("app_restore_script_failed"), + error_message_if_failed=lambda e: m18n.n( + "app_restore_failed", app=app_instance_name, error=e + ), ) - failure_message_with_debug_instructions = operation_logger.error(error) - # Something wrong happened in Yunohost's code (most probably hook_exec) - except Exception: - import traceback - - error = m18n.n("unexpected_error", error="\n" + traceback.format_exc()) - logger.error( - m18n.n("app_restore_failed", app=app_instance_name, error=error) - ) - failure_message_with_debug_instructions = operation_logger.error(error) finally: # Cleaning temporary scripts directory shutil.rmtree(tmp_workdir_for_app, ignore_errors=True) @@ -1546,6 +1537,9 @@ class RestoreManager: # Setup environment for remove script env_dict_remove = _make_environment_for_app_script(app_instance_name) + env_dict_remove["YNH_APP_BASEDIR"] = os.path.join( + self.work_dir, "apps", app_instance_name, "settings" + ) remove_operation_logger = OperationLogger( "remove_on_failed_restore", diff --git a/src/yunohost/certificate.py b/src/yunohost/certificate.py index 52d58777b..817f9d57a 100644 --- a/src/yunohost/certificate.py +++ b/src/yunohost/certificate.py @@ -86,11 +86,8 @@ def certificate_status(domain_list, full=False): domain_list = yunohost.domain.domain_list()["domains"] # Else, validate that yunohost knows the domains given else: - yunohost_domains_list = yunohost.domain.domain_list()["domains"] for domain in domain_list: - # Is it in Yunohost domain list? - if domain not in yunohost_domains_list: - raise YunohostValidationError("domain_name_unknown", domain=domain) + yunohost.domain._assert_domain_exists(domain) certificates = {} @@ -267,9 +264,7 @@ def _certificate_install_letsencrypt( # Else, validate that yunohost knows the domains given else: for domain in domain_list: - yunohost_domains_list = yunohost.domain.domain_list()["domains"] - if domain not in yunohost_domains_list: - raise YunohostValidationError("domain_name_unknown", domain=domain) + yunohost.domain._assert_domain_exists(domain) # Is it self-signed? status = _get_status(domain) @@ -368,9 +363,8 @@ def certificate_renew( else: for domain in domain_list: - # Is it in Yunohost dmomain list? - if domain not in yunohost.domain.domain_list()["domains"]: - raise YunohostValidationError("domain_name_unknown", domain=domain) + # Is it in Yunohost domain list? + yunohost.domain._assert_domain_exists(domain) status = _get_status(domain) diff --git a/src/yunohost/data_migrations/0021_migrate_to_bullseye.py b/src/yunohost/data_migrations/0021_migrate_to_bullseye.py index 6678fa040..4b40fcbe0 100644 --- a/src/yunohost/data_migrations/0021_migrate_to_bullseye.py +++ b/src/yunohost/data_migrations/0021_migrate_to_bullseye.py @@ -36,6 +36,13 @@ class MyMigration(Migration): logger.info(m18n.n("migration_0021_start")) + # + # Add new apt .deb signing key + # + + new_apt_key = "https://forge.yunohost.org/yunohost_bullseye.asc" + check_output(f"wget -O- {new_apt_key} -q | apt-key add -qq -") + # # Patch sources.list # diff --git a/src/yunohost/dns.py b/src/yunohost/dns.py new file mode 100644 index 000000000..0581fa82c --- /dev/null +++ b/src/yunohost/dns.py @@ -0,0 +1,1002 @@ +# -*- coding: utf-8 -*- + +""" License + + Copyright (C) 2013 YunoHost + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program; if not, see http://www.gnu.org/licenses + +""" + +""" yunohost_domain.py + + Manage domains +""" +import os +import re +import time + +from difflib import SequenceMatcher +from collections import OrderedDict + +from moulinette import m18n, Moulinette +from moulinette.utils.log import getActionLogger +from moulinette.utils.filesystem import read_file, write_to_file, read_toml + +from yunohost.domain import ( + domain_list, + _assert_domain_exists, + domain_config_get, + _get_domain_settings, + _set_domain_settings, +) +from yunohost.utils.dns import dig, YNH_DYNDNS_DOMAINS +from yunohost.utils.error import YunohostValidationError, YunohostError +from yunohost.utils.network import get_public_ip +from yunohost.log import is_unit_operation +from yunohost.hook import hook_callback + +logger = getActionLogger("yunohost.domain") + +DOMAIN_REGISTRAR_LIST_PATH = "/usr/share/yunohost/other/registrar_list.toml" + + +def domain_dns_suggest(domain): + """ + Generate DNS configuration for a domain + + Keyword argument: + domain -- Domain name + + """ + + _assert_domain_exists(domain) + + dns_conf = _build_dns_conf(domain) + + result = "" + + if dns_conf["basic"]: + result += "; Basic ipv4/ipv6 records" + for record in dns_conf["basic"]: + result += "\n{name} {ttl} IN {type} {value}".format(**record) + + if dns_conf["mail"]: + result += "\n\n" + result += "; Mail" + for record in dns_conf["mail"]: + result += "\n{name} {ttl} IN {type} {value}".format(**record) + result += "\n\n" + + if dns_conf["xmpp"]: + result += "\n\n" + result += "; XMPP" + for record in dns_conf["xmpp"]: + result += "\n{name} {ttl} IN {type} {value}".format(**record) + + if dns_conf["extra"]: + result += "; Extra" + for record in dns_conf["extra"]: + result += "\n{name} {ttl} IN {type} {value}".format(**record) + + for name, record_list in dns_conf.items(): + if name not in ("basic", "xmpp", "mail", "extra") and record_list: + result += "\n\n" + result += "; " + name + for record in record_list: + result += "\n{name} {ttl} IN {type} {value}".format(**record) + + if Moulinette.interface.type == "cli": + # FIXME Update this to point to our "dns push" doc + logger.info(m18n.n("domain_dns_conf_is_just_a_recommendation")) + + return result + + +def _list_subdomains_of(parent_domain): + + _assert_domain_exists(parent_domain) + + out = [] + for domain in domain_list()["domains"]: + if domain.endswith(f".{parent_domain}"): + out.append(domain) + + return out + + +def _build_dns_conf(base_domain, include_empty_AAAA_if_no_ipv6=False): + """ + Internal function that will returns a data structure containing the needed + information to generate/adapt the dns configuration + + Arguments: + domains -- List of a domain and its subdomains + + The returned datastructure will have the following form: + { + "basic": [ + # if ipv4 available + {"type": "A", "name": "@", "value": "123.123.123.123", "ttl": 3600}, + # if ipv6 available + {"type": "AAAA", "name": "@", "value": "valid-ipv6", "ttl": 3600}, + ], + "xmpp": [ + {"type": "SRV", "name": "_xmpp-client._tcp", "value": "0 5 5222 domain.tld.", "ttl": 3600}, + {"type": "SRV", "name": "_xmpp-server._tcp", "value": "0 5 5269 domain.tld.", "ttl": 3600}, + {"type": "CNAME", "name": "muc", "value": "@", "ttl": 3600}, + {"type": "CNAME", "name": "pubsub", "value": "@", "ttl": 3600}, + {"type": "CNAME", "name": "vjud", "value": "@", "ttl": 3600} + {"type": "CNAME", "name": "xmpp-upload", "value": "@", "ttl": 3600} + ], + "mail": [ + {"type": "MX", "name": "@", "value": "10 domain.tld.", "ttl": 3600}, + {"type": "TXT", "name": "@", "value": "\"v=spf1 a mx ip4:123.123.123.123 ipv6:valid-ipv6 -all\"", "ttl": 3600 }, + {"type": "TXT", "name": "mail._domainkey", "value": "\"v=DKIM1; k=rsa; p=some-super-long-key\"", "ttl": 3600}, + {"type": "TXT", "name": "_dmarc", "value": "\"v=DMARC1; p=none\"", "ttl": 3600} + ], + "extra": [ + # if ipv4 available + {"type": "A", "name": "*", "value": "123.123.123.123", "ttl": 3600}, + # if ipv6 available + {"type": "AAAA", "name": "*", "value": "valid-ipv6", "ttl": 3600}, + {"type": "CAA", "name": "@", "value": "128 issue \"letsencrypt.org\"", "ttl": 3600}, + ], + "example_of_a_custom_rule": [ + {"type": "SRV", "name": "_matrix", "value": "domain.tld.", "ttl": 3600} + ], + } + """ + + basic = [] + mail = [] + xmpp = [] + extra = [] + ipv4 = get_public_ip() + ipv6 = get_public_ip(6) + + # If this is a ynh_dyndns_domain, we're not gonna include all the subdomains in the conf + # Because dynette only accept a specific list of name/type + # And the wildcard */A already covers the bulk of use cases + if any( + base_domain.endswith("." + ynh_dyndns_domain) + for ynh_dyndns_domain in YNH_DYNDNS_DOMAINS + ): + subdomains = [] + else: + subdomains = _list_subdomains_of(base_domain) + + domains_settings = { + domain: domain_config_get(domain, export=True) + for domain in [base_domain] + subdomains + } + + base_dns_zone = _get_dns_zone_for_domain(base_domain) + + for domain, settings in domains_settings.items(): + + # Domain # Base DNS zone # Basename # Suffix # + # ------------------ # ----------------- # --------- # -------- # + # domain.tld # domain.tld # @ # # + # sub.domain.tld # domain.tld # sub # .sub # + # foo.sub.domain.tld # domain.tld # foo.sub # .foo.sub # + # sub.domain.tld # sub.domain.tld # @ # # + # foo.sub.domain.tld # sub.domain.tld # foo # .foo # + + basename = domain.replace(base_dns_zone, "").rstrip(".") or "@" + suffix = f".{basename}" if basename != "@" else "" + + # ttl = settings["ttl"] + ttl = 3600 + + ########################### + # Basic ipv4/ipv6 records # + ########################### + if ipv4: + basic.append([basename, ttl, "A", ipv4]) + + if ipv6: + basic.append([basename, ttl, "AAAA", ipv6]) + elif include_empty_AAAA_if_no_ipv6: + basic.append([basename, ttl, "AAAA", None]) + + ######### + # Email # + ######### + if settings["mail_in"]: + mail.append([basename, ttl, "MX", f"10 {domain}."]) + + if settings["mail_out"]: + mail.append([basename, ttl, "TXT", '"v=spf1 a mx -all"']) + + # DKIM/DMARC record + dkim_host, dkim_publickey = _get_DKIM(domain) + + if dkim_host: + mail += [ + [f"{dkim_host}{suffix}", ttl, "TXT", dkim_publickey], + [f"_dmarc{suffix}", ttl, "TXT", '"v=DMARC1; p=none"'], + ] + + ######## + # XMPP # + ######## + if settings["xmpp"]: + xmpp += [ + [ + f"_xmpp-client._tcp{suffix}", + ttl, + "SRV", + f"0 5 5222 {domain}.", + ], + [ + f"_xmpp-server._tcp{suffix}", + ttl, + "SRV", + f"0 5 5269 {domain}.", + ], + [f"muc{suffix}", ttl, "CNAME", basename], + [f"pubsub{suffix}", ttl, "CNAME", basename], + [f"vjud{suffix}", ttl, "CNAME", basename], + [f"xmpp-upload{suffix}", ttl, "CNAME", basename], + ] + + ######### + # Extra # + ######### + + # Only recommend wildcard and CAA for the top level + if domain == base_domain: + if ipv4: + extra.append([f"*{suffix}", ttl, "A", ipv4]) + + if ipv6: + extra.append([f"*{suffix}", ttl, "AAAA", ipv6]) + elif include_empty_AAAA_if_no_ipv6: + extra.append([f"*{suffix}", ttl, "AAAA", None]) + + extra.append([basename, ttl, "CAA", '128 issue "letsencrypt.org"']) + + #################### + # Standard records # + #################### + + records = { + "basic": [ + {"name": name, "ttl": ttl_, "type": type_, "value": value} + for name, ttl_, type_, value in basic + ], + "xmpp": [ + {"name": name, "ttl": ttl_, "type": type_, "value": value} + for name, ttl_, type_, value in xmpp + ], + "mail": [ + {"name": name, "ttl": ttl_, "type": type_, "value": value} + for name, ttl_, type_, value in mail + ], + "extra": [ + {"name": name, "ttl": ttl_, "type": type_, "value": value} + for name, ttl_, type_, value in extra + ], + } + + ################## + # Custom records # + ################## + + # Defined by custom hooks ships in apps for example ... + + hook_results = hook_callback("custom_dns_rules", args=[base_domain]) + for hook_name, results in hook_results.items(): + # + # There can be multiple results per hook name, so results look like + # {'/some/path/to/hook1': + # { 'state': 'succeed', + # 'stdreturn': [{'type': 'SRV', + # 'name': 'stuff.foo.bar.', + # 'value': 'yoloswag', + # 'ttl': 3600}] + # }, + # '/some/path/to/hook2': + # { ... }, + # [...] + # + # Loop over the sub-results + custom_records = [ + v["stdreturn"] for v in results.values() if v and v["stdreturn"] + ] + + records[hook_name] = [] + for record_list in custom_records: + # Check that record_list is indeed a list of dict + # with the required keys + if ( + not isinstance(record_list, list) + or any(not isinstance(record, dict) for record in record_list) + or any( + key not in record + for record in record_list + for key in ["name", "ttl", "type", "value"] + ) + ): + # Display an error, mainly for app packagers trying to implement a hook + logger.warning( + "Ignored custom record from hook '%s' because the data is not a *list* of dict with keys name, ttl, type and value. Raw data : %s" + % (hook_name, record_list) + ) + continue + + records[hook_name].extend(record_list) + + return records + + +def _get_DKIM(domain): + DKIM_file = "/etc/dkim/{domain}.mail.txt".format(domain=domain) + + if not os.path.isfile(DKIM_file): + return (None, None) + + with open(DKIM_file) as f: + dkim_content = f.read() + + # Gotta manage two formats : + # + # Legacy + # ----- + # + # mail._domainkey IN TXT ( "v=DKIM1; k=rsa; " + # "p=" ) + # + # New + # ------ + # + # mail._domainkey IN TXT ( "v=DKIM1; h=sha256; k=rsa; " + # "p=" ) + + is_legacy_format = " h=sha256; " not in dkim_content + + # Legacy DKIM format + if is_legacy_format: + dkim = re.match( + ( + r"^(?P[a-z_\-\.]+)[\s]+([0-9]+[\s]+)?IN[\s]+TXT[\s]+" + r'[^"]*"v=(?P[^";]+);' + r'[\s"]*k=(?P[^";]+);' + r'[\s"]*p=(?P

[^";]+)' + ), + dkim_content, + re.M | re.S, + ) + else: + dkim = re.match( + ( + r"^(?P[a-z_\-\.]+)[\s]+([0-9]+[\s]+)?IN[\s]+TXT[\s]+" + r'[^"]*"v=(?P[^";]+);' + r'[\s"]*h=(?P[^";]+);' + r'[\s"]*k=(?P[^";]+);' + r'[\s"]*p=(?P

[^";]+)' + ), + dkim_content, + re.M | re.S, + ) + + if not dkim: + return (None, None) + + if is_legacy_format: + return ( + dkim.group("host"), + '"v={v}; k={k}; p={p}"'.format( + v=dkim.group("v"), k=dkim.group("k"), p=dkim.group("p") + ), + ) + else: + return ( + dkim.group("host"), + '"v={v}; h={h}; k={k}; p={p}"'.format( + v=dkim.group("v"), + h=dkim.group("h"), + k=dkim.group("k"), + p=dkim.group("p"), + ), + ) + + +def _get_dns_zone_for_domain(domain): + """ + Get the DNS zone of a domain + + Keyword arguments: + domain -- The domain name + + """ + + # First, check if domain is a nohost.me / noho.st / ynh.fr + # This is mainly meant to speed up things for "dyndns update" + # ... otherwise we end up constantly doing a bunch of dig requests + for ynh_dyndns_domain in YNH_DYNDNS_DOMAINS: + if domain.endswith("." + ynh_dyndns_domain): + return ynh_dyndns_domain + + # Check cache + cache_folder = "/var/cache/yunohost/dns_zones" + cache_file = f"{cache_folder}/{domain}" + cache_duration = 3600 # one hour + if ( + os.path.exists(cache_file) + and abs(os.path.getctime(cache_file) - time.time()) < cache_duration + ): + dns_zone = read_file(cache_file).strip() + if dns_zone: + return dns_zone + + # Check cache for parent domain + # This is another strick to try to prevent this function from being + # a bottleneck on system with 1 main domain + 10ish subdomains + # when building the dns conf for the main domain (which will call domain_config_get, etc...) + parent_domain = domain.split(".", 1)[1] + if parent_domain in domain_list()["domains"]: + parent_cache_file = f"{cache_folder}/{parent_domain}" + if ( + os.path.exists(parent_cache_file) + and abs(os.path.getctime(parent_cache_file) - time.time()) < cache_duration + ): + dns_zone = read_file(parent_cache_file).strip() + if dns_zone: + return dns_zone + + # For foo.bar.baz.gni we want to scan all the parent domains + # (including the domain itself) + # foo.bar.baz.gni + # bar.baz.gni + # baz.gni + # gni + # Until we find the first one that has a NS record + parent_list = [domain.split(".", i)[-1] for i, _ in enumerate(domain.split("."))] + + for parent in parent_list: + + # Check if there's a NS record for that domain + answer = dig(parent, rdtype="NS", full_answers=True, resolvers="force_external") + if answer[0] == "ok": + os.system(f"mkdir -p {cache_folder}") + write_to_file(cache_file, parent) + return parent + + if len(parent_list) >= 2: + zone = parent_list[-2] + else: + zone = parent_list[-1] + + logger.warning( + f"Could not identify the dns zone for domain {domain}, returning {zone}" + ) + return zone + + +def _get_registrar_config_section(domain): + + from lexicon.providers.auto import _relevant_provider_for_domain + + registrar_infos = {} + + dns_zone = _get_dns_zone_for_domain(domain) + + # If parent domain exists in yunohost + parent_domain = domain.split(".", 1)[1] + if parent_domain in domain_list()["domains"]: + + # Dirty hack to have a link on the webadmin + if Moulinette.interface.type == "api": + parent_domain_link = f"[{parent_domain}](#/domains/{parent_domain}/config)" + else: + parent_domain_link = parent_domain + + registrar_infos["registrar"] = OrderedDict( + { + "type": "alert", + "style": "info", + "ask": m18n.n( + "domain_dns_registrar_managed_in_parent_domain", + parent_domain=domain, + parent_domain_link=parent_domain_link, + ), + "value": "parent_domain", + } + ) + return OrderedDict(registrar_infos) + + # TODO big project, integrate yunohost's dynette as a registrar-like provider + # TODO big project, integrate other dyndns providers such as netlib.re, or cf the list of dyndns providers supported by cloudron... + if dns_zone in YNH_DYNDNS_DOMAINS: + registrar_infos["registrar"] = OrderedDict( + { + "type": "alert", + "style": "success", + "ask": m18n.n("domain_dns_registrar_yunohost"), + "value": "yunohost", + } + ) + return OrderedDict(registrar_infos) + + try: + registrar = _relevant_provider_for_domain(dns_zone)[0] + except ValueError: + registrar_infos["registrar"] = OrderedDict( + { + "type": "alert", + "style": "warning", + "ask": m18n.n("domain_dns_registrar_not_supported"), + "value": None, + } + ) + else: + + registrar_infos["registrar"] = OrderedDict( + { + "type": "alert", + "style": "info", + "ask": m18n.n("domain_dns_registrar_supported", registrar=registrar), + "value": registrar, + } + ) + + TESTED_REGISTRARS = ["ovh", "gandi"] + if registrar not in TESTED_REGISTRARS: + registrar_infos["experimental_disclaimer"] = OrderedDict( + { + "type": "alert", + "style": "danger", + "ask": m18n.n( + "domain_dns_registrar_experimental", registrar=registrar + ), + } + ) + + # TODO : add a help tip with the link to the registar's API doc (c.f. Lexicon's README) + registrar_list = read_toml(DOMAIN_REGISTRAR_LIST_PATH) + registrar_credentials = registrar_list[registrar] + for credential, infos in registrar_credentials.items(): + infos["default"] = infos.get("default", "") + infos["optional"] = infos.get("optional", "False") + registrar_infos.update(registrar_credentials) + + return OrderedDict(registrar_infos) + + +def _get_registar_settings(domain): + + _assert_domain_exists(domain) + + settings = domain_config_get(domain, key="dns.registrar", export=True) + + registrar = settings.pop("registrar") + + if "experimental_disclaimer" in settings: + settings.pop("experimental_disclaimer") + + return registrar, settings + + +@is_unit_operation() +def domain_dns_push(operation_logger, domain, dry_run=False, force=False, purge=False): + """ + Send DNS records to the previously-configured registrar of the domain. + """ + + from lexicon.client import Client as LexiconClient + from lexicon.config import ConfigResolver as LexiconConfigResolver + + registrar, registrar_credentials = _get_registar_settings(domain) + + _assert_domain_exists(domain) + + if not registrar or registrar == "None": # yes it's None as a string + raise YunohostValidationError("domain_dns_push_not_applicable", domain=domain) + + # FIXME: in the future, properly unify this with yunohost dyndns update + if registrar == "yunohost": + logger.info(m18n.n("domain_dns_registrar_yunohost")) + return {} + + if registrar == "parent_domain": + parent_domain = domain.split(".", 1)[1] + registar, registrar_credentials = _get_registar_settings(parent_domain) + if any(registrar_credentials.values()): + raise YunohostValidationError( + "domain_dns_push_managed_in_parent_domain", + domain=domain, + parent_domain=parent_domain, + ) + else: + raise YunohostValidationError( + "domain_registrar_is_not_configured", domain=parent_domain + ) + + if not all(registrar_credentials.values()): + raise YunohostValidationError( + "domain_registrar_is_not_configured", domain=domain + ) + + base_dns_zone = _get_dns_zone_for_domain(domain) + + # Convert the generated conf into a format that matches what we'll fetch using the API + # Makes it easier to compare "wanted records" with "current records on remote" + wanted_records = [] + for records in _build_dns_conf(domain).values(): + for record in records: + + # Make sure the name is a FQDN + name = ( + f"{record['name']}.{base_dns_zone}" + if record["name"] != "@" + else base_dns_zone + ) + type_ = record["type"] + content = record["value"] + + # Make sure the content is also a FQDN (with trailing . ?) + if content == "@" and record["type"] == "CNAME": + content = base_dns_zone + "." + + wanted_records.append( + {"name": name, "type": type_, "ttl": record["ttl"], "content": content} + ) + + # FIXME Lexicon does not support CAA records + # See https://github.com/AnalogJ/lexicon/issues/282 and https://github.com/AnalogJ/lexicon/pull/371 + # They say it's trivial to implement it! + # And yet, it is still not done/merged + # Update by Aleks: it works - at least with Gandi ?! + # wanted_records = [record for record in wanted_records if record["type"] != "CAA"] + + if purge: + wanted_records = [] + force = True + + # Construct the base data structure to use lexicon's API. + + base_config = { + "provider_name": registrar, + "domain": base_dns_zone, + registrar: registrar_credentials, + } + + # Ugly hack to be able to fetch all record types at once: + # we initialize a LexiconClient with a dummy type "all" + # (which lexicon doesnt actually understands) + # then trigger ourselves the authentication + list_records + # instead of calling .execute() + query = ( + LexiconConfigResolver() + .with_dict(dict_object=base_config) + .with_dict(dict_object={"action": "list", "type": "all"}) + ) + client = LexiconClient(query) + try: + client.provider.authenticate() + except Exception as e: + raise YunohostValidationError( + "domain_dns_push_failed_to_authenticate", domain=domain, error=str(e) + ) + + try: + current_records = client.provider.list_records() + except Exception as e: + raise YunohostError("domain_dns_push_failed_to_list", error=str(e)) + + managed_dns_records_hashes = _get_managed_dns_records_hashes(domain) + + # Keep only records for relevant types: A, AAAA, MX, TXT, CNAME, SRV + relevant_types = ["A", "AAAA", "MX", "TXT", "CNAME", "SRV", "CAA"] + current_records = [r for r in current_records if r["type"] in relevant_types] + + # Ignore records which are for a higher-level domain + # i.e. we don't care about the records for domain.tld when pushing yuno.domain.tld + current_records = [ + r + for r in current_records + if r["name"].endswith(f".{domain}") or r["name"] == domain + ] + + for record in current_records: + + # Try to get rid of weird stuff like ".domain.tld" or "@.domain.tld" + record["name"] = record["name"].strip("@").strip(".") + + # Some API return '@' in content and we shall convert it to absolute/fqdn + record["content"] = ( + record["content"] + .replace("@.", base_dns_zone + ".") + .replace("@", base_dns_zone + ".") + ) + + if record["type"] == "TXT": + if not record["content"].startswith('"'): + record["content"] = '"' + record["content"] + if not record["content"].endswith('"'): + record["content"] = record["content"] + '"' + + # Check if this record was previously set by YunoHost + record["managed_by_yunohost"] = ( + _hash_dns_record(record) in managed_dns_records_hashes + ) + + # Step 0 : Get the list of unique (type, name) + # And compare the current and wanted records + # + # i.e. we want this kind of stuff: + # wanted current + # (A, .domain.tld) 1.2.3.4 1.2.3.4 + # (A, www.domain.tld) 1.2.3.4 5.6.7.8 + # (A, foobar.domain.tld) 1.2.3.4 + # (AAAA, .domain.tld) 2001::abcd + # (MX, .domain.tld) 10 domain.tld [10 mx1.ovh.net, 20 mx2.ovh.net] + # (TXT, .domain.tld) "v=spf1 ..." ["v=spf1", "foobar"] + # (SRV, .domain.tld) 0 5 5269 domain.tld + changes = {"delete": [], "update": [], "create": [], "unchanged": []} + + type_and_names = sorted( + set([(r["type"], r["name"]) for r in current_records + wanted_records]) + ) + comparison = { + type_and_name: {"current": [], "wanted": []} for type_and_name in type_and_names + } + + for record in current_records: + comparison[(record["type"], record["name"])]["current"].append(record) + + for record in wanted_records: + comparison[(record["type"], record["name"])]["wanted"].append(record) + + for type_and_name, records in comparison.items(): + + # + # Step 1 : compute a first "diff" where we remove records which are the same on both sides + # + wanted_contents = [r["content"] for r in records["wanted"]] + current_contents = [r["content"] for r in records["current"]] + + current = [r for r in records["current"] if r["content"] not in wanted_contents] + wanted = [r for r in records["wanted"] if r["content"] not in current_contents] + + # + # Step 2 : simple case: 0 record on one side, 0 on the other + # -> either nothing do (0/0) or creations (0/N) or deletions (N/0) + # + if len(current) == 0 and len(wanted) == 0: + # No diff, nothing to do + changes["unchanged"].extend(records["current"]) + continue + + elif len(wanted) == 0: + changes["delete"].extend(current) + continue + + elif len(current) == 0: + changes["create"].extend(wanted) + continue + + # + # Step 3 : N record on one side, M on the other + # + # Fuzzy matching strategy: + # For each wanted record, try to find a current record which looks like the wanted one + # -> if found, trigger an update + # -> if no match found, trigger a create + # + for record in wanted: + + def likeliness(r): + # We compute this only on the first 100 chars, to have a high value even for completely different DKIM keys + return SequenceMatcher( + None, r["content"][:100], record["content"][:100] + ).ratio() + + matches = sorted(current, key=lambda r: likeliness(r), reverse=True) + if matches and likeliness(matches[0]) > 0.50: + match = matches[0] + # Remove the match from 'current' so that it's not added to the removed stuff later + current.remove(match) + match["old_content"] = match["content"] + match["content"] = record["content"] + changes["update"].append(match) + else: + changes["create"].append(record) + + # + # For all other remaining current records: + # -> trigger deletions + # + for record in current: + changes["delete"].append(record) + + def relative_name(name): + name = name.strip(".") + name = name.replace("." + base_dns_zone, "") + name = name.replace(base_dns_zone, "@") + return name + + def human_readable_record(action, record): + name = relative_name(record["name"]) + name = name[:20] + t = record["type"] + + if not force and action in ["update", "delete"]: + ignored = ( + "" + if record["managed_by_yunohost"] + else "(ignored, won't be changed by Yunohost unless forced)" + ) + else: + ignored = "" + + if action == "create": + old_content = record.get("old_content", "(None)")[:30] + new_content = record.get("content", "(None)")[:30] + return f"{name:>20} [{t:^5}] {new_content:^30} {ignored}" + elif action == "update": + old_content = record.get("old_content", "(None)")[:30] + new_content = record.get("content", "(None)")[:30] + return ( + f"{name:>20} [{t:^5}] {old_content:^30} -> {new_content:^30} {ignored}" + ) + elif action == "unchanged": + old_content = new_content = record.get("content", "(None)")[:30] + return f"{name:>20} [{t:^5}] {old_content:^30}" + else: + old_content = record.get("content", "(None)")[:30] + return f"{name:>20} [{t:^5}] {old_content:^30} {ignored}" + + if dry_run: + if Moulinette.interface.type == "api": + for records in changes.values(): + for record in records: + record["name"] = relative_name(record["name"]) + return changes + else: + out = {"delete": [], "create": [], "update": [], "unchanged": []} + for action in ["delete", "create", "update", "unchanged"]: + for record in changes[action]: + out[action].append(human_readable_record(action, record)) + + return out + + # If --force ain't used, we won't delete/update records not managed by yunohost + if not force: + for action in ["delete", "update"]: + changes[action] = [r for r in changes[action] if r["managed_by_yunohost"]] + + def progress(info=""): + progress.nb += 1 + width = 20 + bar = int(progress.nb * width / progress.total) + bar = "[" + "#" * bar + "." * (width - bar) + "]" + if info: + bar += " > " + info + if progress.old == bar: + return + progress.old = bar + logger.info(bar) + + progress.nb = 0 + progress.old = "" + progress.total = len(changes["delete"] + changes["create"] + changes["update"]) + + if progress.total == 0: + logger.success(m18n.n("domain_dns_push_already_up_to_date")) + return {} + + # + # Actually push the records + # + + operation_logger.start() + logger.info(m18n.n("domain_dns_pushing")) + + new_managed_dns_records_hashes = [_hash_dns_record(r) for r in changes["unchanged"]] + results = {"warnings": [], "errors": []} + + for action in ["delete", "create", "update"]: + + for record in changes[action]: + + relative_name = record["name"].replace(base_dns_zone, "").rstrip(".") or "@" + progress( + f"{action} {record['type']:^5} / {relative_name}" + ) # FIXME: i18n but meh + + # Apparently Lexicon yields us some 'id' during fetch + # But wants 'identifier' during push ... + if "id" in record: + record["identifier"] = record["id"] + del record["id"] + + if registrar == "godaddy": + if record["name"] == base_dns_zone: + record["name"] = "@." + record["name"] + if record["type"] in ["MX", "SRV", "CAA"]: + logger.warning( + f"Pushing {record['type']} records is not properly supported by Lexicon/Godaddy." + ) + results["warnings"].append( + f"Pushing {record['type']} records is not properly supported by Lexicon/Godaddy." + ) + continue + + record["action"] = action + query = ( + LexiconConfigResolver() + .with_dict(dict_object=base_config) + .with_dict(dict_object=record) + ) + + try: + result = LexiconClient(query).execute() + except Exception as e: + msg = m18n.n( + "domain_dns_push_record_failed", + action=action, + type=record["type"], + name=record["name"], + error=str(e), + ) + logger.error(msg) + results["errors"].append(msg) + else: + if result: + new_managed_dns_records_hashes.append(_hash_dns_record(record)) + else: + msg = m18n.n( + "domain_dns_push_record_failed", + action=action, + type=record["type"], + name=record["name"], + error="unkonwn error?", + ) + logger.error(msg) + results["errors"].append(msg) + + _set_managed_dns_records_hashes(domain, new_managed_dns_records_hashes) + + # Everything succeeded + if len(results["errors"]) + len(results["warnings"]) == 0: + logger.success(m18n.n("domain_dns_push_success")) + return {} + # Everything failed + elif len(results["errors"]) + len(results["warnings"]) == progress.total: + logger.error(m18n.n("domain_dns_push_failed")) + else: + logger.warning(m18n.n("domain_dns_push_partial_failure")) + + return results + + +def _get_managed_dns_records_hashes(domain: str) -> list: + return _get_domain_settings(domain).get("managed_dns_records_hashes", []) + + +def _set_managed_dns_records_hashes(domain: str, hashes: list) -> None: + settings = _get_domain_settings(domain) + settings["managed_dns_records_hashes"] = hashes or [] + _set_domain_settings(domain, settings) + + +def _hash_dns_record(record: dict) -> int: + + fields = ["name", "type", "content"] + record_ = {f: record.get(f) for f in fields} + + return hash(frozenset(record_.items())) diff --git a/src/yunohost/domain.py b/src/yunohost/domain.py index 3bc70c424..1f96ced8a 100644 --- a/src/yunohost/domain.py +++ b/src/yunohost/domain.py @@ -24,13 +24,12 @@ Manage domains """ import os -import re +from typing import Dict, Any from moulinette import m18n, Moulinette from moulinette.core import MoulinetteError -from yunohost.utils.error import YunohostError, YunohostValidationError from moulinette.utils.log import getActionLogger -from moulinette.utils.filesystem import write_to_file +from moulinette.utils.filesystem import write_to_file, read_yaml, write_to_yaml from yunohost.app import ( app_ssowatconf, @@ -39,12 +38,18 @@ from yunohost.app import ( _get_conflicting_apps, ) from yunohost.regenconf import regen_conf, _force_clear_hashes, _process_regen_conf -from yunohost.utils.network import get_public_ip +from yunohost.utils.config import ConfigPanel, Question +from yunohost.utils.error import YunohostError, YunohostValidationError from yunohost.log import is_unit_operation -from yunohost.hook import hook_callback logger = getActionLogger("yunohost.domain") +DOMAIN_CONFIG_PATH = "/usr/share/yunohost/other/config_domain.toml" +DOMAIN_SETTINGS_DIR = "/etc/yunohost/domains" + +# Lazy dev caching to avoid re-query ldap every time we need the domain list +domain_list_cache: Dict[str, Any] = {} + def domain_list(exclude_subdomains=False): """ @@ -54,6 +59,10 @@ def domain_list(exclude_subdomains=False): exclude_subdomains -- Filter out domains that are subdomains of other declared domains """ + global domain_list_cache + if not exclude_subdomains and domain_list_cache: + return domain_list_cache + from yunohost.utils.ldap import _get_ldap_interface ldap = _get_ldap_interface() @@ -83,7 +92,17 @@ def domain_list(exclude_subdomains=False): result_list = sorted(result_list, key=cmp_domain) - return {"domains": result_list, "main": _get_maindomain()} + # Don't cache answer if using exclude_subdomains + if exclude_subdomains: + return {"domains": result_list, "main": _get_maindomain()} + + domain_list_cache = {"domains": result_list, "main": _get_maindomain()} + return domain_list_cache + + +def _assert_domain_exists(domain): + if domain not in domain_list()["domains"]: + raise YunohostValidationError("domain_name_unknown", domain=domain) @is_unit_operation() @@ -152,6 +171,9 @@ def domain_add(operation_logger, domain, dyndns=False): ldap.add("virtualdomain=%s,ou=domains" % domain, attr_dict) except Exception as e: raise YunohostError("domain_creation_failed", domain=domain, error=e) + finally: + global domain_list_cache + domain_list_cache = {} # Don't regen these conf if we're still in postinstall if os.path.exists("/etc/yunohost/installed"): @@ -203,8 +225,8 @@ def domain_remove(operation_logger, domain, remove_apps=False, force=False): # the 'force' here is related to the exception happening in domain_add ... # we don't want to check the domain exists because the ldap add may have # failed - if not force and domain not in domain_list()["domains"]: - raise YunohostValidationError("domain_name_unknown", domain=domain) + if not force: + _assert_domain_exists(domain) # Check domain is not the main domain if domain == _get_maindomain(): @@ -268,11 +290,18 @@ def domain_remove(operation_logger, domain, remove_apps=False, force=False): ldap.remove("virtualdomain=" + domain + ",ou=domains") except Exception as e: raise YunohostError("domain_deletion_failed", domain=domain, error=e) + finally: + global domain_list_cache + domain_list_cache = {} - os.system("rm -rf /etc/yunohost/certs/%s" % domain) + stuff_to_delete = [ + f"/etc/yunohost/certs/{domain}", + f"/etc/yunohost/dyndns/K{domain}.+*", + f"{DOMAIN_SETTINGS_DIR}/{domain}.yml", + ] - # Delete dyndns keys for this domain (if any) - os.system("rm -rf /etc/yunohost/dyndns/K%s.+*" % domain) + for stuff in stuff_to_delete: + os.system("rm -rf {stuff}") # Sometime we have weird issues with the regenconf where some files # appears as manually modified even though they weren't touched ... @@ -303,57 +332,6 @@ def domain_remove(operation_logger, domain, remove_apps=False, force=False): logger.success(m18n.n("domain_deleted")) -def domain_dns_conf(domain, ttl=None): - """ - Generate DNS configuration for a domain - - Keyword argument: - domain -- Domain name - ttl -- Time to live - - """ - - if domain not in domain_list()["domains"]: - raise YunohostValidationError("domain_name_unknown", domain=domain) - - ttl = 3600 if ttl is None else ttl - - dns_conf = _build_dns_conf(domain, ttl) - - result = "" - - result += "; Basic ipv4/ipv6 records" - for record in dns_conf["basic"]: - result += "\n{name} {ttl} IN {type} {value}".format(**record) - - result += "\n\n" - result += "; XMPP" - for record in dns_conf["xmpp"]: - result += "\n{name} {ttl} IN {type} {value}".format(**record) - - result += "\n\n" - result += "; Mail" - for record in dns_conf["mail"]: - result += "\n{name} {ttl} IN {type} {value}".format(**record) - result += "\n\n" - - result += "; Extra" - for record in dns_conf["extra"]: - result += "\n{name} {ttl} IN {type} {value}".format(**record) - - for name, record_list in dns_conf.items(): - if name not in ("basic", "xmpp", "mail", "extra") and record_list: - result += "\n\n" - result += "; " + name - for record in record_list: - result += "\n{name} {ttl} IN {type} {value}".format(**record) - - if Moulinette.interface.type == "cli": - logger.info(m18n.n("domain_dns_conf_is_just_a_recommendation")) - - return result - - @is_unit_operation() def domain_main_domain(operation_logger, new_main_domain=None): """ @@ -370,8 +348,7 @@ def domain_main_domain(operation_logger, new_main_domain=None): return {"current_main_domain": _get_maindomain()} # Check domain exists - if new_main_domain not in domain_list()["domains"]: - raise YunohostValidationError("domain_name_unknown", domain=new_main_domain) + _assert_domain_exists(new_main_domain) operation_logger.related_to.append(("domain", new_main_domain)) operation_logger.start() @@ -379,7 +356,8 @@ def domain_main_domain(operation_logger, new_main_domain=None): # Apply changes to ssl certs try: write_to_file("/etc/yunohost/current_host", new_main_domain) - + global domain_list_cache + domain_list_cache = {} _set_hostname(new_main_domain) except Exception as e: logger.warning("%s" % e, exc_info=1) @@ -395,6 +373,116 @@ def domain_main_domain(operation_logger, new_main_domain=None): logger.success(m18n.n("main_domain_changed")) +def domain_url_available(domain, path): + """ + Check availability of a web path + + Keyword argument: + domain -- The domain for the web path (e.g. your.domain.tld) + path -- The path to check (e.g. /coffee) + """ + + return len(_get_conflicting_apps(domain, path)) == 0 + + +def _get_maindomain(): + with open("/etc/yunohost/current_host", "r") as f: + maindomain = f.readline().rstrip() + return maindomain + + +def domain_config_get(domain, key="", full=False, export=False): + """ + Display a domain configuration + """ + + if full and export: + raise YunohostValidationError( + "You can't use --full and --export together.", raw_msg=True + ) + + if full: + mode = "full" + elif export: + mode = "export" + else: + mode = "classic" + + config = DomainConfigPanel(domain) + return config.get(key, mode) + + +@is_unit_operation() +def domain_config_set( + operation_logger, domain, key=None, value=None, args=None, args_file=None +): + """ + Apply a new domain configuration + """ + Question.operation_logger = operation_logger + config = DomainConfigPanel(domain) + return config.set(key, value, args, args_file, operation_logger=operation_logger) + + +class DomainConfigPanel(ConfigPanel): + def __init__(self, domain): + _assert_domain_exists(domain) + self.domain = domain + self.save_mode = "diff" + super().__init__( + config_path=DOMAIN_CONFIG_PATH, + save_path=f"{DOMAIN_SETTINGS_DIR}/{domain}.yml", + ) + + def _get_toml(self): + from yunohost.dns import _get_registrar_config_section + + toml = super()._get_toml() + + toml["feature"]["xmpp"]["xmpp"]["default"] = ( + 1 if self.domain == _get_maindomain() else 0 + ) + toml["dns"]["registrar"] = _get_registrar_config_section(self.domain) + + # FIXME: Ugly hack to save the registar id/value and reinject it in _load_current_values ... + self.registar_id = toml["dns"]["registrar"]["registrar"]["value"] + del toml["dns"]["registrar"]["registrar"]["value"] + + return toml + + def _load_current_values(self): + + # TODO add mechanism to share some settings with other domains on the same zone + super()._load_current_values() + + # FIXME: Ugly hack to save the registar id/value and reinject it in _load_current_values ... + self.values["registrar"] = self.registar_id + + +def _get_domain_settings(domain: str) -> dict: + + _assert_domain_exists(domain) + + if os.path.exists(f"{DOMAIN_SETTINGS_DIR}/{domain}.yml"): + return read_yaml(f"{DOMAIN_SETTINGS_DIR}/{domain}.yml") or {} + else: + return {} + + +def _set_domain_settings(domain: str, settings: dict) -> None: + + _assert_domain_exists(domain) + + write_to_yaml(f"{DOMAIN_SETTINGS_DIR}/{domain}.yml", settings) + + +# +# +# Stuff managed in other files +# +# + + def domain_cert_status(domain_list, full=False): import yunohost.certificate @@ -421,268 +509,17 @@ def domain_cert_renew( ) -def domain_url_available(domain, path): - """ - Check availability of a web path - - Keyword argument: - domain -- The domain for the web path (e.g. your.domain.tld) - path -- The path to check (e.g. /coffee) - """ - - return len(_get_conflicting_apps(domain, path)) == 0 +def domain_dns_conf(domain): + return domain_dns_suggest(domain) -def _get_maindomain(): - with open("/etc/yunohost/current_host", "r") as f: - maindomain = f.readline().rstrip() - return maindomain +def domain_dns_suggest(domain): + import yunohost.dns + + return yunohost.dns.domain_dns_suggest(domain) -def _build_dns_conf(domain, ttl=3600, include_empty_AAAA_if_no_ipv6=False): - """ - Internal function that will returns a data structure containing the needed - information to generate/adapt the dns configuration +def domain_dns_push(domain, dry_run, force, purge): + import yunohost.dns - The returned datastructure will have the following form: - { - "basic": [ - # if ipv4 available - {"type": "A", "name": "@", "value": "123.123.123.123", "ttl": 3600}, - # if ipv6 available - {"type": "AAAA", "name": "@", "value": "valid-ipv6", "ttl": 3600}, - ], - "xmpp": [ - {"type": "SRV", "name": "_xmpp-client._tcp", "value": "0 5 5222 domain.tld.", "ttl": 3600}, - {"type": "SRV", "name": "_xmpp-server._tcp", "value": "0 5 5269 domain.tld.", "ttl": 3600}, - {"type": "CNAME", "name": "muc", "value": "@", "ttl": 3600}, - {"type": "CNAME", "name": "pubsub", "value": "@", "ttl": 3600}, - {"type": "CNAME", "name": "vjud", "value": "@", "ttl": 3600} - {"type": "CNAME", "name": "xmpp-upload", "value": "@", "ttl": 3600} - ], - "mail": [ - {"type": "MX", "name": "@", "value": "10 domain.tld.", "ttl": 3600}, - {"type": "TXT", "name": "@", "value": "\"v=spf1 a mx ip4:123.123.123.123 ipv6:valid-ipv6 -all\"", "ttl": 3600 }, - {"type": "TXT", "name": "mail._domainkey", "value": "\"v=DKIM1; k=rsa; p=some-super-long-key\"", "ttl": 3600}, - {"type": "TXT", "name": "_dmarc", "value": "\"v=DMARC1; p=none\"", "ttl": 3600} - ], - "extra": [ - # if ipv4 available - {"type": "A", "name": "*", "value": "123.123.123.123", "ttl": 3600}, - # if ipv6 available - {"type": "AAAA", "name": "*", "value": "valid-ipv6", "ttl": 3600}, - {"type": "CAA", "name": "@", "value": "128 issue \"letsencrypt.org\"", "ttl": 3600}, - ], - "example_of_a_custom_rule": [ - {"type": "SRV", "name": "_matrix", "value": "domain.tld.", "ttl": 3600} - ], - } - """ - - ipv4 = get_public_ip() - ipv6 = get_public_ip(6) - - ########################### - # Basic ipv4/ipv6 records # - ########################### - - basic = [] - if ipv4: - basic.append(["@", ttl, "A", ipv4]) - - if ipv6: - basic.append(["@", ttl, "AAAA", ipv6]) - elif include_empty_AAAA_if_no_ipv6: - basic.append(["@", ttl, "AAAA", None]) - - ######### - # Email # - ######### - - mail = [ - ["@", ttl, "MX", "10 %s." % domain], - ["@", ttl, "TXT", '"v=spf1 a mx -all"'], - ] - - # DKIM/DMARC record - dkim_host, dkim_publickey = _get_DKIM(domain) - - if dkim_host: - mail += [ - [dkim_host, ttl, "TXT", dkim_publickey], - ["_dmarc", ttl, "TXT", '"v=DMARC1; p=none"'], - ] - - ######## - # XMPP # - ######## - - xmpp = [ - ["_xmpp-client._tcp", ttl, "SRV", "0 5 5222 %s." % domain], - ["_xmpp-server._tcp", ttl, "SRV", "0 5 5269 %s." % domain], - ["muc", ttl, "CNAME", "@"], - ["pubsub", ttl, "CNAME", "@"], - ["vjud", ttl, "CNAME", "@"], - ["xmpp-upload", ttl, "CNAME", "@"], - ] - - ######### - # Extra # - ######### - - extra = [] - - if ipv4: - extra.append(["*", ttl, "A", ipv4]) - - if ipv6: - extra.append(["*", ttl, "AAAA", ipv6]) - elif include_empty_AAAA_if_no_ipv6: - extra.append(["*", ttl, "AAAA", None]) - - extra.append(["@", ttl, "CAA", '128 issue "letsencrypt.org"']) - - #################### - # Standard records # - #################### - - records = { - "basic": [ - {"name": name, "ttl": ttl_, "type": type_, "value": value} - for name, ttl_, type_, value in basic - ], - "xmpp": [ - {"name": name, "ttl": ttl_, "type": type_, "value": value} - for name, ttl_, type_, value in xmpp - ], - "mail": [ - {"name": name, "ttl": ttl_, "type": type_, "value": value} - for name, ttl_, type_, value in mail - ], - "extra": [ - {"name": name, "ttl": ttl_, "type": type_, "value": value} - for name, ttl_, type_, value in extra - ], - } - - ################## - # Custom records # - ################## - - # Defined by custom hooks ships in apps for example ... - - hook_results = hook_callback("custom_dns_rules", args=[domain]) - for hook_name, results in hook_results.items(): - # - # There can be multiple results per hook name, so results look like - # {'/some/path/to/hook1': - # { 'state': 'succeed', - # 'stdreturn': [{'type': 'SRV', - # 'name': 'stuff.foo.bar.', - # 'value': 'yoloswag', - # 'ttl': 3600}] - # }, - # '/some/path/to/hook2': - # { ... }, - # [...] - # - # Loop over the sub-results - custom_records = [ - v["stdreturn"] for v in results.values() if v and v["stdreturn"] - ] - - records[hook_name] = [] - for record_list in custom_records: - # Check that record_list is indeed a list of dict - # with the required keys - if ( - not isinstance(record_list, list) - or any(not isinstance(record, dict) for record in record_list) - or any( - key not in record - for record in record_list - for key in ["name", "ttl", "type", "value"] - ) - ): - # Display an error, mainly for app packagers trying to implement a hook - logger.warning( - "Ignored custom record from hook '%s' because the data is not a *list* of dict with keys name, ttl, type and value. Raw data : %s" - % (hook_name, record_list) - ) - continue - - records[hook_name].extend(record_list) - - return records - - -def _get_DKIM(domain): - DKIM_file = "/etc/dkim/{domain}.mail.txt".format(domain=domain) - - if not os.path.isfile(DKIM_file): - return (None, None) - - with open(DKIM_file) as f: - dkim_content = f.read() - - # Gotta manage two formats : - # - # Legacy - # ----- - # - # mail._domainkey IN TXT ( "v=DKIM1; k=rsa; " - # "p=" ) - # - # New - # ------ - # - # mail._domainkey IN TXT ( "v=DKIM1; h=sha256; k=rsa; " - # "p=" ) - - is_legacy_format = " h=sha256; " not in dkim_content - - # Legacy DKIM format - if is_legacy_format: - dkim = re.match( - ( - r"^(?P[a-z_\-\.]+)[\s]+([0-9]+[\s]+)?IN[\s]+TXT[\s]+" - r'[^"]*"v=(?P[^";]+);' - r'[\s"]*k=(?P[^";]+);' - r'[\s"]*p=(?P

[^";]+)' - ), - dkim_content, - re.M | re.S, - ) - else: - dkim = re.match( - ( - r"^(?P[a-z_\-\.]+)[\s]+([0-9]+[\s]+)?IN[\s]+TXT[\s]+" - r'[^"]*"v=(?P[^";]+);' - r'[\s"]*h=(?P[^";]+);' - r'[\s"]*k=(?P[^";]+);' - r'[\s"]*p=(?P

[^";]+)' - ), - dkim_content, - re.M | re.S, - ) - - if not dkim: - return (None, None) - - if is_legacy_format: - return ( - dkim.group("host"), - '"v={v}; k={k}; p={p}"'.format( - v=dkim.group("v"), k=dkim.group("k"), p=dkim.group("p") - ), - ) - else: - return ( - dkim.group("host"), - '"v={v}; h={h}; k={k}; p={p}"'.format( - v=dkim.group("v"), - h=dkim.group("h"), - k=dkim.group("k"), - p=dkim.group("p"), - ), - ) + return yunohost.dns.domain_dns_push(domain, dry_run, force, purge) diff --git a/src/yunohost/dyndns.py b/src/yunohost/dyndns.py index ae49759d2..bd462c468 100644 --- a/src/yunohost/dyndns.py +++ b/src/yunohost/dyndns.py @@ -37,8 +37,9 @@ from moulinette.utils.filesystem import write_to_file, read_file from moulinette.utils.network import download_json from yunohost.utils.error import YunohostError, YunohostValidationError -from yunohost.domain import _get_maindomain, _build_dns_conf -from yunohost.utils.network import get_public_ip, dig +from yunohost.domain import _get_maindomain +from yunohost.utils.network import get_public_ip +from yunohost.utils.dns import dig from yunohost.log import is_unit_operation from yunohost.regenconf import regen_conf @@ -224,9 +225,8 @@ def dyndns_update( ipv6 -- IPv6 address to send """ - # Get old ipv4/v6 - old_ipv4, old_ipv6 = (None, None) # (default values) + from yunohost.dns import _build_dns_conf # If domain is not given, try to guess it from keys available... if domain is None: @@ -307,6 +307,12 @@ def dyndns_update( logger.debug("Old IPv4/v6 are (%s, %s)" % (old_ipv4, old_ipv6)) logger.debug("Requested IPv4/v6 are (%s, %s)" % (ipv4, ipv6)) + if ipv4 is None and ipv6 is None: + logger.debug( + "No ipv4 nor ipv6 ?! Sounds like the server is not connected to the internet, or the ip.yunohost.org infrastructure is down somehow" + ) + return + # no need to update if (not force and not dry_run) and (old_ipv4 == ipv4 and old_ipv6 == ipv6): logger.info("No updated needed.") diff --git a/src/yunohost/hook.py b/src/yunohost/hook.py index 0594a27ae..c55809fce 100644 --- a/src/yunohost/hook.py +++ b/src/yunohost/hook.py @@ -34,7 +34,7 @@ from importlib import import_module from moulinette import m18n, Moulinette from yunohost.utils.error import YunohostError, YunohostValidationError from moulinette.utils import log -from moulinette.utils.filesystem import read_json +from moulinette.utils.filesystem import read_yaml HOOK_FOLDER = "/usr/share/yunohost/hooks/" CUSTOM_HOOK_FOLDER = "/etc/yunohost/hooks.d/" @@ -326,7 +326,7 @@ def hook_exec( chdir=None, env=None, user="root", - return_format="json", + return_format="yaml", ): """ Execute hook from a file with arguments @@ -447,10 +447,10 @@ def _hook_exec_bash(path, args, chdir, env, user, return_format, loggers): raw_content = f.read() returncontent = {} - if return_format == "json": + if return_format == "yaml": if raw_content != "": try: - returncontent = read_json(stdreturn) + returncontent = read_yaml(stdreturn) except Exception as e: raise YunohostError( "hook_json_return_error", @@ -498,6 +498,40 @@ def _hook_exec_python(path, args, env, loggers): return ret +def hook_exec_with_script_debug_if_failure(*args, **kwargs): + + operation_logger = kwargs.pop("operation_logger") + error_message_if_failed = kwargs.pop("error_message_if_failed") + error_message_if_script_failed = kwargs.pop("error_message_if_script_failed") + + failed = True + failure_message_with_debug_instructions = None + try: + retcode, retpayload = hook_exec(*args, **kwargs) + failed = True if retcode != 0 else False + if failed: + error = error_message_if_script_failed + logger.error(error_message_if_failed(error)) + failure_message_with_debug_instructions = operation_logger.error(error) + if Moulinette.interface.type != "api": + operation_logger.dump_script_log_extract_for_debugging() + # Script got manually interrupted ... + # N.B. : KeyboardInterrupt does not inherit from Exception + except (KeyboardInterrupt, EOFError): + error = m18n.n("operation_interrupted") + logger.error(error_message_if_failed(error)) + failure_message_with_debug_instructions = operation_logger.error(error) + # Something wrong happened in Yunohost's code (most probably hook_exec) + except Exception: + import traceback + + error = m18n.n("unexpected_error", error="\n" + traceback.format_exc()) + logger.error(error_message_if_failed(error)) + failure_message_with_debug_instructions = operation_logger.error(error) + + return failed, failure_message_with_debug_instructions + + def _extract_filename_parts(filename): """Extract hook parts from filename""" if "-" in filename: diff --git a/src/yunohost/log.py b/src/yunohost/log.py index 3f6382af2..c99c1bbc9 100644 --- a/src/yunohost/log.py +++ b/src/yunohost/log.py @@ -29,6 +29,7 @@ import re import yaml import glob import psutil +from typing import List from datetime import datetime, timedelta from logging import FileHandler, getLogger, Formatter @@ -69,7 +70,13 @@ def log_list(limit=None, with_details=False, with_suboperations=False): logs = list(reversed(sorted(logs))) if limit is not None: - logs = logs[:limit] + if with_suboperations: + logs = logs[:limit] + else: + # If we displaying only parent, we are still gonna load up to limit * 5 logs + # because many of them are suboperations which are not gonna be kept + # Yet we still want to obtain ~limit number of logs + logs = logs[: limit * 5] for log in logs: @@ -122,6 +129,9 @@ def log_list(limit=None, with_details=False, with_suboperations=False): else: operations = [o for o in operations.values()] + if limit: + operations = operations[:limit] + operations = list(reversed(sorted(operations, key=lambda o: o["name"]))) # Reverse the order of log when in cli, more comfortable to read (avoid # unecessary scrolling) @@ -151,26 +161,42 @@ def log_show( filter_irrelevant = True if filter_irrelevant: - filters = [ - r"set [+-]x$", - r"set [+-]o xtrace$", - r"local \w+$", - r"local legacy_args=.*$", - r".*Helper used in legacy mode.*", - r"args_array=.*$", - r"local -A args_array$", - r"ynh_handle_getopts_args", - r"ynh_script_progression", - ] + + def _filter(lines): + filters = [ + r"set [+-]x$", + r"set [+-]o xtrace$", + r"set [+-]o errexit$", + r"set [+-]o nounset$", + r"trap '' EXIT", + r"local \w+$", + r"local exit_code=(1|0)$", + r"local legacy_args=.*$", + r"local -A args_array$", + r"args_array=.*$", + r"ret_code=1", + r".*Helper used in legacy mode.*", + r"ynh_handle_getopts_args", + r"ynh_script_progression", + r"sleep 0.5", + r"'\[' (1|0) -eq (1|0) '\]'$", + r"\[?\['? -n '' '?\]\]?$", + r"rm -rf /var/cache/yunohost/download/$", + r"type -t ynh_clean_setup$", + r"DEBUG - \+ echo '", + r"DEBUG - \+ exit (1|0)$", + ] + filters = [re.compile(f) for f in filters] + return [ + line + for line in lines + if not any(f.search(line.strip()) for f in filters) + ] + else: - filters = [] - def _filter_lines(lines, filters=[]): - - filters = [re.compile(f) for f in filters] - return [ - line for line in lines if not any(f.search(line.strip()) for f in filters) - ] + def _filter(lines): + return lines # Normalize log/metadata paths and filenames abs_path = path @@ -209,7 +235,7 @@ def log_show( content += "\n============\n\n" if os.path.exists(log_path): actual_log = read_file(log_path) - content += "\n".join(_filter_lines(actual_log.split("\n"), filters)) + content += "\n".join(_filter(actual_log.split("\n"))) url = yunopaste(content) @@ -282,13 +308,13 @@ def log_show( if os.path.exists(log_path): from yunohost.service import _tail - if number and filters: + if number and filter_irrelevant: logs = _tail(log_path, int(number * 4)) elif number: logs = _tail(log_path, int(number)) else: logs = read_file(log_path) - logs = _filter_lines(logs, filters) + logs = list(_filter(logs)) if number: logs = logs[-number:] infos["log_path"] = log_path @@ -427,7 +453,7 @@ class RedactingFormatter(Formatter): # (the secret part being at least 3 chars to avoid catching some lines like just "db_pwd=") # Some names like "key" or "manifest_key" are ignored, used in helpers like ynh_app_setting_set or ynh_read_manifest match = re.search( - r"(pwd|pass|password|passphrase|secret\w*|\w+key|token|PASSPHRASE)=(\S{3,})$", + r"(pwd|pass|passwd|password|passphrase|secret\w*|\w+key|token|PASSPHRASE)=(\S{3,})$", record.strip(), ) if ( @@ -453,7 +479,7 @@ class OperationLogger(object): This class record logs and metadata like context or start time/end time. """ - _instances = [] + _instances: List[object] = [] def __init__(self, operation, related_to=None, **kwargs): # TODO add a way to not save password on app installation @@ -707,6 +733,52 @@ class OperationLogger(object): else: self.error(m18n.n("log_operation_unit_unclosed_properly")) + def dump_script_log_extract_for_debugging(self): + + with open(self.log_path, "r") as f: + lines = f.readlines() + + filters = [ + r"set [+-]x$", + r"set [+-]o xtrace$", + r"local \w+$", + r"local legacy_args=.*$", + r".*Helper used in legacy mode.*", + r"args_array=.*$", + r"local -A args_array$", + r"ynh_handle_getopts_args", + r"ynh_script_progression", + ] + + filters = [re.compile(f_) for f_ in filters] + + lines_to_display = [] + for line in lines: + + if ": " not in line.strip(): + continue + + # A line typically looks like + # 2019-10-19 16:10:27,611: DEBUG - + mysql -u piwigo --password=********** -B piwigo + # And we just want the part starting by "DEBUG - " + line = line.strip().split(": ", 1)[1] + + if any(filter_.search(line) for filter_ in filters): + continue + + lines_to_display.append(line) + + if line.endswith("+ ynh_exit_properly") or " + ynh_die " in line: + break + elif len(lines_to_display) > 20: + lines_to_display.pop(0) + + logger.warning( + "Here's an extract of the logs before the crash. It might help debugging the error:" + ) + for line in lines_to_display: + logger.info(line) + def _get_datetime_from_name(name): diff --git a/src/yunohost/permission.py b/src/yunohost/permission.py index 01330ad7f..80d3b8602 100644 --- a/src/yunohost/permission.py +++ b/src/yunohost/permission.py @@ -457,22 +457,26 @@ def permission_create( "permission_creation_failed", permission=permission, error=e ) - permission_url( - permission, - url=url, - add_url=additional_urls, - auth_header=auth_header, - sync_perm=False, - ) + try: + permission_url( + permission, + url=url, + add_url=additional_urls, + auth_header=auth_header, + sync_perm=False, + ) - new_permission = _update_ldap_group_permission( - permission=permission, - allowed=allowed, - label=label, - show_tile=show_tile, - protected=protected, - sync_perm=sync_perm, - ) + new_permission = _update_ldap_group_permission( + permission=permission, + allowed=allowed, + label=label, + show_tile=show_tile, + protected=protected, + sync_perm=sync_perm, + ) + except: + permission_delete(permission, force=True) + raise logger.debug(m18n.n("permission_created", permission=permission)) return new_permission @@ -860,11 +864,9 @@ def _validate_and_sanitize_permission_url(url, app_base_path, app): re:^/api/.*|/scripts/api.js$ """ - from yunohost.domain import domain_list + from yunohost.domain import _assert_domain_exists from yunohost.app import _assert_no_conflicting_apps - domains = domain_list()["domains"] - # # Regexes # @@ -896,8 +898,8 @@ def _validate_and_sanitize_permission_url(url, app_base_path, app): domain, path = url[3:].split("/", 1) path = "/" + path - if domain.replace("%", "").replace("\\", "") not in domains: - raise YunohostValidationError("domain_name_unknown", domain=domain) + domain_with_no_regex = domain.replace("%", "").replace("\\", "") + _assert_domain_exists(domain_with_no_regex) validate_regex(path) @@ -931,8 +933,7 @@ def _validate_and_sanitize_permission_url(url, app_base_path, app): domain, path = split_domain_path(url) sanitized_url = domain + path - if domain not in domains: - raise YunohostValidationError("domain_name_unknown", domain=domain) + _assert_domain_exists(domain) _assert_no_conflicting_apps(domain, path, ignore_app=app) diff --git a/src/yunohost/regenconf.py b/src/yunohost/regenconf.py index 0608bcf8c..ef3c29b32 100644 --- a/src/yunohost/regenconf.py +++ b/src/yunohost/regenconf.py @@ -105,13 +105,9 @@ def regen_conf( else: filesystem.mkdir(PENDING_CONF_DIR, 0o755, True) - # Format common hooks arguments - common_args = [1 if force else 0, 1 if dry_run else 0] - # Execute hooks for pre-regen - pre_args = [ - "pre", - ] + common_args + # element 2 and 3 with empty string is because of legacy... + pre_args = ["pre", "", ""] def _pre_call(name, priority, path, args): # create the pending conf directory for the category @@ -417,9 +413,8 @@ def regen_conf( return result # Execute hooks for post-regen - post_args = [ - "post", - ] + common_args + # element 2 and 3 with empty string is because of legacy... + post_args = ["post", "", ""] def _pre_call(name, priority, path, args): # append coma-separated applied changes for the category diff --git a/src/yunohost/service.py b/src/yunohost/service.py index f6e3efc5b..73534e2e3 100644 --- a/src/yunohost/service.py +++ b/src/yunohost/service.py @@ -243,7 +243,7 @@ def service_restart(names): ) -def service_reload_or_restart(names): +def service_reload_or_restart(names, test_conf=True): """ Reload one or more services if they support it. If not, restart them instead. If the services are not running yet, they will be started. @@ -253,7 +253,36 @@ def service_reload_or_restart(names): """ if isinstance(names, str): names = [names] + + services = _get_services() + for name in names: + + logger.debug(f"Reloading service {name}") + + test_conf_cmd = services.get(name, {}).get("test_conf") + if test_conf and test_conf_cmd: + + p = subprocess.Popen( + test_conf_cmd, + shell=True, + executable="/bin/bash", + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ) + + out, _ = p.communicate() + if p.returncode != 0: + errors = out.decode().strip().split("\n") + logger.error( + m18n.n( + "service_not_reloading_because_conf_broken", + name=name, + errors=errors, + ) + ) + continue + if _run_service_command("reload-or-restart", name): logger.success(m18n.n("service_reloaded_or_restarted", service=name)) else: diff --git a/src/yunohost/settings.py b/src/yunohost/settings.py index 475ac70d1..d59b41a58 100644 --- a/src/yunohost/settings.py +++ b/src/yunohost/settings.py @@ -18,6 +18,9 @@ SETTINGS_PATH_OTHER_LOCATION = "/etc/yunohost/settings-%s.json" def is_boolean(value): + TRUE = ["true", "on", "yes", "y", "1"] + FALSE = ["false", "off", "no", "n", "0"] + """ Ensure a string value is intended as a boolean @@ -30,9 +33,11 @@ def is_boolean(value): """ if isinstance(value, bool): return True, value + if value in [0, 1]: + return True, bool(value) elif isinstance(value, str): - if str(value).lower() in ["true", "on", "yes", "false", "off", "no"]: - return True, str(value).lower() in ["true", "on", "yes"] + if str(value).lower() in TRUE + FALSE: + return True, str(value).lower() in TRUE else: return False, None else: diff --git a/src/yunohost/tests/conftest.py b/src/yunohost/tests/conftest.py index 6b4e2c3fd..a07c44346 100644 --- a/src/yunohost/tests/conftest.py +++ b/src/yunohost/tests/conftest.py @@ -1,14 +1,11 @@ import os import pytest -import sys import moulinette from moulinette import m18n, Moulinette from yunohost.utils.error import YunohostError from contextlib import contextmanager -sys.path.append("..") - @pytest.fixture(scope="session", autouse=True) def clone_test_app(request): @@ -77,6 +74,8 @@ moulinette.core.Moulinette18n.n = new_m18nn def pytest_cmdline_main(config): + import sys + sys.path.insert(0, "/usr/lib/moulinette/") import yunohost @@ -84,9 +83,12 @@ def pytest_cmdline_main(config): class DummyInterface: - type = "test" + type = "cli" - def prompt(*args, **kwargs): + def prompt(self, *args, **kwargs): raise NotImplementedError + def display(self, message, *args, **kwargs): + print(message) + Moulinette._interface = DummyInterface() diff --git a/src/yunohost/tests/test_app_config.py b/src/yunohost/tests/test_app_config.py new file mode 100644 index 000000000..d705076c4 --- /dev/null +++ b/src/yunohost/tests/test_app_config.py @@ -0,0 +1,202 @@ +import glob +import os +import shutil +import pytest + +from .conftest import get_test_apps_dir + +from moulinette.utils.filesystem import read_file + +from yunohost.domain import _get_maindomain +from yunohost.app import ( + app_setting, + app_install, + app_remove, + _is_installed, + app_config_get, + app_config_set, + app_ssowatconf, +) + +from yunohost.utils.error import YunohostError, YunohostValidationError + + +def setup_function(function): + + clean() + + +def teardown_function(function): + + clean() + + +def clean(): + + # Make sure we have a ssowat + os.system("mkdir -p /etc/ssowat/") + app_ssowatconf() + + test_apps = ["config_app", "legacy_app"] + + for test_app in test_apps: + + if _is_installed(test_app): + app_remove(test_app) + + for filepath in glob.glob("/etc/nginx/conf.d/*.d/*%s*" % test_app): + os.remove(filepath) + for folderpath in glob.glob("/etc/yunohost/apps/*%s*" % test_app): + shutil.rmtree(folderpath, ignore_errors=True) + for folderpath in glob.glob("/var/www/*%s*" % test_app): + shutil.rmtree(folderpath, ignore_errors=True) + + os.system("bash -c \"mysql -B 2>/dev/null <<< 'DROP DATABASE %s' \"" % test_app) + os.system( + "bash -c \"mysql -B 2>/dev/null <<< 'DROP USER %s@localhost'\"" % test_app + ) + + # Reset failed quota for service to avoid running into start-limit rate ? + os.system("systemctl reset-failed nginx") + os.system("systemctl start nginx") + + +@pytest.fixture() +def legacy_app(request): + + main_domain = _get_maindomain() + + app_install( + os.path.join(get_test_apps_dir(), "legacy_app_ynh"), + args="domain=%s&path=%s&is_public=%s" % (main_domain, "/", 1), + force=True, + ) + + def remove_app(): + app_remove("legacy_app") + + request.addfinalizer(remove_app) + + return "legacy_app" + + +@pytest.fixture() +def config_app(request): + + app_install( + os.path.join(get_test_apps_dir(), "config_app_ynh"), + args="", + force=True, + ) + + def remove_app(): + app_remove("config_app") + + request.addfinalizer(remove_app) + + return "config_app" + + +def test_app_config_get(config_app): + + assert isinstance(app_config_get(config_app), dict) + assert isinstance(app_config_get(config_app, full=True), dict) + assert isinstance(app_config_get(config_app, export=True), dict) + assert isinstance(app_config_get(config_app, "main"), dict) + assert isinstance(app_config_get(config_app, "main.components"), dict) + assert app_config_get(config_app, "main.components.boolean") == "0" + + +def test_app_config_nopanel(legacy_app): + + with pytest.raises(YunohostValidationError): + app_config_get(legacy_app) + + +def test_app_config_get_nonexistentstuff(config_app): + + with pytest.raises(YunohostValidationError): + app_config_get("nonexistent") + + with pytest.raises(YunohostValidationError): + app_config_get(config_app, "nonexistent") + + with pytest.raises(YunohostValidationError): + app_config_get(config_app, "main.nonexistent") + + with pytest.raises(YunohostValidationError): + app_config_get(config_app, "main.components.nonexistent") + + app_setting(config_app, "boolean", delete=True) + with pytest.raises(YunohostError): + app_config_get(config_app, "main.components.boolean") + + +def test_app_config_regular_setting(config_app): + + assert app_config_get(config_app, "main.components.boolean") == "0" + + app_config_set(config_app, "main.components.boolean", "no") + + assert app_config_get(config_app, "main.components.boolean") == "0" + assert app_setting(config_app, "boolean") == "0" + + app_config_set(config_app, "main.components.boolean", "yes") + + assert app_config_get(config_app, "main.components.boolean") == "1" + assert app_setting(config_app, "boolean") == "1" + + with pytest.raises(YunohostValidationError): + app_config_set(config_app, "main.components.boolean", "pwet") + + +def test_app_config_bind_on_file(config_app): + + # c.f. conf/test.php in the config app + assert '$arg5= "Arg5 value";' in read_file("/var/www/config_app/test.php") + assert app_config_get(config_app, "bind.variable.arg5") == "Arg5 value" + assert app_setting(config_app, "arg5") is None + + app_config_set(config_app, "bind.variable.arg5", "Foo Bar") + + assert '$arg5= "Foo Bar";' in read_file("/var/www/config_app/test.php") + assert app_config_get(config_app, "bind.variable.arg5") == "Foo Bar" + assert app_setting(config_app, "arg5") == "Foo Bar" + + +def test_app_config_custom_get(config_app): + + assert app_setting(config_app, "arg9") is None + assert ( + "Files in /var/www" + in app_config_get(config_app, "bind.function.arg9")["ask"]["en"] + ) + assert app_setting(config_app, "arg9") is None + + +def test_app_config_custom_validator(config_app): + + # c.f. the config script + # arg8 is a password that must be at least 8 chars + assert not os.path.exists("/var/www/config_app/password") + assert app_setting(config_app, "arg8") is None + + with pytest.raises(YunohostValidationError): + app_config_set(config_app, "bind.function.arg8", "pZo6i7u91h") + + assert not os.path.exists("/var/www/config_app/password") + assert app_setting(config_app, "arg8") is None + + +def test_app_config_custom_set(config_app): + + assert not os.path.exists("/var/www/config_app/password") + assert app_setting(config_app, "arg8") is None + + app_config_set(config_app, "bind.function.arg8", "OneSuperStrongPassword") + + assert os.path.exists("/var/www/config_app/password") + content = read_file("/var/www/config_app/password") + assert "OneSuperStrongPassword" not in content + assert content.startswith("$6$saltsalt$") + assert app_setting(config_app, "arg8") is None diff --git a/src/yunohost/tests/test_backuprestore.py b/src/yunohost/tests/test_backuprestore.py index 2a88e4042..59eb7d021 100644 --- a/src/yunohost/tests/test_backuprestore.py +++ b/src/yunohost/tests/test_backuprestore.py @@ -2,6 +2,7 @@ import pytest import os import shutil import subprocess +from mock import patch from .conftest import message, raiseYunohostError, get_test_apps_dir @@ -77,7 +78,8 @@ def setup_function(function): if "with_permission_app_installed" in markers: assert not app_is_installed("permissions_app") user_create("alice", "Alice", "White", maindomain, "test123Ynh") - install_app("permissions_app_ynh", "/urlpermissionapp" "&admin=alice") + with patch.object(os, "isatty", return_value=False): + install_app("permissions_app_ynh", "/urlpermissionapp" "&admin=alice") assert app_is_installed("permissions_app") if "with_custom_domain" in markers: @@ -469,7 +471,7 @@ def test_restore_app_script_failure_handling(monkeypatch, mocker): monkeypatch.undo() return (1, None) - monkeypatch.setattr("yunohost.backup.hook_exec", custom_hook_exec) + monkeypatch.setattr("yunohost.hook.hook_exec", custom_hook_exec) assert not _is_installed("wordpress") diff --git a/src/yunohost/tests/test_dns.py b/src/yunohost/tests/test_dns.py new file mode 100644 index 000000000..497cab2fd --- /dev/null +++ b/src/yunohost/tests/test_dns.py @@ -0,0 +1,80 @@ +import pytest + +from moulinette.utils.filesystem import read_toml + +from yunohost.domain import domain_add, domain_remove +from yunohost.dns import ( + DOMAIN_REGISTRAR_LIST_PATH, + _get_dns_zone_for_domain, + _get_registrar_config_section, + _build_dns_conf, +) + + +def setup_function(function): + + clean() + + +def teardown_function(function): + + clean() + + +def clean(): + pass + + +# DNS utils testing +def test_get_dns_zone_from_domain_existing(): + assert _get_dns_zone_for_domain("yunohost.org") == "yunohost.org" + assert _get_dns_zone_for_domain("donate.yunohost.org") == "yunohost.org" + assert _get_dns_zone_for_domain("fr.wikipedia.org") == "wikipedia.org" + assert _get_dns_zone_for_domain("www.fr.wikipedia.org") == "wikipedia.org" + assert ( + _get_dns_zone_for_domain("non-existing-domain.yunohost.org") == "yunohost.org" + ) + assert _get_dns_zone_for_domain("yolo.nohost.me") == "nohost.me" + assert _get_dns_zone_for_domain("foo.yolo.nohost.me") == "nohost.me" + assert _get_dns_zone_for_domain("yolo.tld") == "yolo.tld" + assert _get_dns_zone_for_domain("foo.yolo.tld") == "yolo.tld" + + +# Domain registrar testing +def test_registrar_list_integrity(): + assert read_toml(DOMAIN_REGISTRAR_LIST_PATH) + + +def test_magic_guess_registrar_weird_domain(): + assert _get_registrar_config_section("yolo.tld")["registrar"]["value"] is None + + +def test_magic_guess_registrar_ovh(): + assert ( + _get_registrar_config_section("yolo.yunohost.org")["registrar"]["value"] + == "ovh" + ) + + +def test_magic_guess_registrar_yunodyndns(): + assert ( + _get_registrar_config_section("yolo.nohost.me")["registrar"]["value"] + == "yunohost" + ) + + +@pytest.fixture +def example_domain(): + domain_add("example.tld") + yield "example.tld" + domain_remove("example.tld") + + +def test_domain_dns_suggest(example_domain): + + assert _build_dns_conf(example_domain) + + +# def domain_dns_push(domain, dry_run): +# import yunohost.dns +# return yunohost.dns.domain_registrar_push(domain, dry_run) diff --git a/src/yunohost/tests/test_domains.py b/src/yunohost/tests/test_domains.py new file mode 100644 index 000000000..02d60ead4 --- /dev/null +++ b/src/yunohost/tests/test_domains.py @@ -0,0 +1,118 @@ +import pytest +import os + +from moulinette.core import MoulinetteError + +from yunohost.utils.error import YunohostValidationError +from yunohost.domain import ( + DOMAIN_SETTINGS_DIR, + _get_maindomain, + domain_add, + domain_remove, + domain_list, + domain_main_domain, + domain_config_get, + domain_config_set, +) + +TEST_DOMAINS = ["example.tld", "sub.example.tld", "other-example.com"] + + +def setup_function(function): + + # Save domain list in variable to avoid multiple calls to domain_list() + domains = domain_list()["domains"] + + # First domain is main domain + if not TEST_DOMAINS[0] in domains: + domain_add(TEST_DOMAINS[0]) + else: + # Reset settings if any + os.system(f"rm -rf {DOMAIN_SETTINGS_DIR}/{TEST_DOMAINS[0]}.yml") + + if not _get_maindomain() == TEST_DOMAINS[0]: + domain_main_domain(TEST_DOMAINS[0]) + + # Clear other domains + for domain in domains: + if domain not in TEST_DOMAINS or domain == TEST_DOMAINS[2]: + # Clean domains not used for testing + domain_remove(domain) + elif domain in TEST_DOMAINS: + # Reset settings if any + os.system(f"rm -rf {DOMAIN_SETTINGS_DIR}/{domain}.yml") + + # Create classical second domain of not exist + if TEST_DOMAINS[1] not in domains: + domain_add(TEST_DOMAINS[1]) + + # Third domain is not created + + clean() + + +def teardown_function(function): + + clean() + + +def clean(): + pass + + +# Domains management testing +def test_domain_add(): + assert TEST_DOMAINS[2] not in domain_list()["domains"] + domain_add(TEST_DOMAINS[2]) + assert TEST_DOMAINS[2] in domain_list()["domains"] + + +def test_domain_add_existing_domain(): + with pytest.raises(MoulinetteError): + assert TEST_DOMAINS[1] in domain_list()["domains"] + domain_add(TEST_DOMAINS[1]) + + +def test_domain_remove(): + assert TEST_DOMAINS[1] in domain_list()["domains"] + domain_remove(TEST_DOMAINS[1]) + assert TEST_DOMAINS[1] not in domain_list()["domains"] + + +def test_main_domain(): + current_main_domain = _get_maindomain() + assert domain_main_domain()["current_main_domain"] == current_main_domain + + +def test_main_domain_change_unknown(): + with pytest.raises(YunohostValidationError): + domain_main_domain(TEST_DOMAINS[2]) + + +def test_change_main_domain(): + assert _get_maindomain() != TEST_DOMAINS[1] + domain_main_domain(TEST_DOMAINS[1]) + assert _get_maindomain() == TEST_DOMAINS[1] + + +# Domain settings testing +def test_domain_config_get_default(): + assert domain_config_get(TEST_DOMAINS[0], "feature.xmpp.xmpp") == 1 + assert domain_config_get(TEST_DOMAINS[1], "feature.xmpp.xmpp") == 0 + + +def test_domain_config_get_export(): + + assert domain_config_get(TEST_DOMAINS[0], export=True)["xmpp"] == 1 + assert domain_config_get(TEST_DOMAINS[1], export=True)["xmpp"] == 0 + + +def test_domain_config_set(): + assert domain_config_get(TEST_DOMAINS[1], "feature.xmpp.xmpp") == 0 + domain_config_set(TEST_DOMAINS[1], "feature.xmpp.xmpp", "yes") + assert domain_config_get(TEST_DOMAINS[1], "feature.xmpp.xmpp") == 1 + + +def test_domain_configs_unknown(): + with pytest.raises(YunohostValidationError): + domain_config_get(TEST_DOMAINS[2], "feature.xmpp.xmpp.xmpp") diff --git a/src/yunohost/tests/test_apps_arguments_parsing.py b/src/yunohost/tests/test_questions.py similarity index 52% rename from src/yunohost/tests/test_apps_arguments_parsing.py rename to src/yunohost/tests/test_questions.py index fe5c5f8cd..9753b08e4 100644 --- a/src/yunohost/tests/test_apps_arguments_parsing.py +++ b/src/yunohost/tests/test_questions.py @@ -1,14 +1,19 @@ import sys import pytest +import os -from mock import patch +from mock import patch, MagicMock from io import StringIO from collections import OrderedDict from moulinette import Moulinette from yunohost import domain, user -from yunohost.app import _parse_args_in_yunohost_format, PasswordArgumentParser +from yunohost.utils.config import ( + parse_args_in_yunohost_format, + PasswordQuestion, + Question, +) from yunohost.utils.error import YunohostError @@ -35,11 +40,11 @@ User answers: """ -def test_parse_args_in_yunohost_format_empty(): - assert _parse_args_in_yunohost_format({}, []) == {} +def test_question_empty(): + assert parse_args_in_yunohost_format({}, []) == {} -def test_parse_args_in_yunohost_format_string(): +def test_question_string(): questions = [ { "name": "some_string", @@ -48,10 +53,10 @@ def test_parse_args_in_yunohost_format_string(): ] answers = {"some_string": "some_value"} expected_result = OrderedDict({"some_string": ("some_value", "string")}) - assert _parse_args_in_yunohost_format(answers, questions) == expected_result + assert parse_args_in_yunohost_format(answers, questions) == expected_result -def test_parse_args_in_yunohost_format_string_default_type(): +def test_question_string_default_type(): questions = [ { "name": "some_string", @@ -59,10 +64,10 @@ def test_parse_args_in_yunohost_format_string_default_type(): ] answers = {"some_string": "some_value"} expected_result = OrderedDict({"some_string": ("some_value", "string")}) - assert _parse_args_in_yunohost_format(answers, questions) == expected_result + assert parse_args_in_yunohost_format(answers, questions) == expected_result -def test_parse_args_in_yunohost_format_string_no_input(): +def test_question_string_no_input(): questions = [ { "name": "some_string", @@ -70,11 +75,11 @@ def test_parse_args_in_yunohost_format_string_no_input(): ] answers = {} - with pytest.raises(YunohostError): - _parse_args_in_yunohost_format(answers, questions) + with pytest.raises(YunohostError), patch.object(os, "isatty", return_value=False): + parse_args_in_yunohost_format(answers, questions) -def test_parse_args_in_yunohost_format_string_input(): +def test_question_string_input(): questions = [ { "name": "some_string", @@ -84,11 +89,13 @@ def test_parse_args_in_yunohost_format_string_input(): answers = {} expected_result = OrderedDict({"some_string": ("some_value", "string")}) - with patch.object(Moulinette.interface, "prompt", return_value="some_value"): - assert _parse_args_in_yunohost_format(answers, questions) == expected_result + with patch.object(Moulinette, "prompt", return_value="some_value"), patch.object( + os, "isatty", return_value=True + ): + assert parse_args_in_yunohost_format(answers, questions) == expected_result -def test_parse_args_in_yunohost_format_string_input_no_ask(): +def test_question_string_input_no_ask(): questions = [ { "name": "some_string", @@ -97,11 +104,13 @@ def test_parse_args_in_yunohost_format_string_input_no_ask(): answers = {} expected_result = OrderedDict({"some_string": ("some_value", "string")}) - with patch.object(Moulinette.interface, "prompt", return_value="some_value"): - assert _parse_args_in_yunohost_format(answers, questions) == expected_result + with patch.object(Moulinette, "prompt", return_value="some_value"), patch.object( + os, "isatty", return_value=True + ): + assert parse_args_in_yunohost_format(answers, questions) == expected_result -def test_parse_args_in_yunohost_format_string_no_input_optional(): +def test_question_string_no_input_optional(): questions = [ { "name": "some_string", @@ -110,10 +119,11 @@ def test_parse_args_in_yunohost_format_string_no_input_optional(): ] answers = {} expected_result = OrderedDict({"some_string": ("", "string")}) - assert _parse_args_in_yunohost_format(answers, questions) == expected_result + with patch.object(os, "isatty", return_value=False): + assert parse_args_in_yunohost_format(answers, questions) == expected_result -def test_parse_args_in_yunohost_format_string_optional_with_input(): +def test_question_string_optional_with_input(): questions = [ { "name": "some_string", @@ -124,11 +134,13 @@ def test_parse_args_in_yunohost_format_string_optional_with_input(): answers = {} expected_result = OrderedDict({"some_string": ("some_value", "string")}) - with patch.object(Moulinette.interface, "prompt", return_value="some_value"): - assert _parse_args_in_yunohost_format(answers, questions) == expected_result + with patch.object(Moulinette, "prompt", return_value="some_value"), patch.object( + os, "isatty", return_value=True + ): + assert parse_args_in_yunohost_format(answers, questions) == expected_result -def test_parse_args_in_yunohost_format_string_optional_with_empty_input(): +def test_question_string_optional_with_empty_input(): questions = [ { "name": "some_string", @@ -139,11 +151,13 @@ def test_parse_args_in_yunohost_format_string_optional_with_empty_input(): answers = {} expected_result = OrderedDict({"some_string": ("", "string")}) - with patch.object(Moulinette.interface, "prompt", return_value=""): - assert _parse_args_in_yunohost_format(answers, questions) == expected_result + with patch.object(Moulinette, "prompt", return_value=""), patch.object( + os, "isatty", return_value=True + ): + assert parse_args_in_yunohost_format(answers, questions) == expected_result -def test_parse_args_in_yunohost_format_string_optional_with_input_without_ask(): +def test_question_string_optional_with_input_without_ask(): questions = [ { "name": "some_string", @@ -153,11 +167,13 @@ def test_parse_args_in_yunohost_format_string_optional_with_input_without_ask(): answers = {} expected_result = OrderedDict({"some_string": ("some_value", "string")}) - with patch.object(Moulinette.interface, "prompt", return_value="some_value"): - assert _parse_args_in_yunohost_format(answers, questions) == expected_result + with patch.object(Moulinette, "prompt", return_value="some_value"), patch.object( + os, "isatty", return_value=True + ): + assert parse_args_in_yunohost_format(answers, questions) == expected_result -def test_parse_args_in_yunohost_format_string_no_input_default(): +def test_question_string_no_input_default(): questions = [ { "name": "some_string", @@ -167,10 +183,11 @@ def test_parse_args_in_yunohost_format_string_no_input_default(): ] answers = {} expected_result = OrderedDict({"some_string": ("some_value", "string")}) - assert _parse_args_in_yunohost_format(answers, questions) == expected_result + with patch.object(os, "isatty", return_value=False): + assert parse_args_in_yunohost_format(answers, questions) == expected_result -def test_parse_args_in_yunohost_format_string_input_test_ask(): +def test_question_string_input_test_ask(): ask_text = "some question" questions = [ { @@ -181,13 +198,19 @@ def test_parse_args_in_yunohost_format_string_input_test_ask(): answers = {} with patch.object( - Moulinette.interface, "prompt", return_value="some_value" - ) as prompt: - _parse_args_in_yunohost_format(answers, questions) - prompt.assert_called_with(ask_text, False) + Moulinette, "prompt", return_value="some_value" + ) as prompt, patch.object(os, "isatty", return_value=True): + parse_args_in_yunohost_format(answers, questions) + prompt.assert_called_with( + message=ask_text, + is_password=False, + confirm=False, + prefill="", + is_multiline=False, + ) -def test_parse_args_in_yunohost_format_string_input_test_ask_with_default(): +def test_question_string_input_test_ask_with_default(): ask_text = "some question" default_text = "some example" questions = [ @@ -200,14 +223,20 @@ def test_parse_args_in_yunohost_format_string_input_test_ask_with_default(): answers = {} with patch.object( - Moulinette.interface, "prompt", return_value="some_value" - ) as prompt: - _parse_args_in_yunohost_format(answers, questions) - prompt.assert_called_with("%s (default: %s)" % (ask_text, default_text), False) + Moulinette, "prompt", return_value="some_value" + ) as prompt, patch.object(os, "isatty", return_value=True): + parse_args_in_yunohost_format(answers, questions) + prompt.assert_called_with( + message=ask_text, + is_password=False, + confirm=False, + prefill=default_text, + is_multiline=False, + ) @pytest.mark.skip # we should do something with this example -def test_parse_args_in_yunohost_format_string_input_test_ask_with_example(): +def test_question_string_input_test_ask_with_example(): ask_text = "some question" example_text = "some example" questions = [ @@ -220,15 +249,15 @@ def test_parse_args_in_yunohost_format_string_input_test_ask_with_example(): answers = {} with patch.object( - Moulinette.interface, "prompt", return_value="some_value" - ) as prompt: - _parse_args_in_yunohost_format(answers, questions) - assert ask_text in prompt.call_args[0][0] - assert example_text in prompt.call_args[0][0] + Moulinette, "prompt", return_value="some_value" + ) as prompt, patch.object(os, "isatty", return_value=True): + parse_args_in_yunohost_format(answers, questions) + assert ask_text in prompt.call_args[1]["message"] + assert example_text in prompt.call_args[1]["message"] @pytest.mark.skip # we should do something with this help -def test_parse_args_in_yunohost_format_string_input_test_ask_with_help(): +def test_question_string_input_test_ask_with_help(): ask_text = "some question" help_text = "some_help" questions = [ @@ -241,37 +270,39 @@ def test_parse_args_in_yunohost_format_string_input_test_ask_with_help(): answers = {} with patch.object( - Moulinette.interface, "prompt", return_value="some_value" - ) as prompt: - _parse_args_in_yunohost_format(answers, questions) - assert ask_text in prompt.call_args[0][0] - assert help_text in prompt.call_args[0][0] + Moulinette, "prompt", return_value="some_value" + ) as prompt, patch.object(os, "isatty", return_value=True): + parse_args_in_yunohost_format(answers, questions) + assert ask_text in prompt.call_args[1]["message"] + assert help_text in prompt.call_args[1]["message"] -def test_parse_args_in_yunohost_format_string_with_choice(): +def test_question_string_with_choice(): questions = [{"name": "some_string", "type": "string", "choices": ["fr", "en"]}] answers = {"some_string": "fr"} expected_result = OrderedDict({"some_string": ("fr", "string")}) - assert _parse_args_in_yunohost_format(answers, questions) == expected_result + assert parse_args_in_yunohost_format(answers, questions) == expected_result -def test_parse_args_in_yunohost_format_string_with_choice_prompt(): +def test_question_string_with_choice_prompt(): questions = [{"name": "some_string", "type": "string", "choices": ["fr", "en"]}] answers = {"some_string": "fr"} expected_result = OrderedDict({"some_string": ("fr", "string")}) - with patch.object(Moulinette.interface, "prompt", return_value="fr"): - assert _parse_args_in_yunohost_format(answers, questions) == expected_result + with patch.object(Moulinette, "prompt", return_value="fr"), patch.object( + os, "isatty", return_value=True + ): + assert parse_args_in_yunohost_format(answers, questions) == expected_result -def test_parse_args_in_yunohost_format_string_with_choice_bad(): +def test_question_string_with_choice_bad(): questions = [{"name": "some_string", "type": "string", "choices": ["fr", "en"]}] answers = {"some_string": "bad"} - with pytest.raises(YunohostError): - assert _parse_args_in_yunohost_format(answers, questions) + with pytest.raises(YunohostError), patch.object(os, "isatty", return_value=False): + assert parse_args_in_yunohost_format(answers, questions) -def test_parse_args_in_yunohost_format_string_with_choice_ask(): +def test_question_string_with_choice_ask(): ask_text = "some question" choices = ["fr", "en", "es", "it", "ru"] questions = [ @@ -283,15 +314,17 @@ def test_parse_args_in_yunohost_format_string_with_choice_ask(): ] answers = {} - with patch.object(Moulinette.interface, "prompt", return_value="ru") as prompt: - _parse_args_in_yunohost_format(answers, questions) - assert ask_text in prompt.call_args[0][0] + with patch.object(Moulinette, "prompt", return_value="ru") as prompt, patch.object( + os, "isatty", return_value=True + ): + parse_args_in_yunohost_format(answers, questions) + assert ask_text in prompt.call_args[1]["message"] for choice in choices: - assert choice in prompt.call_args[0][0] + assert choice in prompt.call_args[1]["message"] -def test_parse_args_in_yunohost_format_string_with_choice_default(): +def test_question_string_with_choice_default(): questions = [ { "name": "some_string", @@ -302,10 +335,11 @@ def test_parse_args_in_yunohost_format_string_with_choice_default(): ] answers = {} expected_result = OrderedDict({"some_string": ("en", "string")}) - assert _parse_args_in_yunohost_format(answers, questions) == expected_result + with patch.object(os, "isatty", return_value=False): + assert parse_args_in_yunohost_format(answers, questions) == expected_result -def test_parse_args_in_yunohost_format_password(): +def test_question_password(): questions = [ { "name": "some_password", @@ -314,10 +348,10 @@ def test_parse_args_in_yunohost_format_password(): ] answers = {"some_password": "some_value"} expected_result = OrderedDict({"some_password": ("some_value", "password")}) - assert _parse_args_in_yunohost_format(answers, questions) == expected_result + assert parse_args_in_yunohost_format(answers, questions) == expected_result -def test_parse_args_in_yunohost_format_password_no_input(): +def test_question_password_no_input(): questions = [ { "name": "some_password", @@ -326,11 +360,11 @@ def test_parse_args_in_yunohost_format_password_no_input(): ] answers = {} - with pytest.raises(YunohostError): - _parse_args_in_yunohost_format(answers, questions) + with pytest.raises(YunohostError), patch.object(os, "isatty", return_value=False): + parse_args_in_yunohost_format(answers, questions) -def test_parse_args_in_yunohost_format_password_input(): +def test_question_password_input(): questions = [ { "name": "some_password", @@ -341,11 +375,13 @@ def test_parse_args_in_yunohost_format_password_input(): answers = {} expected_result = OrderedDict({"some_password": ("some_value", "password")}) - with patch.object(Moulinette.interface, "prompt", return_value="some_value"): - assert _parse_args_in_yunohost_format(answers, questions) == expected_result + with patch.object(Moulinette, "prompt", return_value="some_value"), patch.object( + os, "isatty", return_value=True + ): + assert parse_args_in_yunohost_format(answers, questions) == expected_result -def test_parse_args_in_yunohost_format_password_input_no_ask(): +def test_question_password_input_no_ask(): questions = [ { "name": "some_password", @@ -355,11 +391,13 @@ def test_parse_args_in_yunohost_format_password_input_no_ask(): answers = {} expected_result = OrderedDict({"some_password": ("some_value", "password")}) - with patch.object(Moulinette.interface, "prompt", return_value="some_value"): - assert _parse_args_in_yunohost_format(answers, questions) == expected_result + with patch.object(Moulinette, "prompt", return_value="some_value"), patch.object( + os, "isatty", return_value=True + ): + assert parse_args_in_yunohost_format(answers, questions) == expected_result -def test_parse_args_in_yunohost_format_password_no_input_optional(): +def test_question_password_no_input_optional(): questions = [ { "name": "some_password", @@ -370,16 +408,18 @@ def test_parse_args_in_yunohost_format_password_no_input_optional(): answers = {} expected_result = OrderedDict({"some_password": ("", "password")}) - assert _parse_args_in_yunohost_format(answers, questions) == expected_result + with patch.object(os, "isatty", return_value=False): + assert parse_args_in_yunohost_format(answers, questions) == expected_result questions = [ {"name": "some_password", "type": "password", "optional": True, "default": ""} ] - assert _parse_args_in_yunohost_format(answers, questions) == expected_result + with patch.object(os, "isatty", return_value=False): + assert parse_args_in_yunohost_format(answers, questions) == expected_result -def test_parse_args_in_yunohost_format_password_optional_with_input(): +def test_question_password_optional_with_input(): questions = [ { "name": "some_password", @@ -391,11 +431,13 @@ def test_parse_args_in_yunohost_format_password_optional_with_input(): answers = {} expected_result = OrderedDict({"some_password": ("some_value", "password")}) - with patch.object(Moulinette.interface, "prompt", return_value="some_value"): - assert _parse_args_in_yunohost_format(answers, questions) == expected_result + with patch.object(Moulinette, "prompt", return_value="some_value"), patch.object( + os, "isatty", return_value=True + ): + assert parse_args_in_yunohost_format(answers, questions) == expected_result -def test_parse_args_in_yunohost_format_password_optional_with_empty_input(): +def test_question_password_optional_with_empty_input(): questions = [ { "name": "some_password", @@ -407,11 +449,13 @@ def test_parse_args_in_yunohost_format_password_optional_with_empty_input(): answers = {} expected_result = OrderedDict({"some_password": ("", "password")}) - with patch.object(Moulinette.interface, "prompt", return_value=""): - assert _parse_args_in_yunohost_format(answers, questions) == expected_result + with patch.object(Moulinette, "prompt", return_value=""), patch.object( + os, "isatty", return_value=True + ): + assert parse_args_in_yunohost_format(answers, questions) == expected_result -def test_parse_args_in_yunohost_format_password_optional_with_input_without_ask(): +def test_question_password_optional_with_input_without_ask(): questions = [ { "name": "some_password", @@ -422,11 +466,13 @@ def test_parse_args_in_yunohost_format_password_optional_with_input_without_ask( answers = {} expected_result = OrderedDict({"some_password": ("some_value", "password")}) - with patch.object(Moulinette.interface, "prompt", return_value="some_value"): - assert _parse_args_in_yunohost_format(answers, questions) == expected_result + with patch.object(Moulinette, "prompt", return_value="some_value"), patch.object( + os, "isatty", return_value=True + ): + assert parse_args_in_yunohost_format(answers, questions) == expected_result -def test_parse_args_in_yunohost_format_password_no_input_default(): +def test_question_password_no_input_default(): questions = [ { "name": "some_password", @@ -438,12 +484,12 @@ def test_parse_args_in_yunohost_format_password_no_input_default(): answers = {} # no default for password! - with pytest.raises(YunohostError): - _parse_args_in_yunohost_format(answers, questions) + with pytest.raises(YunohostError), patch.object(os, "isatty", return_value=False): + parse_args_in_yunohost_format(answers, questions) @pytest.mark.skip # this should raises -def test_parse_args_in_yunohost_format_password_no_input_example(): +def test_question_password_no_input_example(): questions = [ { "name": "some_password", @@ -455,11 +501,11 @@ def test_parse_args_in_yunohost_format_password_no_input_example(): answers = {"some_password": "some_value"} # no example for password! - with pytest.raises(YunohostError): - _parse_args_in_yunohost_format(answers, questions) + with pytest.raises(YunohostError), patch.object(os, "isatty", return_value=False): + parse_args_in_yunohost_format(answers, questions) -def test_parse_args_in_yunohost_format_password_input_test_ask(): +def test_question_password_input_test_ask(): ask_text = "some question" questions = [ { @@ -471,14 +517,20 @@ def test_parse_args_in_yunohost_format_password_input_test_ask(): answers = {} with patch.object( - Moulinette.interface, "prompt", return_value="some_value" - ) as prompt: - _parse_args_in_yunohost_format(answers, questions) - prompt.assert_called_with(ask_text, True) + Moulinette, "prompt", return_value="some_value" + ) as prompt, patch.object(os, "isatty", return_value=True): + parse_args_in_yunohost_format(answers, questions) + prompt.assert_called_with( + message=ask_text, + is_password=True, + confirm=False, + prefill="", + is_multiline=False, + ) @pytest.mark.skip # we should do something with this example -def test_parse_args_in_yunohost_format_password_input_test_ask_with_example(): +def test_question_password_input_test_ask_with_example(): ask_text = "some question" example_text = "some example" questions = [ @@ -492,15 +544,15 @@ def test_parse_args_in_yunohost_format_password_input_test_ask_with_example(): answers = {} with patch.object( - Moulinette.interface, "prompt", return_value="some_value" - ) as prompt: - _parse_args_in_yunohost_format(answers, questions) - assert ask_text in prompt.call_args[0][0] - assert example_text in prompt.call_args[0][0] + Moulinette, "prompt", return_value="some_value" + ) as prompt, patch.object(os, "isatty", return_value=True): + parse_args_in_yunohost_format(answers, questions) + assert ask_text in prompt.call_args[1]["message"] + assert example_text in prompt.call_args[1]["message"] @pytest.mark.skip # we should do something with this help -def test_parse_args_in_yunohost_format_password_input_test_ask_with_help(): +def test_question_password_input_test_ask_with_help(): ask_text = "some question" help_text = "some_help" questions = [ @@ -514,14 +566,14 @@ def test_parse_args_in_yunohost_format_password_input_test_ask_with_help(): answers = {} with patch.object( - Moulinette.interface, "prompt", return_value="some_value" - ) as prompt: - _parse_args_in_yunohost_format(answers, questions) - assert ask_text in prompt.call_args[0][0] - assert help_text in prompt.call_args[0][0] + Moulinette, "prompt", return_value="some_value" + ) as prompt, patch.object(os, "isatty", return_value=True): + parse_args_in_yunohost_format(answers, questions) + assert ask_text in prompt.call_args[1]["message"] + assert help_text in prompt.call_args[1]["message"] -def test_parse_args_in_yunohost_format_password_bad_chars(): +def test_question_password_bad_chars(): questions = [ { "name": "some_password", @@ -531,12 +583,14 @@ def test_parse_args_in_yunohost_format_password_bad_chars(): } ] - for i in PasswordArgumentParser.forbidden_chars: - with pytest.raises(YunohostError): - _parse_args_in_yunohost_format({"some_password": i * 8}, questions) + for i in PasswordQuestion.forbidden_chars: + with pytest.raises(YunohostError), patch.object( + os, "isatty", return_value=False + ): + parse_args_in_yunohost_format({"some_password": i * 8}, questions) -def test_parse_args_in_yunohost_format_password_strong_enough(): +def test_question_password_strong_enough(): questions = [ { "name": "some_password", @@ -546,15 +600,15 @@ def test_parse_args_in_yunohost_format_password_strong_enough(): } ] - with pytest.raises(YunohostError): + with pytest.raises(YunohostError), patch.object(os, "isatty", return_value=False): # too short - _parse_args_in_yunohost_format({"some_password": "a"}, questions) + parse_args_in_yunohost_format({"some_password": "a"}, questions) - with pytest.raises(YunohostError): - _parse_args_in_yunohost_format({"some_password": "password"}, questions) + with pytest.raises(YunohostError), patch.object(os, "isatty", return_value=False): + parse_args_in_yunohost_format({"some_password": "password"}, questions) -def test_parse_args_in_yunohost_format_password_optional_strong_enough(): +def test_question_password_optional_strong_enough(): questions = [ { "name": "some_password", @@ -564,15 +618,15 @@ def test_parse_args_in_yunohost_format_password_optional_strong_enough(): } ] - with pytest.raises(YunohostError): + with pytest.raises(YunohostError), patch.object(os, "isatty", return_value=False): # too short - _parse_args_in_yunohost_format({"some_password": "a"}, questions) + parse_args_in_yunohost_format({"some_password": "a"}, questions) - with pytest.raises(YunohostError): - _parse_args_in_yunohost_format({"some_password": "password"}, questions) + with pytest.raises(YunohostError), patch.object(os, "isatty", return_value=False): + parse_args_in_yunohost_format({"some_password": "password"}, questions) -def test_parse_args_in_yunohost_format_path(): +def test_question_path(): questions = [ { "name": "some_path", @@ -581,10 +635,10 @@ def test_parse_args_in_yunohost_format_path(): ] answers = {"some_path": "some_value"} expected_result = OrderedDict({"some_path": ("some_value", "path")}) - assert _parse_args_in_yunohost_format(answers, questions) == expected_result + assert parse_args_in_yunohost_format(answers, questions) == expected_result -def test_parse_args_in_yunohost_format_path_no_input(): +def test_question_path_no_input(): questions = [ { "name": "some_path", @@ -593,11 +647,11 @@ def test_parse_args_in_yunohost_format_path_no_input(): ] answers = {} - with pytest.raises(YunohostError): - _parse_args_in_yunohost_format(answers, questions) + with pytest.raises(YunohostError), patch.object(os, "isatty", return_value=False): + parse_args_in_yunohost_format(answers, questions) -def test_parse_args_in_yunohost_format_path_input(): +def test_question_path_input(): questions = [ { "name": "some_path", @@ -608,11 +662,13 @@ def test_parse_args_in_yunohost_format_path_input(): answers = {} expected_result = OrderedDict({"some_path": ("some_value", "path")}) - with patch.object(Moulinette.interface, "prompt", return_value="some_value"): - assert _parse_args_in_yunohost_format(answers, questions) == expected_result + with patch.object(Moulinette, "prompt", return_value="some_value"), patch.object( + os, "isatty", return_value=True + ): + assert parse_args_in_yunohost_format(answers, questions) == expected_result -def test_parse_args_in_yunohost_format_path_input_no_ask(): +def test_question_path_input_no_ask(): questions = [ { "name": "some_path", @@ -622,11 +678,13 @@ def test_parse_args_in_yunohost_format_path_input_no_ask(): answers = {} expected_result = OrderedDict({"some_path": ("some_value", "path")}) - with patch.object(Moulinette.interface, "prompt", return_value="some_value"): - assert _parse_args_in_yunohost_format(answers, questions) == expected_result + with patch.object(Moulinette, "prompt", return_value="some_value"), patch.object( + os, "isatty", return_value=True + ): + assert parse_args_in_yunohost_format(answers, questions) == expected_result -def test_parse_args_in_yunohost_format_path_no_input_optional(): +def test_question_path_no_input_optional(): questions = [ { "name": "some_path", @@ -636,10 +694,11 @@ def test_parse_args_in_yunohost_format_path_no_input_optional(): ] answers = {} expected_result = OrderedDict({"some_path": ("", "path")}) - assert _parse_args_in_yunohost_format(answers, questions) == expected_result + with patch.object(os, "isatty", return_value=False): + assert parse_args_in_yunohost_format(answers, questions) == expected_result -def test_parse_args_in_yunohost_format_path_optional_with_input(): +def test_question_path_optional_with_input(): questions = [ { "name": "some_path", @@ -651,11 +710,13 @@ def test_parse_args_in_yunohost_format_path_optional_with_input(): answers = {} expected_result = OrderedDict({"some_path": ("some_value", "path")}) - with patch.object(Moulinette.interface, "prompt", return_value="some_value"): - assert _parse_args_in_yunohost_format(answers, questions) == expected_result + with patch.object(Moulinette, "prompt", return_value="some_value"), patch.object( + os, "isatty", return_value=True + ): + assert parse_args_in_yunohost_format(answers, questions) == expected_result -def test_parse_args_in_yunohost_format_path_optional_with_empty_input(): +def test_question_path_optional_with_empty_input(): questions = [ { "name": "some_path", @@ -667,11 +728,13 @@ def test_parse_args_in_yunohost_format_path_optional_with_empty_input(): answers = {} expected_result = OrderedDict({"some_path": ("", "path")}) - with patch.object(Moulinette.interface, "prompt", return_value=""): - assert _parse_args_in_yunohost_format(answers, questions) == expected_result + with patch.object(Moulinette, "prompt", return_value=""), patch.object( + os, "isatty", return_value=True + ): + assert parse_args_in_yunohost_format(answers, questions) == expected_result -def test_parse_args_in_yunohost_format_path_optional_with_input_without_ask(): +def test_question_path_optional_with_input_without_ask(): questions = [ { "name": "some_path", @@ -682,11 +745,13 @@ def test_parse_args_in_yunohost_format_path_optional_with_input_without_ask(): answers = {} expected_result = OrderedDict({"some_path": ("some_value", "path")}) - with patch.object(Moulinette.interface, "prompt", return_value="some_value"): - assert _parse_args_in_yunohost_format(answers, questions) == expected_result + with patch.object(Moulinette, "prompt", return_value="some_value"), patch.object( + os, "isatty", return_value=True + ): + assert parse_args_in_yunohost_format(answers, questions) == expected_result -def test_parse_args_in_yunohost_format_path_no_input_default(): +def test_question_path_no_input_default(): questions = [ { "name": "some_path", @@ -697,10 +762,11 @@ def test_parse_args_in_yunohost_format_path_no_input_default(): ] answers = {} expected_result = OrderedDict({"some_path": ("some_value", "path")}) - assert _parse_args_in_yunohost_format(answers, questions) == expected_result + with patch.object(os, "isatty", return_value=False): + assert parse_args_in_yunohost_format(answers, questions) == expected_result -def test_parse_args_in_yunohost_format_path_input_test_ask(): +def test_question_path_input_test_ask(): ask_text = "some question" questions = [ { @@ -712,13 +778,19 @@ def test_parse_args_in_yunohost_format_path_input_test_ask(): answers = {} with patch.object( - Moulinette.interface, "prompt", return_value="some_value" - ) as prompt: - _parse_args_in_yunohost_format(answers, questions) - prompt.assert_called_with(ask_text, False) + Moulinette, "prompt", return_value="some_value" + ) as prompt, patch.object(os, "isatty", return_value=True): + parse_args_in_yunohost_format(answers, questions) + prompt.assert_called_with( + message=ask_text, + is_password=False, + confirm=False, + prefill="", + is_multiline=False, + ) -def test_parse_args_in_yunohost_format_path_input_test_ask_with_default(): +def test_question_path_input_test_ask_with_default(): ask_text = "some question" default_text = "some example" questions = [ @@ -732,14 +804,20 @@ def test_parse_args_in_yunohost_format_path_input_test_ask_with_default(): answers = {} with patch.object( - Moulinette.interface, "prompt", return_value="some_value" - ) as prompt: - _parse_args_in_yunohost_format(answers, questions) - prompt.assert_called_with("%s (default: %s)" % (ask_text, default_text), False) + Moulinette, "prompt", return_value="some_value" + ) as prompt, patch.object(os, "isatty", return_value=True): + parse_args_in_yunohost_format(answers, questions) + prompt.assert_called_with( + message=ask_text, + is_password=False, + confirm=False, + prefill=default_text, + is_multiline=False, + ) @pytest.mark.skip # we should do something with this example -def test_parse_args_in_yunohost_format_path_input_test_ask_with_example(): +def test_question_path_input_test_ask_with_example(): ask_text = "some question" example_text = "some example" questions = [ @@ -753,15 +831,15 @@ def test_parse_args_in_yunohost_format_path_input_test_ask_with_example(): answers = {} with patch.object( - Moulinette.interface, "prompt", return_value="some_value" - ) as prompt: - _parse_args_in_yunohost_format(answers, questions) - assert ask_text in prompt.call_args[0][0] - assert example_text in prompt.call_args[0][0] + Moulinette, "prompt", return_value="some_value" + ) as prompt, patch.object(os, "isatty", return_value=True): + parse_args_in_yunohost_format(answers, questions) + assert ask_text in prompt.call_args[1]["message"] + assert example_text in prompt.call_args[1]["message"] @pytest.mark.skip # we should do something with this help -def test_parse_args_in_yunohost_format_path_input_test_ask_with_help(): +def test_question_path_input_test_ask_with_help(): ask_text = "some question" help_text = "some_help" questions = [ @@ -775,14 +853,14 @@ def test_parse_args_in_yunohost_format_path_input_test_ask_with_help(): answers = {} with patch.object( - Moulinette.interface, "prompt", return_value="some_value" - ) as prompt: - _parse_args_in_yunohost_format(answers, questions) - assert ask_text in prompt.call_args[0][0] - assert help_text in prompt.call_args[0][0] + Moulinette, "prompt", return_value="some_value" + ) as prompt, patch.object(os, "isatty", return_value=True): + parse_args_in_yunohost_format(answers, questions) + assert ask_text in prompt.call_args[1]["message"] + assert help_text in prompt.call_args[1]["message"] -def test_parse_args_in_yunohost_format_boolean(): +def test_question_boolean(): questions = [ { "name": "some_boolean", @@ -791,10 +869,10 @@ def test_parse_args_in_yunohost_format_boolean(): ] answers = {"some_boolean": "y"} expected_result = OrderedDict({"some_boolean": (1, "boolean")}) - assert _parse_args_in_yunohost_format(answers, questions) == expected_result + assert parse_args_in_yunohost_format(answers, questions) == expected_result -def test_parse_args_in_yunohost_format_boolean_all_yes(): +def test_question_boolean_all_yes(): questions = [ { "name": "some_boolean", @@ -803,52 +881,51 @@ def test_parse_args_in_yunohost_format_boolean_all_yes(): ] expected_result = OrderedDict({"some_boolean": (1, "boolean")}) assert ( - _parse_args_in_yunohost_format({"some_boolean": "y"}, questions) + parse_args_in_yunohost_format({"some_boolean": "y"}, questions) == expected_result ) assert ( - _parse_args_in_yunohost_format({"some_boolean": "Y"}, questions) + parse_args_in_yunohost_format({"some_boolean": "Y"}, questions) == expected_result ) assert ( - _parse_args_in_yunohost_format({"some_boolean": "yes"}, questions) + parse_args_in_yunohost_format({"some_boolean": "yes"}, questions) == expected_result ) assert ( - _parse_args_in_yunohost_format({"some_boolean": "Yes"}, questions) + parse_args_in_yunohost_format({"some_boolean": "Yes"}, questions) == expected_result ) assert ( - _parse_args_in_yunohost_format({"some_boolean": "YES"}, questions) + parse_args_in_yunohost_format({"some_boolean": "YES"}, questions) == expected_result ) assert ( - _parse_args_in_yunohost_format({"some_boolean": "1"}, questions) + parse_args_in_yunohost_format({"some_boolean": "1"}, questions) == expected_result ) assert ( - _parse_args_in_yunohost_format({"some_boolean": 1}, questions) + parse_args_in_yunohost_format({"some_boolean": 1}, questions) == expected_result + ) + assert ( + parse_args_in_yunohost_format({"some_boolean": True}, questions) == expected_result ) assert ( - _parse_args_in_yunohost_format({"some_boolean": True}, questions) + parse_args_in_yunohost_format({"some_boolean": "True"}, questions) == expected_result ) assert ( - _parse_args_in_yunohost_format({"some_boolean": "True"}, questions) + parse_args_in_yunohost_format({"some_boolean": "TRUE"}, questions) == expected_result ) assert ( - _parse_args_in_yunohost_format({"some_boolean": "TRUE"}, questions) - == expected_result - ) - assert ( - _parse_args_in_yunohost_format({"some_boolean": "true"}, questions) + parse_args_in_yunohost_format({"some_boolean": "true"}, questions) == expected_result ) -def test_parse_args_in_yunohost_format_boolean_all_no(): +def test_question_boolean_all_no(): questions = [ { "name": "some_boolean", @@ -857,53 +934,52 @@ def test_parse_args_in_yunohost_format_boolean_all_no(): ] expected_result = OrderedDict({"some_boolean": (0, "boolean")}) assert ( - _parse_args_in_yunohost_format({"some_boolean": "n"}, questions) + parse_args_in_yunohost_format({"some_boolean": "n"}, questions) == expected_result ) assert ( - _parse_args_in_yunohost_format({"some_boolean": "N"}, questions) + parse_args_in_yunohost_format({"some_boolean": "N"}, questions) == expected_result ) assert ( - _parse_args_in_yunohost_format({"some_boolean": "no"}, questions) + parse_args_in_yunohost_format({"some_boolean": "no"}, questions) == expected_result ) assert ( - _parse_args_in_yunohost_format({"some_boolean": "No"}, questions) + parse_args_in_yunohost_format({"some_boolean": "No"}, questions) == expected_result ) assert ( - _parse_args_in_yunohost_format({"some_boolean": "No"}, questions) + parse_args_in_yunohost_format({"some_boolean": "No"}, questions) == expected_result ) assert ( - _parse_args_in_yunohost_format({"some_boolean": "0"}, questions) + parse_args_in_yunohost_format({"some_boolean": "0"}, questions) == expected_result ) assert ( - _parse_args_in_yunohost_format({"some_boolean": 0}, questions) + parse_args_in_yunohost_format({"some_boolean": 0}, questions) == expected_result + ) + assert ( + parse_args_in_yunohost_format({"some_boolean": False}, questions) == expected_result ) assert ( - _parse_args_in_yunohost_format({"some_boolean": False}, questions) + parse_args_in_yunohost_format({"some_boolean": "False"}, questions) == expected_result ) assert ( - _parse_args_in_yunohost_format({"some_boolean": "False"}, questions) + parse_args_in_yunohost_format({"some_boolean": "FALSE"}, questions) == expected_result ) assert ( - _parse_args_in_yunohost_format({"some_boolean": "FALSE"}, questions) - == expected_result - ) - assert ( - _parse_args_in_yunohost_format({"some_boolean": "false"}, questions) + parse_args_in_yunohost_format({"some_boolean": "false"}, questions) == expected_result ) # XXX apparently boolean are always False (0) by default, I'm not sure what to think about that -def test_parse_args_in_yunohost_format_boolean_no_input(): +def test_question_boolean_no_input(): questions = [ { "name": "some_boolean", @@ -913,10 +989,11 @@ def test_parse_args_in_yunohost_format_boolean_no_input(): answers = {} expected_result = OrderedDict({"some_boolean": (0, "boolean")}) - assert _parse_args_in_yunohost_format(answers, questions) == expected_result + with patch.object(os, "isatty", return_value=False): + assert parse_args_in_yunohost_format(answers, questions) == expected_result -def test_parse_args_in_yunohost_format_boolean_bad_input(): +def test_question_boolean_bad_input(): questions = [ { "name": "some_boolean", @@ -925,11 +1002,11 @@ def test_parse_args_in_yunohost_format_boolean_bad_input(): ] answers = {"some_boolean": "stuff"} - with pytest.raises(YunohostError): - _parse_args_in_yunohost_format(answers, questions) + with pytest.raises(YunohostError), patch.object(os, "isatty", return_value=False): + parse_args_in_yunohost_format(answers, questions) -def test_parse_args_in_yunohost_format_boolean_input(): +def test_question_boolean_input(): questions = [ { "name": "some_boolean", @@ -940,15 +1017,19 @@ def test_parse_args_in_yunohost_format_boolean_input(): answers = {} expected_result = OrderedDict({"some_boolean": (1, "boolean")}) - with patch.object(Moulinette.interface, "prompt", return_value="y"): - assert _parse_args_in_yunohost_format(answers, questions) == expected_result + with patch.object(Moulinette, "prompt", return_value="y"), patch.object( + os, "isatty", return_value=True + ): + assert parse_args_in_yunohost_format(answers, questions) == expected_result expected_result = OrderedDict({"some_boolean": (0, "boolean")}) - with patch.object(Moulinette.interface, "prompt", return_value="n"): - assert _parse_args_in_yunohost_format(answers, questions) == expected_result + with patch.object(Moulinette, "prompt", return_value="n"), patch.object( + os, "isatty", return_value=True + ): + assert parse_args_in_yunohost_format(answers, questions) == expected_result -def test_parse_args_in_yunohost_format_boolean_input_no_ask(): +def test_question_boolean_input_no_ask(): questions = [ { "name": "some_boolean", @@ -958,11 +1039,13 @@ def test_parse_args_in_yunohost_format_boolean_input_no_ask(): answers = {} expected_result = OrderedDict({"some_boolean": (1, "boolean")}) - with patch.object(Moulinette.interface, "prompt", return_value="y"): - assert _parse_args_in_yunohost_format(answers, questions) == expected_result + with patch.object(Moulinette, "prompt", return_value="y"), patch.object( + os, "isatty", return_value=True + ): + assert parse_args_in_yunohost_format(answers, questions) == expected_result -def test_parse_args_in_yunohost_format_boolean_no_input_optional(): +def test_question_boolean_no_input_optional(): questions = [ { "name": "some_boolean", @@ -972,10 +1055,11 @@ def test_parse_args_in_yunohost_format_boolean_no_input_optional(): ] answers = {} expected_result = OrderedDict({"some_boolean": (0, "boolean")}) # default to false - assert _parse_args_in_yunohost_format(answers, questions) == expected_result + with patch.object(os, "isatty", return_value=False): + assert parse_args_in_yunohost_format(answers, questions) == expected_result -def test_parse_args_in_yunohost_format_boolean_optional_with_input(): +def test_question_boolean_optional_with_input(): questions = [ { "name": "some_boolean", @@ -987,11 +1071,13 @@ def test_parse_args_in_yunohost_format_boolean_optional_with_input(): answers = {} expected_result = OrderedDict({"some_boolean": (1, "boolean")}) - with patch.object(Moulinette.interface, "prompt", return_value="y"): - assert _parse_args_in_yunohost_format(answers, questions) == expected_result + with patch.object(Moulinette, "prompt", return_value="y"), patch.object( + os, "isatty", return_value=True + ): + assert parse_args_in_yunohost_format(answers, questions) == expected_result -def test_parse_args_in_yunohost_format_boolean_optional_with_empty_input(): +def test_question_boolean_optional_with_empty_input(): questions = [ { "name": "some_boolean", @@ -1003,11 +1089,13 @@ def test_parse_args_in_yunohost_format_boolean_optional_with_empty_input(): answers = {} expected_result = OrderedDict({"some_boolean": (0, "boolean")}) # default to false - with patch.object(Moulinette.interface, "prompt", return_value=""): - assert _parse_args_in_yunohost_format(answers, questions) == expected_result + with patch.object(Moulinette, "prompt", return_value=""), patch.object( + os, "isatty", return_value=True + ): + assert parse_args_in_yunohost_format(answers, questions) == expected_result -def test_parse_args_in_yunohost_format_boolean_optional_with_input_without_ask(): +def test_question_boolean_optional_with_input_without_ask(): questions = [ { "name": "some_boolean", @@ -1018,11 +1106,13 @@ def test_parse_args_in_yunohost_format_boolean_optional_with_input_without_ask() answers = {} expected_result = OrderedDict({"some_boolean": (0, "boolean")}) - with patch.object(Moulinette.interface, "prompt", return_value="n"): - assert _parse_args_in_yunohost_format(answers, questions) == expected_result + with patch.object(Moulinette, "prompt", return_value="n"), patch.object( + os, "isatty", return_value=True + ): + assert parse_args_in_yunohost_format(answers, questions) == expected_result -def test_parse_args_in_yunohost_format_boolean_no_input_default(): +def test_question_boolean_no_input_default(): questions = [ { "name": "some_boolean", @@ -1033,10 +1123,11 @@ def test_parse_args_in_yunohost_format_boolean_no_input_default(): ] answers = {} expected_result = OrderedDict({"some_boolean": (0, "boolean")}) - assert _parse_args_in_yunohost_format(answers, questions) == expected_result + with patch.object(os, "isatty", return_value=False): + assert parse_args_in_yunohost_format(answers, questions) == expected_result -def test_parse_args_in_yunohost_format_boolean_bad_default(): +def test_question_boolean_bad_default(): questions = [ { "name": "some_boolean", @@ -1047,10 +1138,10 @@ def test_parse_args_in_yunohost_format_boolean_bad_default(): ] answers = {} with pytest.raises(YunohostError): - _parse_args_in_yunohost_format(answers, questions) + parse_args_in_yunohost_format(answers, questions) -def test_parse_args_in_yunohost_format_boolean_input_test_ask(): +def test_question_boolean_input_test_ask(): ask_text = "some question" questions = [ { @@ -1061,12 +1152,20 @@ def test_parse_args_in_yunohost_format_boolean_input_test_ask(): ] answers = {} - with patch.object(Moulinette.interface, "prompt", return_value=0) as prompt: - _parse_args_in_yunohost_format(answers, questions) - prompt.assert_called_with(ask_text + " [yes | no] (default: no)", False) + with patch.object(Moulinette, "prompt", return_value=0) as prompt, patch.object( + os, "isatty", return_value=True + ): + parse_args_in_yunohost_format(answers, questions) + prompt.assert_called_with( + message=ask_text + " [yes | no]", + is_password=False, + confirm=False, + prefill="no", + is_multiline=False, + ) -def test_parse_args_in_yunohost_format_boolean_input_test_ask_with_default(): +def test_question_boolean_input_test_ask_with_default(): ask_text = "some question" default_text = 1 questions = [ @@ -1079,12 +1178,20 @@ def test_parse_args_in_yunohost_format_boolean_input_test_ask_with_default(): ] answers = {} - with patch.object(Moulinette.interface, "prompt", return_value=1) as prompt: - _parse_args_in_yunohost_format(answers, questions) - prompt.assert_called_with("%s [yes | no] (default: yes)" % ask_text, False) + with patch.object(Moulinette, "prompt", return_value=1) as prompt, patch.object( + os, "isatty", return_value=True + ): + parse_args_in_yunohost_format(answers, questions) + prompt.assert_called_with( + message=ask_text + " [yes | no]", + is_password=False, + confirm=False, + prefill="yes", + is_multiline=False, + ) -def test_parse_args_in_yunohost_format_domain_empty(): +def test_question_domain_empty(): questions = [ { "name": "some_domain", @@ -1097,11 +1204,15 @@ def test_parse_args_in_yunohost_format_domain_empty(): with patch.object( domain, "_get_maindomain", return_value="my_main_domain.com" - ), patch.object(domain, "domain_list", return_value={"domains": [main_domain]}): - assert _parse_args_in_yunohost_format(answers, questions) == expected_result + ), patch.object( + domain, "domain_list", return_value={"domains": [main_domain]} + ), patch.object( + os, "isatty", return_value=False + ): + assert parse_args_in_yunohost_format(answers, questions) == expected_result -def test_parse_args_in_yunohost_format_domain(): +def test_question_domain(): main_domain = "my_main_domain.com" domains = [main_domain] questions = [ @@ -1117,10 +1228,10 @@ def test_parse_args_in_yunohost_format_domain(): with patch.object( domain, "_get_maindomain", return_value=main_domain ), patch.object(domain, "domain_list", return_value={"domains": domains}): - assert _parse_args_in_yunohost_format(answers, questions) == expected_result + assert parse_args_in_yunohost_format(answers, questions) == expected_result -def test_parse_args_in_yunohost_format_domain_two_domains(): +def test_question_domain_two_domains(): main_domain = "my_main_domain.com" other_domain = "some_other_domain.tld" domains = [main_domain, other_domain] @@ -1137,7 +1248,7 @@ def test_parse_args_in_yunohost_format_domain_two_domains(): with patch.object( domain, "_get_maindomain", return_value=main_domain ), patch.object(domain, "domain_list", return_value={"domains": domains}): - assert _parse_args_in_yunohost_format(answers, questions) == expected_result + assert parse_args_in_yunohost_format(answers, questions) == expected_result answers = {"some_domain": main_domain} expected_result = OrderedDict({"some_domain": (main_domain, "domain")}) @@ -1145,10 +1256,10 @@ def test_parse_args_in_yunohost_format_domain_two_domains(): with patch.object( domain, "_get_maindomain", return_value=main_domain ), patch.object(domain, "domain_list", return_value={"domains": domains}): - assert _parse_args_in_yunohost_format(answers, questions) == expected_result + assert parse_args_in_yunohost_format(answers, questions) == expected_result -def test_parse_args_in_yunohost_format_domain_two_domains_wrong_answer(): +def test_question_domain_two_domains_wrong_answer(): main_domain = "my_main_domain.com" other_domain = "some_other_domain.tld" domains = [main_domain, other_domain] @@ -1164,11 +1275,13 @@ def test_parse_args_in_yunohost_format_domain_two_domains_wrong_answer(): with patch.object( domain, "_get_maindomain", return_value=main_domain ), patch.object(domain, "domain_list", return_value={"domains": domains}): - with pytest.raises(YunohostError): - _parse_args_in_yunohost_format(answers, questions) + with pytest.raises(YunohostError), patch.object( + os, "isatty", return_value=False + ): + parse_args_in_yunohost_format(answers, questions) -def test_parse_args_in_yunohost_format_domain_two_domains_default_no_ask(): +def test_question_domain_two_domains_default_no_ask(): main_domain = "my_main_domain.com" other_domain = "some_other_domain.tld" domains = [main_domain, other_domain] @@ -1184,11 +1297,15 @@ def test_parse_args_in_yunohost_format_domain_two_domains_default_no_ask(): with patch.object( domain, "_get_maindomain", return_value=main_domain - ), patch.object(domain, "domain_list", return_value={"domains": domains}): - assert _parse_args_in_yunohost_format(answers, questions) == expected_result + ), patch.object( + domain, "domain_list", return_value={"domains": domains} + ), patch.object( + os, "isatty", return_value=False + ): + assert parse_args_in_yunohost_format(answers, questions) == expected_result -def test_parse_args_in_yunohost_format_domain_two_domains_default(): +def test_question_domain_two_domains_default(): main_domain = "my_main_domain.com" other_domain = "some_other_domain.tld" domains = [main_domain, other_domain] @@ -1199,11 +1316,15 @@ def test_parse_args_in_yunohost_format_domain_two_domains_default(): with patch.object( domain, "_get_maindomain", return_value=main_domain - ), patch.object(domain, "domain_list", return_value={"domains": domains}): - assert _parse_args_in_yunohost_format(answers, questions) == expected_result + ), patch.object( + domain, "domain_list", return_value={"domains": domains} + ), patch.object( + os, "isatty", return_value=False + ): + assert parse_args_in_yunohost_format(answers, questions) == expected_result -def test_parse_args_in_yunohost_format_domain_two_domains_default_input(): +def test_question_domain_two_domains_default_input(): main_domain = "my_main_domain.com" other_domain = "some_other_domain.tld" domains = [main_domain, other_domain] @@ -1213,17 +1334,21 @@ def test_parse_args_in_yunohost_format_domain_two_domains_default_input(): with patch.object( domain, "_get_maindomain", return_value=main_domain - ), patch.object(domain, "domain_list", return_value={"domains": domains}): + ), patch.object( + domain, "domain_list", return_value={"domains": domains} + ), patch.object( + os, "isatty", return_value=True + ): expected_result = OrderedDict({"some_domain": (main_domain, "domain")}) - with patch.object(Moulinette.interface, "prompt", return_value=main_domain): - assert _parse_args_in_yunohost_format(answers, questions) == expected_result + with patch.object(Moulinette, "prompt", return_value=main_domain): + assert parse_args_in_yunohost_format(answers, questions) == expected_result expected_result = OrderedDict({"some_domain": (other_domain, "domain")}) - with patch.object(Moulinette.interface, "prompt", return_value=other_domain): - assert _parse_args_in_yunohost_format(answers, questions) == expected_result + with patch.object(Moulinette, "prompt", return_value=other_domain): + assert parse_args_in_yunohost_format(answers, questions) == expected_result -def test_parse_args_in_yunohost_format_user_empty(): +def test_question_user_empty(): users = { "some_user": { "ssh_allowed": False, @@ -1243,11 +1368,13 @@ def test_parse_args_in_yunohost_format_user_empty(): answers = {} with patch.object(user, "user_list", return_value={"users": users}): - with pytest.raises(YunohostError): - _parse_args_in_yunohost_format(answers, questions) + with pytest.raises(YunohostError), patch.object( + os, "isatty", return_value=False + ): + parse_args_in_yunohost_format(answers, questions) -def test_parse_args_in_yunohost_format_user(): +def test_question_user(): username = "some_user" users = { username: { @@ -1269,12 +1396,13 @@ def test_parse_args_in_yunohost_format_user(): expected_result = OrderedDict({"some_user": (username, "user")}) - with patch.object(user, "user_list", return_value={"users": users}): - with patch.object(user, "user_info", return_value={}): - assert _parse_args_in_yunohost_format(answers, questions) == expected_result + with patch.object(user, "user_list", return_value={"users": users}), patch.object( + user, "user_info", return_value={} + ): + assert parse_args_in_yunohost_format(answers, questions) == expected_result -def test_parse_args_in_yunohost_format_user_two_users(): +def test_question_user_two_users(): username = "some_user" other_user = "some_other_user" users = { @@ -1303,19 +1431,21 @@ def test_parse_args_in_yunohost_format_user_two_users(): answers = {"some_user": other_user} expected_result = OrderedDict({"some_user": (other_user, "user")}) - with patch.object(user, "user_list", return_value={"users": users}): - with patch.object(user, "user_info", return_value={}): - assert _parse_args_in_yunohost_format(answers, questions) == expected_result + with patch.object(user, "user_list", return_value={"users": users}), patch.object( + user, "user_info", return_value={} + ): + assert parse_args_in_yunohost_format(answers, questions) == expected_result answers = {"some_user": username} expected_result = OrderedDict({"some_user": (username, "user")}) - with patch.object(user, "user_list", return_value={"users": users}): - with patch.object(user, "user_info", return_value={}): - assert _parse_args_in_yunohost_format(answers, questions) == expected_result + with patch.object(user, "user_list", return_value={"users": users}), patch.object( + user, "user_info", return_value={} + ): + assert parse_args_in_yunohost_format(answers, questions) == expected_result -def test_parse_args_in_yunohost_format_user_two_users_wrong_answer(): +def test_question_user_two_users_wrong_answer(): username = "my_username.com" other_user = "some_other_user" users = { @@ -1344,11 +1474,13 @@ def test_parse_args_in_yunohost_format_user_two_users_wrong_answer(): answers = {"some_user": "doesnt_exist.pouet"} with patch.object(user, "user_list", return_value={"users": users}): - with pytest.raises(YunohostError): - _parse_args_in_yunohost_format(answers, questions) + with pytest.raises(YunohostError), patch.object( + os, "isatty", return_value=False + ): + parse_args_in_yunohost_format(answers, questions) -def test_parse_args_in_yunohost_format_user_two_users_no_default(): +def test_question_user_two_users_no_default(): username = "my_username.com" other_user = "some_other_user.tld" users = { @@ -1372,11 +1504,13 @@ def test_parse_args_in_yunohost_format_user_two_users_no_default(): answers = {} with patch.object(user, "user_list", return_value={"users": users}): - with pytest.raises(YunohostError): - _parse_args_in_yunohost_format(answers, questions) + with pytest.raises(YunohostError), patch.object( + os, "isatty", return_value=False + ): + parse_args_in_yunohost_format(answers, questions) -def test_parse_args_in_yunohost_format_user_two_users_default_input(): +def test_question_user_two_users_default_input(): username = "my_username.com" other_user = "some_other_user.tld" users = { @@ -1399,24 +1533,24 @@ def test_parse_args_in_yunohost_format_user_two_users_default_input(): questions = [{"name": "some_user", "type": "user", "ask": "choose a user"}] answers = {} - with patch.object(user, "user_list", return_value={"users": users}): + with patch.object(user, "user_list", return_value={"users": users}), patch.object( + os, "isatty", return_value=True + ): with patch.object(user, "user_info", return_value={}): expected_result = OrderedDict({"some_user": (username, "user")}) - with patch.object(Moulinette.interface, "prompt", return_value=username): + with patch.object(Moulinette, "prompt", return_value=username): assert ( - _parse_args_in_yunohost_format(answers, questions) - == expected_result + parse_args_in_yunohost_format(answers, questions) == expected_result ) expected_result = OrderedDict({"some_user": (other_user, "user")}) - with patch.object(Moulinette.interface, "prompt", return_value=other_user): + with patch.object(Moulinette, "prompt", return_value=other_user): assert ( - _parse_args_in_yunohost_format(answers, questions) - == expected_result + parse_args_in_yunohost_format(answers, questions) == expected_result ) -def test_parse_args_in_yunohost_format_number(): +def test_question_number(): questions = [ { "name": "some_number", @@ -1425,10 +1559,10 @@ def test_parse_args_in_yunohost_format_number(): ] answers = {"some_number": 1337} expected_result = OrderedDict({"some_number": (1337, "number")}) - assert _parse_args_in_yunohost_format(answers, questions) == expected_result + assert parse_args_in_yunohost_format(answers, questions) == expected_result -def test_parse_args_in_yunohost_format_number_no_input(): +def test_question_number_no_input(): questions = [ { "name": "some_number", @@ -1437,11 +1571,11 @@ def test_parse_args_in_yunohost_format_number_no_input(): ] answers = {} - expected_result = OrderedDict({"some_number": (0, "number")}) - assert _parse_args_in_yunohost_format(answers, questions) == expected_result + with pytest.raises(YunohostError), patch.object(os, "isatty", return_value=False): + parse_args_in_yunohost_format(answers, questions) -def test_parse_args_in_yunohost_format_number_bad_input(): +def test_question_number_bad_input(): questions = [ { "name": "some_number", @@ -1450,15 +1584,15 @@ def test_parse_args_in_yunohost_format_number_bad_input(): ] answers = {"some_number": "stuff"} - with pytest.raises(YunohostError): - _parse_args_in_yunohost_format(answers, questions) + with pytest.raises(YunohostError), patch.object(os, "isatty", return_value=False): + parse_args_in_yunohost_format(answers, questions) answers = {"some_number": 1.5} - with pytest.raises(YunohostError): - _parse_args_in_yunohost_format(answers, questions) + with pytest.raises(YunohostError), patch.object(os, "isatty", return_value=False): + parse_args_in_yunohost_format(answers, questions) -def test_parse_args_in_yunohost_format_number_input(): +def test_question_number_input(): questions = [ { "name": "some_number", @@ -1469,18 +1603,24 @@ def test_parse_args_in_yunohost_format_number_input(): answers = {} expected_result = OrderedDict({"some_number": (1337, "number")}) - with patch.object(Moulinette.interface, "prompt", return_value="1337"): - assert _parse_args_in_yunohost_format(answers, questions) == expected_result + with patch.object(Moulinette, "prompt", return_value="1337"), patch.object( + os, "isatty", return_value=True + ): + assert parse_args_in_yunohost_format(answers, questions) == expected_result - with patch.object(Moulinette.interface, "prompt", return_value=1337): - assert _parse_args_in_yunohost_format(answers, questions) == expected_result + with patch.object(Moulinette, "prompt", return_value=1337), patch.object( + os, "isatty", return_value=True + ): + assert parse_args_in_yunohost_format(answers, questions) == expected_result expected_result = OrderedDict({"some_number": (0, "number")}) - with patch.object(Moulinette.interface, "prompt", return_value=""): - assert _parse_args_in_yunohost_format(answers, questions) == expected_result + with patch.object(Moulinette, "prompt", return_value="0"), patch.object( + os, "isatty", return_value=True + ): + assert parse_args_in_yunohost_format(answers, questions) == expected_result -def test_parse_args_in_yunohost_format_number_input_no_ask(): +def test_question_number_input_no_ask(): questions = [ { "name": "some_number", @@ -1490,11 +1630,13 @@ def test_parse_args_in_yunohost_format_number_input_no_ask(): answers = {} expected_result = OrderedDict({"some_number": (1337, "number")}) - with patch.object(Moulinette.interface, "prompt", return_value="1337"): - assert _parse_args_in_yunohost_format(answers, questions) == expected_result + with patch.object(Moulinette, "prompt", return_value="1337"), patch.object( + os, "isatty", return_value=True + ): + assert parse_args_in_yunohost_format(answers, questions) == expected_result -def test_parse_args_in_yunohost_format_number_no_input_optional(): +def test_question_number_no_input_optional(): questions = [ { "name": "some_number", @@ -1503,11 +1645,12 @@ def test_parse_args_in_yunohost_format_number_no_input_optional(): } ] answers = {} - expected_result = OrderedDict({"some_number": (0, "number")}) # default to 0 - assert _parse_args_in_yunohost_format(answers, questions) == expected_result + expected_result = OrderedDict({"some_number": (None, "number")}) # default to 0 + with patch.object(os, "isatty", return_value=False): + assert parse_args_in_yunohost_format(answers, questions) == expected_result -def test_parse_args_in_yunohost_format_number_optional_with_input(): +def test_question_number_optional_with_input(): questions = [ { "name": "some_number", @@ -1519,11 +1662,13 @@ def test_parse_args_in_yunohost_format_number_optional_with_input(): answers = {} expected_result = OrderedDict({"some_number": (1337, "number")}) - with patch.object(Moulinette.interface, "prompt", return_value="1337"): - assert _parse_args_in_yunohost_format(answers, questions) == expected_result + with patch.object(Moulinette, "prompt", return_value="1337"), patch.object( + os, "isatty", return_value=True + ): + assert parse_args_in_yunohost_format(answers, questions) == expected_result -def test_parse_args_in_yunohost_format_number_optional_with_input_without_ask(): +def test_question_number_optional_with_input_without_ask(): questions = [ { "name": "some_number", @@ -1534,11 +1679,13 @@ def test_parse_args_in_yunohost_format_number_optional_with_input_without_ask(): answers = {} expected_result = OrderedDict({"some_number": (0, "number")}) - with patch.object(Moulinette.interface, "prompt", return_value="0"): - assert _parse_args_in_yunohost_format(answers, questions) == expected_result + with patch.object(Moulinette, "prompt", return_value="0"), patch.object( + os, "isatty", return_value=True + ): + assert parse_args_in_yunohost_format(answers, questions) == expected_result -def test_parse_args_in_yunohost_format_number_no_input_default(): +def test_question_number_no_input_default(): questions = [ { "name": "some_number", @@ -1549,10 +1696,11 @@ def test_parse_args_in_yunohost_format_number_no_input_default(): ] answers = {} expected_result = OrderedDict({"some_number": (1337, "number")}) - assert _parse_args_in_yunohost_format(answers, questions) == expected_result + with patch.object(os, "isatty", return_value=False): + assert parse_args_in_yunohost_format(answers, questions) == expected_result -def test_parse_args_in_yunohost_format_number_bad_default(): +def test_question_number_bad_default(): questions = [ { "name": "some_number", @@ -1562,11 +1710,11 @@ def test_parse_args_in_yunohost_format_number_bad_default(): } ] answers = {} - with pytest.raises(YunohostError): - _parse_args_in_yunohost_format(answers, questions) + with pytest.raises(YunohostError), patch.object(os, "isatty", return_value=False): + parse_args_in_yunohost_format(answers, questions) -def test_parse_args_in_yunohost_format_number_input_test_ask(): +def test_question_number_input_test_ask(): ask_text = "some question" questions = [ { @@ -1577,12 +1725,20 @@ def test_parse_args_in_yunohost_format_number_input_test_ask(): ] answers = {} - with patch.object(Moulinette.interface, "prompt", return_value="1111") as prompt: - _parse_args_in_yunohost_format(answers, questions) - prompt.assert_called_with("%s (default: 0)" % (ask_text), False) + with patch.object( + Moulinette, "prompt", return_value="1111" + ) as prompt, patch.object(os, "isatty", return_value=True): + parse_args_in_yunohost_format(answers, questions) + prompt.assert_called_with( + message=ask_text, + is_password=False, + confirm=False, + prefill="", + is_multiline=False, + ) -def test_parse_args_in_yunohost_format_number_input_test_ask_with_default(): +def test_question_number_input_test_ask_with_default(): ask_text = "some question" default_value = 1337 questions = [ @@ -1595,13 +1751,21 @@ def test_parse_args_in_yunohost_format_number_input_test_ask_with_default(): ] answers = {} - with patch.object(Moulinette.interface, "prompt", return_value="1111") as prompt: - _parse_args_in_yunohost_format(answers, questions) - prompt.assert_called_with("%s (default: %s)" % (ask_text, default_value), False) + with patch.object( + Moulinette, "prompt", return_value="1111" + ) as prompt, patch.object(os, "isatty", return_value=True): + parse_args_in_yunohost_format(answers, questions) + prompt.assert_called_with( + message=ask_text, + is_password=False, + confirm=False, + prefill=str(default_value), + is_multiline=False, + ) @pytest.mark.skip # we should do something with this example -def test_parse_args_in_yunohost_format_number_input_test_ask_with_example(): +def test_question_number_input_test_ask_with_example(): ask_text = "some question" example_value = 1337 questions = [ @@ -1614,14 +1778,16 @@ def test_parse_args_in_yunohost_format_number_input_test_ask_with_example(): ] answers = {} - with patch.object(Moulinette.interface, "prompt", return_value="1111") as prompt: - _parse_args_in_yunohost_format(answers, questions) - assert ask_text in prompt.call_args[0][0] - assert example_value in prompt.call_args[0][0] + with patch.object( + Moulinette, "prompt", return_value="1111" + ) as prompt, patch.object(os, "isatty", return_value=True): + parse_args_in_yunohost_format(answers, questions) + assert ask_text in prompt.call_args[1]["message"] + assert example_value in prompt.call_args[1]["message"] @pytest.mark.skip # we should do something with this help -def test_parse_args_in_yunohost_format_number_input_test_ask_with_help(): +def test_question_number_input_test_ask_with_help(): ask_text = "some question" help_value = 1337 questions = [ @@ -1634,16 +1800,20 @@ def test_parse_args_in_yunohost_format_number_input_test_ask_with_help(): ] answers = {} - with patch.object(Moulinette.interface, "prompt", return_value="1111") as prompt: - _parse_args_in_yunohost_format(answers, questions) - assert ask_text in prompt.call_args[0][0] - assert help_value in prompt.call_args[0][0] + with patch.object( + Moulinette, "prompt", return_value="1111" + ) as prompt, patch.object(os, "isatty", return_value=True): + parse_args_in_yunohost_format(answers, questions) + assert ask_text in prompt.call_args[1]["message"] + assert help_value in prompt.call_args[1]["message"] -def test_parse_args_in_yunohost_format_display_text(): +def test_question_display_text(): questions = [{"name": "some_app", "type": "display_text", "ask": "foobar"}] answers = {} - with patch.object(sys, "stdout", new_callable=StringIO) as stdout: - _parse_args_in_yunohost_format(answers, questions) + with patch.object(sys, "stdout", new_callable=StringIO) as stdout, patch.object( + os, "isatty", return_value=True + ): + parse_args_in_yunohost_format(answers, questions) assert "foobar" in stdout.getvalue() diff --git a/src/yunohost/tests/test_service.py b/src/yunohost/tests/test_service.py index 1f82dc8fd..88013a3fe 100644 --- a/src/yunohost/tests/test_service.py +++ b/src/yunohost/tests/test_service.py @@ -9,6 +9,7 @@ from yunohost.service import ( service_add, service_remove, service_log, + service_reload_or_restart, ) @@ -38,6 +39,10 @@ def clean(): _save_services(services) + if os.path.exists("/etc/nginx/conf.d/broken.conf"): + os.remove("/etc/nginx/conf.d/broken.conf") + os.system("systemctl reload-or-restart nginx") + def test_service_status_all(): @@ -118,3 +123,20 @@ def test_service_update_to_remove_properties(): assert _get_services()["dummyservice"].get("test_status") == "false" service_add("dummyservice", description="dummy", test_status="") assert not _get_services()["dummyservice"].get("test_status") + + +def test_service_conf_broken(): + + os.system("echo pwet > /etc/nginx/conf.d/broken.conf") + + status = service_status("nginx") + assert status["status"] == "running" + assert status["configuration"] == "broken" + assert "broken.conf" in status["configuration-details"][0] + + # Service reload-or-restart should check that the conf ain't valid + # before reload-or-restart, hence the service should still be running + service_reload_or_restart("nginx") + assert status["status"] == "running" + + os.remove("/etc/nginx/conf.d/broken.conf") diff --git a/src/yunohost/tools.py b/src/yunohost/tools.py index 5eebe2f51..8c113aee6 100644 --- a/src/yunohost/tools.py +++ b/src/yunohost/tools.py @@ -29,6 +29,7 @@ import subprocess import time from importlib import import_module from packaging import version +from typing import List from moulinette import Moulinette, m18n from moulinette.utils.log import getActionLogger @@ -1086,7 +1087,9 @@ class Migration(object): # Those are to be implemented by daughter classes mode = "auto" - dependencies = [] # List of migration ids required before running this migration + dependencies: List[ + str + ] = [] # List of migration ids required before running this migration @property def disclaimer(self): diff --git a/src/yunohost/user.py b/src/yunohost/user.py index d3e8ad131..413919d61 100644 --- a/src/yunohost/user.py +++ b/src/yunohost/user.py @@ -141,7 +141,7 @@ def user_create( from_import=False, ): - from yunohost.domain import domain_list, _get_maindomain + from yunohost.domain import domain_list, _get_maindomain, _assert_domain_exists from yunohost.hook import hook_callback from yunohost.utils.password import assert_password_is_strong_enough from yunohost.utils.ldap import _get_ldap_interface @@ -169,8 +169,7 @@ def user_create( domain = maindomain # Check that the domain exists - if domain not in domain_list()["domains"]: - raise YunohostValidationError("domain_name_unknown", domain=domain) + _assert_domain_exists(domain) mail = username + "@" + domain ldap = _get_ldap_interface() diff --git a/src/yunohost/utils/config.py b/src/yunohost/utils/config.py new file mode 100644 index 000000000..99c898d15 --- /dev/null +++ b/src/yunohost/utils/config.py @@ -0,0 +1,1081 @@ +# -*- coding: utf-8 -*- + +""" License + + Copyright (C) 2018 YUNOHOST.ORG + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program; if not, see http://www.gnu.org/licenses + +""" + +import os +import re +import urllib.parse +import tempfile +import shutil +from collections import OrderedDict +from typing import Optional, Dict, List + +from moulinette.interfaces.cli import colorize +from moulinette import Moulinette, m18n +from moulinette.utils.log import getActionLogger +from moulinette.utils.filesystem import ( + write_to_file, + read_toml, + read_yaml, + write_to_yaml, + mkdir, +) + +from yunohost.utils.i18n import _value_for_locale +from yunohost.utils.error import YunohostError, YunohostValidationError +from yunohost.log import OperationLogger + +logger = getActionLogger("yunohost.config") +CONFIG_PANEL_VERSION_SUPPORTED = 1.0 + + +class ConfigPanel: + def __init__(self, config_path, save_path=None): + self.config_path = config_path + self.save_path = save_path + self.config = {} + self.values = {} + self.new_values = {} + + def get(self, key="", mode="classic"): + self.filter_key = key or "" + + # Read config panel toml + self._get_config_panel() + + if not self.config: + raise YunohostValidationError("config_no_panel") + + # Read or get values and hydrate the config + self._load_current_values() + self._hydrate() + + # In 'classic' mode, we display the current value if key refer to an option + if self.filter_key.count(".") == 2 and mode == "classic": + option = self.filter_key.split(".")[-1] + return self.values.get(option, None) + + # Format result in 'classic' or 'export' mode + logger.debug(f"Formating result in '{mode}' mode") + result = {} + for panel, section, option in self._iterate(): + key = f"{panel['id']}.{section['id']}.{option['id']}" + if mode == "export": + result[option["id"]] = option.get("current_value") + continue + + ask = None + if "ask" in option: + ask = _value_for_locale(option["ask"]) + elif "i18n" in self.config: + ask = m18n.n(self.config["i18n"] + "_" + option["id"]) + + if mode == "full": + # edit self.config directly + option["ask"] = ask + else: + result[key] = {"ask": ask} + if "current_value" in option: + question_class = ARGUMENTS_TYPE_PARSERS[ + option.get("type", "string") + ] + result[key]["value"] = question_class.humanize( + option["current_value"], option + ) + + if mode == "full": + return self.config + else: + return result + + def set( + self, key=None, value=None, args=None, args_file=None, operation_logger=None + ): + self.filter_key = key or "" + + # Read config panel toml + self._get_config_panel() + + if not self.config: + raise YunohostValidationError("config_no_panel") + + if (args is not None or args_file is not None) and value is not None: + raise YunohostValidationError( + "You should either provide a value, or a serie of args/args_file, but not both at the same time", + raw_msg=True, + ) + + if self.filter_key.count(".") != 2 and value is not None: + raise YunohostValidationError("config_cant_set_value_on_section") + + # Import and parse pre-answered options + logger.debug("Import and parse pre-answered options") + args = urllib.parse.parse_qs(args or "", keep_blank_values=True) + self.args = {key: ",".join(value_) for key, value_ in args.items()} + + if args_file: + # Import YAML / JSON file but keep --args values + self.args = {**read_yaml(args_file), **self.args} + + if value is not None: + self.args = {self.filter_key.split(".")[-1]: value} + + # Read or get values and hydrate the config + self._load_current_values() + self._hydrate() + self._ask() + + if operation_logger: + operation_logger.start() + + try: + self._apply() + except YunohostError: + raise + # Script got manually interrupted ... + # N.B. : KeyboardInterrupt does not inherit from Exception + except (KeyboardInterrupt, EOFError): + error = m18n.n("operation_interrupted") + logger.error(m18n.n("config_apply_failed", error=error)) + raise + # Something wrong happened in Yunohost's code (most probably hook_exec) + except Exception: + import traceback + + error = m18n.n("unexpected_error", error="\n" + traceback.format_exc()) + logger.error(m18n.n("config_apply_failed", error=error)) + raise + finally: + # Delete files uploaded from API + FileQuestion.clean_upload_dirs() + + self._reload_services() + + logger.success("Config updated as expected") + operation_logger.success() + + def _get_toml(self): + return read_toml(self.config_path) + + def _get_config_panel(self): + + # Split filter_key + filter_key = self.filter_key.split(".") if self.filter_key != "" else [] + if len(filter_key) > 3: + raise YunohostError( + f"The filter key {filter_key} has too many sub-levels, the max is 3.", + raw_msg=True, + ) + + if not os.path.exists(self.config_path): + logger.debug(f"Config panel {self.config_path} doesn't exists") + return None + + toml_config_panel = self._get_toml() + + # Check TOML config panel is in a supported version + if float(toml_config_panel["version"]) < CONFIG_PANEL_VERSION_SUPPORTED: + raise YunohostError( + "config_version_not_supported", version=toml_config_panel["version"] + ) + + # Transform toml format into internal format + format_description = { + "toml": { + "properties": ["version", "i18n"], + "default": {"version": 1.0}, + }, + "panels": { + "properties": ["name", "services", "actions", "help"], + "default": { + "services": [], + "actions": {"apply": {"en": "Apply"}}, + }, + }, + "sections": { + "properties": ["name", "services", "optional", "help", "visible"], + "default": { + "name": "", + "services": [], + "optional": True, + }, + }, + "options": { + "properties": [ + "ask", + "type", + "bind", + "help", + "example", + "default", + "style", + "icon", + "placeholder", + "visible", + "optional", + "choices", + "yes", + "no", + "pattern", + "limit", + "min", + "max", + "step", + "accept", + "redact", + ], + "default": {}, + }, + } + + def convert(toml_node, node_type): + """Convert TOML in internal format ('full' mode used by webadmin) + Here are some properties of 1.0 config panel in toml: + - node properties and node children are mixed, + - text are in english only + - some properties have default values + This function detects all children nodes and put them in a list + """ + # Prefill the node default keys if needed + default = format_description[node_type]["default"] + node = {key: toml_node.get(key, value) for key, value in default.items()} + + properties = format_description[node_type]["properties"] + + # Define the filter_key part to use and the children type + i = list(format_description).index(node_type) + subnode_type = ( + list(format_description)[i + 1] if node_type != "options" else None + ) + search_key = filter_key[i] if len(filter_key) > i else False + + for key, value in toml_node.items(): + # Key/value are a child node + if ( + isinstance(value, OrderedDict) + and key not in properties + and subnode_type + ): + # We exclude all nodes not referenced by the filter_key + if search_key and key != search_key: + continue + subnode = convert(value, subnode_type) + subnode["id"] = key + if node_type == "toml": + subnode.setdefault("name", {"en": key.capitalize()}) + elif node_type == "sections": + subnode["name"] = key # legacy + subnode.setdefault("optional", toml_node.get("optional", True)) + node.setdefault(subnode_type, []).append(subnode) + # Key/value are a property + else: + if key not in properties: + logger.warning(f"Unknown key '{key}' found in config toml") + # Todo search all i18n keys + node[key] = ( + value if key not in ["ask", "help", "name"] else {"en": value} + ) + return node + + self.config = convert(toml_config_panel, "toml") + + try: + self.config["panels"][0]["sections"][0]["options"][0] + except (KeyError, IndexError): + raise YunohostValidationError( + "config_unknown_filter_key", filter_key=self.filter_key + ) + + # List forbidden keywords from helpers and sections toml (to avoid conflict) + forbidden_keywords = [ + "old", + "app", + "changed", + "file_hash", + "binds", + "types", + "formats", + "getter", + "setter", + "short_setting", + "type", + "bind", + "nothing_changed", + "changes_validated", + "result", + "max_progression", + ] + forbidden_keywords += format_description["sections"] + + for _, _, option in self._iterate(): + if option["id"] in forbidden_keywords: + raise YunohostError("config_forbidden_keyword", keyword=option["id"]) + return self.config + + def _hydrate(self): + # Hydrating config panel with current value + logger.debug("Hydrating config with current values") + for _, _, option in self._iterate(): + if option["id"] not in self.values: + allowed_empty_types = ["alert", "display_text", "markdown", "file"] + if ( + option["type"] in allowed_empty_types + or option.get("bind") == "null" + ): + continue + else: + raise YunohostError( + f"Config panel question '{option['id']}' should be initialized with a value during install or upgrade.", + raw_msg=True, + ) + value = self.values[option["name"]] + # In general, the value is just a simple value. + # Sometimes it could be a dict used to overwrite the option itself + value = value if isinstance(value, dict) else {"current_value": value} + option.update(value) + + return self.values + + def _ask(self): + logger.debug("Ask unanswered question and prevalidate data") + + if "i18n" in self.config: + for panel, section, option in self._iterate(): + if "ask" not in option: + option["ask"] = m18n.n(self.config["i18n"] + "_" + option["id"]) + + def display_header(message): + """CLI panel/section header display""" + if Moulinette.interface.type == "cli" and self.filter_key.count(".") < 2: + Moulinette.display(colorize(message, "purple")) + + for panel, section, obj in self._iterate(["panel", "section"]): + if panel == obj: + name = _value_for_locale(panel["name"]) + display_header(f"\n{'='*40}\n>>>> {name}\n{'='*40}") + continue + name = _value_for_locale(section["name"]) + if name: + display_header(f"\n# {name}") + + # Check and ask unanswered questions + self.new_values.update( + parse_args_in_yunohost_format(self.args, section["options"]) + ) + self.new_values = { + key: value[0] + for key, value in self.new_values.items() + if not value[0] is None + } + self.errors = None + + def _get_default_values(self): + return { + option["id"]: option["default"] + for _, _, option in self._iterate() + if "default" in option + } + + def _load_current_values(self): + """ + Retrieve entries in YAML file + And set default values if needed + """ + + # Retrieve entries in the YAML + on_disk_settings = {} + if os.path.exists(self.save_path) and os.path.isfile(self.save_path): + on_disk_settings = read_yaml(self.save_path) or {} + + # Inject defaults if needed (using the magic .update() ;)) + self.values = self._get_default_values() + self.values.update(on_disk_settings) + + def _apply(self): + logger.info("Saving the new configuration...") + dir_path = os.path.dirname(os.path.realpath(self.save_path)) + if not os.path.exists(dir_path): + mkdir(dir_path, mode=0o700) + + values_to_save = {**self.values, **self.new_values} + if self.save_mode == "diff": + defaults = self._get_default_values() + values_to_save = { + k: v for k, v in values_to_save.items() if defaults.get(k) != v + } + + # Save the settings to the .yaml file + write_to_yaml(self.save_path, values_to_save) + + def _reload_services(self): + + from yunohost.service import service_reload_or_restart + + services_to_reload = set() + for panel, section, obj in self._iterate(["panel", "section", "option"]): + services_to_reload |= set(obj.get("services", [])) + + services_to_reload = list(services_to_reload) + services_to_reload.sort(key="nginx".__eq__) + if services_to_reload: + logger.info("Reloading services...") + for service in services_to_reload: + if hasattr(self, "app"): + service = service.replace("__APP__", self.app) + service_reload_or_restart(service) + + def _iterate(self, trigger=["option"]): + for panel in self.config.get("panels", []): + if "panel" in trigger: + yield (panel, None, panel) + for section in panel.get("sections", []): + if "section" in trigger: + yield (panel, section, section) + if "option" in trigger: + for option in section.get("options", []): + yield (panel, section, option) + + +class Question(object): + hide_user_input_in_prompt = False + pattern: Optional[Dict] = None + + def __init__(self, question, user_answers): + self.name = question["name"] + self.type = question.get("type", "string") + self.default = question.get("default", None) + self.current_value = question.get("current_value") + self.optional = question.get("optional", False) + self.choices = question.get("choices", []) + self.pattern = question.get("pattern", self.pattern) + self.ask = question.get("ask", {"en": self.name}) + self.help = question.get("help") + self.value = user_answers.get(self.name) + self.redact = question.get("redact", False) + + # Empty value is parsed as empty string + if self.default == "": + self.default = None + + @staticmethod + def humanize(value, option={}): + return str(value) + + @staticmethod + def normalize(value, option={}): + return value + + def _prompt(self, text): + prefill = "" + if self.current_value is not None: + prefill = self.humanize(self.current_value, self) + elif self.default is not None: + prefill = self.humanize(self.default, self) + self.value = Moulinette.prompt( + message=text, + is_password=self.hide_user_input_in_prompt, + confirm=False, # We doesn't want to confirm this kind of password like in webadmin + prefill=prefill, + is_multiline=(self.type == "text"), + ) + + def ask_if_needed(self): + for i in range(5): + # Display question if no value filled or if it's a readonly message + if Moulinette.interface.type == "cli" and os.isatty(1): + text_for_user_input_in_cli = self._format_text_for_user_input_in_cli() + if getattr(self, "readonly", False): + Moulinette.display(text_for_user_input_in_cli) + elif self.value is None: + self._prompt(text_for_user_input_in_cli) + + # Apply default value + class_default = getattr(self, "default_value", None) + if self.value in [None, ""] and ( + self.default is not None or class_default is not None + ): + self.value = class_default if self.default is None else self.default + + # Normalization + # This is done to enforce a certain formating like for boolean + self.value = self.normalize(self.value, self) + + # Prevalidation + try: + self._prevalidate() + except YunohostValidationError as e: + # If in interactive cli, re-ask the current question + if i < 4 and Moulinette.interface.type == "cli" and os.isatty(1): + logger.error(str(e)) + self.value = None + continue + + # Otherwise raise the ValidationError + raise + + break + self.value = self._post_parse_value() + + return (self.value, self.argument_type) + + def _prevalidate(self): + if self.value in [None, ""] and not self.optional: + raise YunohostValidationError("app_argument_required", name=self.name) + + # we have an answer, do some post checks + if self.value not in [None, ""]: + if self.choices and self.value not in self.choices: + self._raise_invalid_answer() + if self.pattern and not re.match(self.pattern["regexp"], str(self.value)): + raise YunohostValidationError( + self.pattern["error"], + name=self.name, + value=self.value, + ) + + def _raise_invalid_answer(self): + raise YunohostValidationError( + "app_argument_choice_invalid", + name=self.name, + value=self.value, + choices=", ".join(self.choices), + ) + + def _format_text_for_user_input_in_cli(self, column=False): + text_for_user_input_in_cli = _value_for_locale(self.ask) + + if self.choices: + text_for_user_input_in_cli += " [{0}]".format(" | ".join(self.choices)) + + if self.help or column: + text_for_user_input_in_cli += ":\033[m" + if self.help: + text_for_user_input_in_cli += "\n - " + text_for_user_input_in_cli += _value_for_locale(self.help) + return text_for_user_input_in_cli + + def _post_parse_value(self): + if not self.redact: + return self.value + + # Tell the operation_logger to redact all password-type / secret args + # Also redact the % escaped version of the password that might appear in + # the 'args' section of metadata (relevant for password with non-alphanumeric char) + data_to_redact = [] + if self.value and isinstance(self.value, str): + data_to_redact.append(self.value) + if self.current_value and isinstance(self.current_value, str): + data_to_redact.append(self.current_value) + data_to_redact += [ + urllib.parse.quote(data) + for data in data_to_redact + if urllib.parse.quote(data) != data + ] + + for operation_logger in OperationLogger._instances: + operation_logger.data_to_redact.extend(data_to_redact) + + return self.value + + +class StringQuestion(Question): + argument_type = "string" + default_value = "" + + +class EmailQuestion(StringQuestion): + pattern = { + "regexp": r"^.+@.+", + "error": "config_validate_email", # i18n: config_validate_email + } + + +class URLQuestion(StringQuestion): + pattern = { + "regexp": r"^https?://.*$", + "error": "config_validate_url", # i18n: config_validate_url + } + + +class DateQuestion(StringQuestion): + pattern = { + "regexp": r"^\d{4}-\d\d-\d\d$", + "error": "config_validate_date", # i18n: config_validate_date + } + + def _prevalidate(self): + from datetime import datetime + + super()._prevalidate() + + if self.value not in [None, ""]: + try: + datetime.strptime(self.value, "%Y-%m-%d") + except ValueError: + raise YunohostValidationError("config_validate_date") + + +class TimeQuestion(StringQuestion): + pattern = { + "regexp": r"^(1[12]|0?\d):[0-5]\d$", + "error": "config_validate_time", # i18n: config_validate_time + } + + +class ColorQuestion(StringQuestion): + pattern = { + "regexp": r"^#[ABCDEFabcdef\d]{3,6}$", + "error": "config_validate_color", # i18n: config_validate_color + } + + +class TagsQuestion(Question): + argument_type = "tags" + + @staticmethod + def humanize(value, option={}): + if isinstance(value, list): + return ",".join(value) + return value + + @staticmethod + def normalize(value, option={}): + if isinstance(value, list): + return ",".join(value) + return value + + def _prevalidate(self): + values = self.value + if isinstance(values, str): + values = values.split(",") + elif values is None: + values = [] + for value in values: + self.value = value + super()._prevalidate() + self.value = values + + def _post_parse_value(self): + if isinstance(self.value, list): + self.value = ",".join(self.value) + return super()._post_parse_value() + + +class PasswordQuestion(Question): + hide_user_input_in_prompt = True + argument_type = "password" + default_value = "" + forbidden_chars = "{}" + + def __init__(self, question, user_answers): + super().__init__(question, user_answers) + self.redact = True + if self.default is not None: + raise YunohostValidationError( + "app_argument_password_no_default", name=self.name + ) + + @staticmethod + def humanize(value, option={}): + if value: + return "********" # Avoid to display the password on screen + return "" + + def _prevalidate(self): + super()._prevalidate() + + if self.value not in [None, ""]: + if any(char in self.value for char in self.forbidden_chars): + raise YunohostValidationError( + "pattern_password_app", forbidden_chars=self.forbidden_chars + ) + + # If it's an optional argument the value should be empty or strong enough + from yunohost.utils.password import assert_password_is_strong_enough + + assert_password_is_strong_enough("user", self.value) + + def _format_text_for_user_input_in_cli(self): + need_column = self.current_value or self.optional + text_for_user_input_in_cli = super()._format_text_for_user_input_in_cli( + need_column + ) + if self.current_value: + text_for_user_input_in_cli += "\n - " + m18n.n( + "app_argument_password_help_keep" + ) + if self.optional: + text_for_user_input_in_cli += "\n - " + m18n.n( + "app_argument_password_help_optional" + ) + + return text_for_user_input_in_cli + + def _prompt(self, text): + super()._prompt(text) + if self.current_value and self.value == "": + self.value = self.current_value + elif self.value == " ": + self.value = "" + + +class PathQuestion(Question): + argument_type = "path" + default_value = "" + + +class BooleanQuestion(Question): + argument_type = "boolean" + default_value = 0 + yes_answers = ["1", "yes", "y", "true", "t", "on"] + no_answers = ["0", "no", "n", "false", "f", "off"] + + @staticmethod + def humanize(value, option={}): + + yes = option.get("yes", 1) + no = option.get("no", 0) + value = str(value).lower() + if value == str(yes).lower(): + return "yes" + if value == str(no).lower(): + return "no" + if value in BooleanQuestion.yes_answers: + return "yes" + if value in BooleanQuestion.no_answers: + return "no" + + if value in ["none", ""]: + return "" + + raise YunohostValidationError( + "app_argument_choice_invalid", + name=option.get("name", ""), + value=value, + choices="yes, no, y, n, 1, 0", + ) + + @staticmethod + def normalize(value, option={}): + yes = option.get("yes", 1) + no = option.get("no", 0) + + if str(value).lower() in BooleanQuestion.yes_answers: + return yes + + if str(value).lower() in BooleanQuestion.no_answers: + return no + + if value in [None, ""]: + return None + raise YunohostValidationError( + "app_argument_choice_invalid", + name=option.get("name", ""), + value=value, + choices="yes, no, y, n, 1, 0", + ) + + def __init__(self, question, user_answers): + super().__init__(question, user_answers) + self.yes = question.get("yes", 1) + self.no = question.get("no", 0) + if self.default is None: + self.default = self.no + + def _format_text_for_user_input_in_cli(self): + text_for_user_input_in_cli = super()._format_text_for_user_input_in_cli() + + text_for_user_input_in_cli += " [yes | no]" + + return text_for_user_input_in_cli + + def get(self, key, default=None): + try: + return getattr(self, key) + except AttributeError: + return default + + +class DomainQuestion(Question): + argument_type = "domain" + + def __init__(self, question, user_answers): + from yunohost.domain import domain_list, _get_maindomain + + super().__init__(question, user_answers) + + if self.default is None: + self.default = _get_maindomain() + + self.choices = domain_list()["domains"] + + def _raise_invalid_answer(self): + raise YunohostValidationError( + "app_argument_invalid", + name=self.name, + error=m18n.n("domain_name_unknown", domain=self.value), + ) + + +class UserQuestion(Question): + argument_type = "user" + + def __init__(self, question, user_answers): + from yunohost.user import user_list, user_info + from yunohost.domain import _get_maindomain + + super().__init__(question, user_answers) + self.choices = user_list()["users"] + + if not self.choices: + raise YunohostValidationError( + "app_argument_invalid", + name=self.name, + error="You should create a YunoHost user first.", + ) + + if self.default is None: + root_mail = "root@%s" % _get_maindomain() + for user in self.choices.keys(): + if root_mail in user_info(user).get("mail-aliases", []): + self.default = user + break + + def _raise_invalid_answer(self): + raise YunohostValidationError( + "app_argument_invalid", + name=self.name, + error=m18n.n("user_unknown", user=self.value), + ) + + +class NumberQuestion(Question): + argument_type = "number" + default_value = None + + def __init__(self, question, user_answers): + super().__init__(question, user_answers) + self.min = question.get("min", None) + self.max = question.get("max", None) + self.step = question.get("step", None) + + @staticmethod + def normalize(value, option={}): + if isinstance(value, int): + return value + + if isinstance(value, str) and value.isdigit(): + return int(value) + + if value in [None, ""]: + return value + + raise YunohostValidationError( + "app_argument_invalid", name=option.name, error=m18n.n("invalid_number") + ) + + def _prevalidate(self): + super()._prevalidate() + if self.value in [None, ""]: + return + + if self.min is not None and int(self.value) < self.min: + raise YunohostValidationError( + "app_argument_invalid", + name=self.name, + error=m18n.n("invalid_number_min", min=self.min), + ) + + if self.max is not None and int(self.value) > self.max: + raise YunohostValidationError( + "app_argument_invalid", + name=self.name, + error=m18n.n("invalid_number_max", max=self.max), + ) + + +class DisplayTextQuestion(Question): + argument_type = "display_text" + readonly = True + + def __init__(self, question, user_answers): + super().__init__(question, user_answers) + + self.optional = True + self.style = question.get( + "style", "info" if question["type"] == "alert" else "" + ) + + def _format_text_for_user_input_in_cli(self): + text = _value_for_locale(self.ask) + + if self.style in ["success", "info", "warning", "danger"]: + color = { + "success": "green", + "info": "cyan", + "warning": "yellow", + "danger": "red", + } + prompt = m18n.g(self.style) if self.style != "danger" else m18n.n("danger") + return colorize(prompt, color[self.style]) + f" {text}" + else: + return text + + +class FileQuestion(Question): + argument_type = "file" + upload_dirs: List[str] = [] + + @classmethod + def clean_upload_dirs(cls): + # Delete files uploaded from API + if Moulinette.interface.type == "api": + for upload_dir in cls.upload_dirs: + if os.path.exists(upload_dir): + shutil.rmtree(upload_dir) + + def __init__(self, question, user_answers): + super().__init__(question, user_answers) + if question.get("accept"): + self.accept = question.get("accept") + else: + self.accept = "" + if Moulinette.interface.type == "api": + if user_answers.get(f"{self.name}[name]"): + self.value = { + "content": self.value, + "filename": user_answers.get(f"{self.name}[name]", self.name), + } + + def _prevalidate(self): + if self.value is None: + self.value = self.current_value + + super()._prevalidate() + if ( + isinstance(self.value, str) + and self.value + and not os.path.exists(self.value) + ): + raise YunohostValidationError( + "app_argument_invalid", + name=self.name, + error=m18n.n("file_does_not_exist", path=self.value), + ) + if self.value in [None, ""] or not self.accept: + return + + filename = self.value if isinstance(self.value, str) else self.value["filename"] + if "." not in filename or "." + filename.split(".")[ + -1 + ] not in self.accept.replace(" ", "").split(","): + raise YunohostValidationError( + "app_argument_invalid", + name=self.name, + error=m18n.n( + "file_extension_not_accepted", file=filename, accept=self.accept + ), + ) + + def _post_parse_value(self): + from base64 import b64decode + + # Upload files from API + # A file arg contains a string with "FILENAME:BASE64_CONTENT" + if not self.value: + return self.value + + if Moulinette.interface.type == "api" and isinstance(self.value, dict): + + upload_dir = tempfile.mkdtemp(prefix="tmp_configpanel_") + FileQuestion.upload_dirs += [upload_dir] + filename = self.value["filename"] + logger.debug( + f"Save uploaded file {self.value['filename']} from API into {upload_dir}" + ) + + # Filename is given by user of the API. For security reason, we have replaced + # os.path.join to avoid the user to be able to rewrite a file in filesystem + # i.e. os.path.join("/foo", "/etc/passwd") == "/etc/passwd" + file_path = os.path.normpath(upload_dir + "/" + filename) + if not file_path.startswith(upload_dir + "/"): + raise YunohostError( + f"Filename '{filename}' received from the API got a relative parent path, which is forbidden", + raw_msg=True, + ) + i = 2 + while os.path.exists(file_path): + file_path = os.path.normpath(upload_dir + "/" + filename + (".%d" % i)) + i += 1 + + content = self.value["content"] + + write_to_file(file_path, b64decode(content), file_mode="wb") + + self.value = file_path + return self.value + + +ARGUMENTS_TYPE_PARSERS = { + "string": StringQuestion, + "text": StringQuestion, + "select": StringQuestion, + "tags": TagsQuestion, + "email": EmailQuestion, + "url": URLQuestion, + "date": DateQuestion, + "time": TimeQuestion, + "color": ColorQuestion, + "password": PasswordQuestion, + "path": PathQuestion, + "boolean": BooleanQuestion, + "domain": DomainQuestion, + "user": UserQuestion, + "number": NumberQuestion, + "range": NumberQuestion, + "display_text": DisplayTextQuestion, + "alert": DisplayTextQuestion, + "markdown": DisplayTextQuestion, + "file": FileQuestion, +} + + +def parse_args_in_yunohost_format(user_answers, argument_questions): + """Parse arguments store in either manifest.json or actions.json or from a + config panel against the user answers when they are present. + + Keyword arguments: + user_answers -- a dictionnary of arguments from the user (generally + empty in CLI, filed from the admin interface) + argument_questions -- the arguments description store in yunohost + format from actions.json/toml, manifest.json/toml + or config_panel.json/toml + """ + parsed_answers_dict = OrderedDict() + + for question in argument_questions: + question_class = ARGUMENTS_TYPE_PARSERS[question.get("type", "string")] + question = question_class(question, user_answers) + + answer = question.ask_if_needed() + if answer is not None: + parsed_answers_dict[question.name] = answer + + return parsed_answers_dict diff --git a/src/yunohost/utils/dns.py b/src/yunohost/utils/dns.py new file mode 100644 index 000000000..3db75f949 --- /dev/null +++ b/src/yunohost/utils/dns.py @@ -0,0 +1,95 @@ +# -*- coding: utf-8 -*- + +""" License + + Copyright (C) 2018 YUNOHOST.ORG + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program; if not, see http://www.gnu.org/licenses + +""" +import dns.resolver +from typing import List + +from moulinette.utils.filesystem import read_file + +YNH_DYNDNS_DOMAINS = ["nohost.me", "noho.st", "ynh.fr"] + +# Lazy dev caching to avoid re-reading the file multiple time when calling +# dig() often during same yunohost operation +external_resolvers_: List[str] = [] + + +def external_resolvers(): + + global external_resolvers_ + + if not external_resolvers_: + resolv_dnsmasq_conf = read_file("/etc/resolv.dnsmasq.conf").split("\n") + external_resolvers_ = [ + r.split(" ")[1] for r in resolv_dnsmasq_conf if r.startswith("nameserver") + ] + # We keep only ipv4 resolvers, otherwise on IPv4-only instances, IPv6 + # will be tried anyway resulting in super-slow dig requests that'll wait + # until timeout... + external_resolvers_ = [r for r in external_resolvers_ if ":" not in r] + + return external_resolvers_ + + +def dig( + qname, rdtype="A", timeout=5, resolvers="local", edns_size=1500, full_answers=False +): + """ + Do a quick DNS request and avoid the "search" trap inside /etc/resolv.conf + """ + + # It's very important to do the request with a qname ended by . + # If we don't and the domain fail, dns resolver try a second request + # by concatenate the qname with the end of the "hostname" + if not qname.endswith("."): + qname += "." + + if resolvers == "local": + resolvers = ["127.0.0.1"] + elif resolvers == "force_external": + resolvers = external_resolvers() + else: + assert isinstance(resolvers, list) + + resolver = dns.resolver.Resolver(configure=False) + resolver.use_edns(0, 0, edns_size) + resolver.nameservers = resolvers + # resolver.timeout is used to trigger the next DNS query on resolvers list. + # In python-dns 1.16, this value is set to 2.0. However, this means that if + # the 3 first dns resolvers in list are down, we wait 6 seconds before to + # run the DNS query to a DNS resolvers up... + # In diagnosis dnsrecords, with 10 domains this means at least 12min, too long. + resolver.timeout = 1.0 + # resolver.lifetime is the timeout for resolver.query() + # By default set it to 5 seconds to allow 4 resolvers to be unreachable. + resolver.lifetime = timeout + try: + answers = resolver.query(qname, rdtype) + except ( + dns.resolver.NXDOMAIN, + dns.resolver.NoNameservers, + dns.resolver.NoAnswer, + dns.exception.Timeout, + ) as e: + return ("nok", (e.__class__.__name__, e)) + + if not full_answers: + answers = [answer.to_text() for answer in answers] + + return ("ok", answers) diff --git a/src/yunohost/utils/error.py b/src/yunohost/utils/error.py index f9b4ac61a..8405830e7 100644 --- a/src/yunohost/utils/error.py +++ b/src/yunohost/utils/error.py @@ -59,4 +59,4 @@ class YunohostValidationError(YunohostError): def content(self): - return {"error": self.strerror, "error_key": self.key} + return {"error": self.strerror, "error_key": self.key, **self.kwargs} diff --git a/src/yunohost/utils/i18n.py b/src/yunohost/utils/i18n.py new file mode 100644 index 000000000..a0daf8181 --- /dev/null +++ b/src/yunohost/utils/i18n.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- + +""" License + + Copyright (C) 2018 YUNOHOST.ORG + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program; if not, see http://www.gnu.org/licenses + +""" +from moulinette import m18n + + +def _value_for_locale(values): + """ + Return proper value for current locale + + Keyword arguments: + values -- A dict of values associated to their locale + + Returns: + An utf-8 encoded string + + """ + if not isinstance(values, dict): + return values + + for lang in [m18n.locale, m18n.default_locale]: + try: + return values[lang] + except KeyError: + continue + + # Fallback to first value + return list(values.values())[0] diff --git a/src/yunohost/utils/ldap.py b/src/yunohost/utils/ldap.py index 4f571ce6f..651d09f75 100644 --- a/src/yunohost/utils/ldap.py +++ b/src/yunohost/utils/ldap.py @@ -101,7 +101,8 @@ class LDAPInterface: except ldap.SERVER_DOWN: raise YunohostError( "Service slapd is not running but is required to perform this action ... " - "You can try to investigate what's happening with 'systemctl status slapd'" + "You can try to investigate what's happening with 'systemctl status slapd'", + raw_msg=True, ) # Check that we are indeed logged in with the right identity @@ -289,7 +290,7 @@ class LDAPInterface: attr_found[0], attr_found[1], ) - raise MoulinetteError( + raise YunohostError( "ldap_attribute_already_exists", attribute=attr_found[0], value=attr_found[1], diff --git a/src/yunohost/utils/network.py b/src/yunohost/utils/network.py index e332a5a25..4474af14f 100644 --- a/src/yunohost/utils/network.py +++ b/src/yunohost/utils/network.py @@ -22,7 +22,6 @@ import os import re import logging import time -import dns.resolver from moulinette.utils.filesystem import read_file, write_to_file from moulinette.utils.network import download_text @@ -124,76 +123,6 @@ def get_gateway(): return addr.popitem()[1] if len(addr) == 1 else None -# Lazy dev caching to avoid re-reading the file multiple time when calling -# dig() often during same yunohost operation -external_resolvers_ = [] - - -def external_resolvers(): - - global external_resolvers_ - - if not external_resolvers_: - resolv_dnsmasq_conf = read_file("/etc/resolv.dnsmasq.conf").split("\n") - external_resolvers_ = [ - r.split(" ")[1] for r in resolv_dnsmasq_conf if r.startswith("nameserver") - ] - # We keep only ipv4 resolvers, otherwise on IPv4-only instances, IPv6 - # will be tried anyway resulting in super-slow dig requests that'll wait - # until timeout... - external_resolvers_ = [r for r in external_resolvers_ if ":" not in r] - - return external_resolvers_ - - -def dig( - qname, rdtype="A", timeout=5, resolvers="local", edns_size=1500, full_answers=False -): - """ - Do a quick DNS request and avoid the "search" trap inside /etc/resolv.conf - """ - - # It's very important to do the request with a qname ended by . - # If we don't and the domain fail, dns resolver try a second request - # by concatenate the qname with the end of the "hostname" - if not qname.endswith("."): - qname += "." - - if resolvers == "local": - resolvers = ["127.0.0.1"] - elif resolvers == "force_external": - resolvers = external_resolvers() - else: - assert isinstance(resolvers, list) - - resolver = dns.resolver.Resolver(configure=False) - resolver.use_edns(0, 0, edns_size) - resolver.nameservers = resolvers - # resolver.timeout is used to trigger the next DNS query on resolvers list. - # In python-dns 1.16, this value is set to 2.0. However, this means that if - # the 3 first dns resolvers in list are down, we wait 6 seconds before to - # run the DNS query to a DNS resolvers up... - # In diagnosis dnsrecords, with 10 domains this means at least 12min, too long. - resolver.timeout = 1.0 - # resolver.lifetime is the timeout for resolver.query() - # By default set it to 5 seconds to allow 4 resolvers to be unreachable. - resolver.lifetime = timeout - try: - answers = resolver.query(qname, rdtype) - except ( - dns.resolver.NXDOMAIN, - dns.resolver.NoNameservers, - dns.resolver.NoAnswer, - dns.exception.Timeout, - ) as e: - return ("nok", (e.__class__.__name__, e)) - - if not full_answers: - answers = [answer.to_text() for answer in answers] - - return ("ok", answers) - - def _extract_inet(string, skip_netmask=False, skip_loopback=True): """ Extract IP addresses (v4 and/or v6) from a string limited to one diff --git a/src/yunohost/utils/password.py b/src/yunohost/utils/password.py index 9e693d8cd..188850183 100644 --- a/src/yunohost/utils/password.py +++ b/src/yunohost/utils/password.py @@ -111,8 +111,13 @@ class PasswordValidator(object): listed = password in SMALL_PWD_LIST or self.is_in_most_used_list(password) strength_level = self.strength_level(password) if listed: + # i18n: password_listed return ("error", "password_listed") if strength_level < self.validation_strength: + # i18n: password_too_simple_1 + # i18n: password_too_simple_2 + # i18n: password_too_simple_3 + # i18n: password_too_simple_4 return ("error", "password_too_simple_%s" % self.validation_strength) return ("success", "") diff --git a/tests/autofix_locale_format.py b/tests/autofix_locale_format.py index dd7812635..f3825bd30 100644 --- a/tests/autofix_locale_format.py +++ b/tests/autofix_locale_format.py @@ -3,7 +3,7 @@ import json import glob # List all locale files (except en.json being the ref) -locale_folder = "locales/" +locale_folder = "../locales/" locale_files = glob.glob(locale_folder + "*.json") locale_files = [filename.split("/")[-1] for filename in locale_files] locale_files.remove("en.json") diff --git a/tests/reformat_locales.py b/tests/reformat_locales.py index 9119c7288..86c2664d7 100644 --- a/tests/reformat_locales.py +++ b/tests/reformat_locales.py @@ -3,11 +3,11 @@ import re def reformat(lang, transformations): - locale = open(f"locales/{lang}.json").read() + locale = open(f"../locales/{lang}.json").read() for pattern, replace in transformations.items(): locale = re.compile(pattern).sub(replace, locale) - open(f"locales/{lang}.json", "w").write(locale) + open(f"../locales/{lang}.json", "w").write(locale) ###################################################### diff --git a/tests/test_helpers.d/ynhtest_config.sh b/tests/test_helpers.d/ynhtest_config.sh new file mode 100644 index 000000000..b64943a48 --- /dev/null +++ b/tests/test_helpers.d/ynhtest_config.sh @@ -0,0 +1,662 @@ + +################# +# _ __ _ _ # +# | '_ \| | | | # +# | |_) | |_| | # +# | .__/ \__, | # +# | | __/ | # +# |_| |___/ # +# # +################# + +_read_py() { + local file="$1" + local key="$2" + python3 -c "exec(open('$file').read()); print($key)" +} + +ynhtest_config_read_py() { + + local dummy_dir="$(mktemp -d -p $VAR_WWW)" + file="$dummy_dir/dummy.py" + + cat << EOF > $dummy_dir/dummy.py +# Some comment +FOO = None +ENABLED = False +# TITLE = "Old title" +TITLE = "Lorem Ipsum" +THEME = "colib'ris" +EMAIL = "root@example.com" # This is a comment without quotes +PORT = 1234 # This is a comment without quotes +URL = 'https://yunohost.org' +DICT = {} +DICT['ldap_base'] = "ou=users,dc=yunohost,dc=org" +DICT['ldap_conf'] = {} +DICT['ldap_conf']['user'] = "camille" +# YNH_ICI +DICT['TITLE'] = "Hello world" +EOF + + test "$(_read_py "$file" "FOO")" == "None" + test "$(ynh_read_var_in_file "$file" "FOO")" == "None" + + test "$(_read_py "$file" "ENABLED")" == "False" + test "$(ynh_read_var_in_file "$file" "ENABLED")" == "False" + + test "$(_read_py "$file" "TITLE")" == "Lorem Ipsum" + test "$(ynh_read_var_in_file "$file" "TITLE")" == "Lorem Ipsum" + + test "$(_read_py "$file" "THEME")" == "colib'ris" + test "$(ynh_read_var_in_file "$file" "THEME")" == "colib'ris" + + test "$(_read_py "$file" "EMAIL")" == "root@example.com" + test "$(ynh_read_var_in_file "$file" "EMAIL")" == "root@example.com" + + test "$(_read_py "$file" "PORT")" == "1234" + test "$(ynh_read_var_in_file "$file" "PORT")" == "1234" + + test "$(_read_py "$file" "URL")" == "https://yunohost.org" + test "$(ynh_read_var_in_file "$file" "URL")" == "https://yunohost.org" + + test "$(ynh_read_var_in_file "$file" "ldap_base")" == "ou=users,dc=yunohost,dc=org" + + test "$(ynh_read_var_in_file "$file" "user")" == "camille" + + test "$(ynh_read_var_in_file "$file" "TITLE" "YNH_ICI")" == "Hello world" + + ! _read_py "$file" "NONEXISTENT" + test "$(ynh_read_var_in_file "$file" "NONEXISTENT")" == "YNH_NULL" + + ! _read_py "$file" "ENABLE" + test "$(ynh_read_var_in_file "$file" "ENABLE")" == "YNH_NULL" +} + +ynhtest_config_write_py() { + local dummy_dir="$(mktemp -d -p $VAR_WWW)" + file="$dummy_dir/dummy.py" + + cat << EOF > $dummy_dir/dummy.py +# Some comment +FOO = None +ENABLED = False +# TITLE = "Old title" +TITLE = "Lorem Ipsum" +THEME = "colib'ris" +EMAIL = "root@example.com" # This is a comment without quotes +PORT = 1234 # This is a comment without quotes +URL = 'https://yunohost.org' +DICT = {} +DICT['ldap_base'] = "ou=users,dc=yunohost,dc=org" +# YNH_ICI +DICT['TITLE'] = "Hello world" +EOF + + ynh_write_var_in_file "$file" "FOO" "bar" + test "$(_read_py "$file" "FOO")" == "bar" + test "$(ynh_read_var_in_file "$file" "FOO")" == "bar" + + ynh_write_var_in_file "$file" "ENABLED" "True" + test "$(_read_py "$file" "ENABLED")" == "True" + test "$(ynh_read_var_in_file "$file" "ENABLED")" == "True" + + ynh_write_var_in_file "$file" "TITLE" "Foo Bar" + test "$(_read_py "$file" "TITLE")" == "Foo Bar" + test "$(ynh_read_var_in_file "$file" "TITLE")" == "Foo Bar" + + ynh_write_var_in_file "$file" "THEME" "super-awesome-theme" + test "$(_read_py "$file" "THEME")" == "super-awesome-theme" + test "$(ynh_read_var_in_file "$file" "THEME")" == "super-awesome-theme" + + ynh_write_var_in_file "$file" "EMAIL" "sam@domain.tld" + test "$(_read_py "$file" "EMAIL")" == "sam@domain.tld" + test "$(ynh_read_var_in_file "$file" "EMAIL")" == "sam@domain.tld" + + ynh_write_var_in_file "$file" "PORT" "5678" + test "$(_read_py "$file" "PORT")" == "5678" + test "$(ynh_read_var_in_file "$file" "PORT")" == "5678" + + ynh_write_var_in_file "$file" "URL" "https://domain.tld/foobar" + test "$(_read_py "$file" "URL")" == "https://domain.tld/foobar" + test "$(ynh_read_var_in_file "$file" "URL")" == "https://domain.tld/foobar" + + ynh_write_var_in_file "$file" "ldap_base" "ou=users,dc=yunohost,dc=org" + test "$(ynh_read_var_in_file "$file" "ldap_base")" == "ou=users,dc=yunohost,dc=org" + + ynh_write_var_in_file "$file" "TITLE" "YOLO" "YNH_ICI" + test "$(ynh_read_var_in_file "$file" "TITLE" "YNH_ICI")" == "YOLO" + + ! ynh_write_var_in_file "$file" "NONEXISTENT" "foobar" + ! _read_py "$file" "NONEXISTENT" + test "$(ynh_read_var_in_file "$file" "NONEXISTENT")" == "YNH_NULL" + + ! ynh_write_var_in_file "$file" "ENABLE" "foobar" + ! _read_py "$file" "ENABLE" + test "$(ynh_read_var_in_file "$file" "ENABLE")" == "YNH_NULL" + +} + +############### +# _ _ # +# (_) (_) # +# _ _ __ _ # +# | | '_ \| | # +# | | | | | | # +# |_|_| |_|_| # +# # +############### + +_read_ini() { + local file="$1" + local key="$2" + python3 -c "import configparser; c = configparser.ConfigParser(); c.read('$file'); print(c['main']['$key'])" +} + +ynhtest_config_read_ini() { + local dummy_dir="$(mktemp -d -p $VAR_WWW)" + file="$dummy_dir/dummy.ini" + + cat << EOF > $file +# Some comment +; Another comment +[main] +foo = null +enabled = False +# title = Old title +title = Lorem Ipsum +theme = colib'ris +email = root@example.com ; This is a comment without quotes +port = 1234 ; This is a comment without quotes +url = https://yunohost.org +[dict] + ldap_base = ou=users,dc=yunohost,dc=org +EOF + + test "$(_read_ini "$file" "foo")" == "null" + test "$(ynh_read_var_in_file "$file" "foo")" == "null" + + test "$(_read_ini "$file" "enabled")" == "False" + test "$(ynh_read_var_in_file "$file" "enabled")" == "False" + + test "$(_read_ini "$file" "title")" == "Lorem Ipsum" + test "$(ynh_read_var_in_file "$file" "title")" == "Lorem Ipsum" + + test "$(_read_ini "$file" "theme")" == "colib'ris" + test "$(ynh_read_var_in_file "$file" "theme")" == "colib'ris" + + #test "$(_read_ini "$file" "email")" == "root@example.com" + test "$(ynh_read_var_in_file "$file" "email")" == "root@example.com" + + #test "$(_read_ini "$file" "port")" == "1234" + test "$(ynh_read_var_in_file "$file" "port")" == "1234" + + test "$(_read_ini "$file" "url")" == "https://yunohost.org" + test "$(ynh_read_var_in_file "$file" "url")" == "https://yunohost.org" + + test "$(ynh_read_var_in_file "$file" "ldap_base")" == "ou=users,dc=yunohost,dc=org" + + ! _read_ini "$file" "nonexistent" + test "$(ynh_read_var_in_file "$file" "nonexistent")" == "YNH_NULL" + + ! _read_ini "$file" "enable" + test "$(ynh_read_var_in_file "$file" "enable")" == "YNH_NULL" + +} + +ynhtest_config_write_ini() { + local dummy_dir="$(mktemp -d -p $VAR_WWW)" + file="$dummy_dir/dummy.ini" + + cat << EOF > $file +# Some comment +; Another comment +[main] +foo = null +enabled = False +# title = Old title +title = Lorem Ipsum +theme = colib'ris +email = root@example.com # This is a comment without quotes +port = 1234 # This is a comment without quotes +url = https://yunohost.org +[dict] + ldap_base = ou=users,dc=yunohost,dc=org +EOF + + ynh_write_var_in_file "$file" "foo" "bar" + test "$(_read_ini "$file" "foo")" == "bar" + test "$(ynh_read_var_in_file "$file" "foo")" == "bar" + + ynh_write_var_in_file "$file" "enabled" "True" + test "$(_read_ini "$file" "enabled")" == "True" + test "$(ynh_read_var_in_file "$file" "enabled")" == "True" + + ynh_write_var_in_file "$file" "title" "Foo Bar" + test "$(_read_ini "$file" "title")" == "Foo Bar" + test "$(ynh_read_var_in_file "$file" "title")" == "Foo Bar" + + ynh_write_var_in_file "$file" "theme" "super-awesome-theme" + test "$(_read_ini "$file" "theme")" == "super-awesome-theme" + test "$(ynh_read_var_in_file "$file" "theme")" == "super-awesome-theme" + + ynh_write_var_in_file "$file" "email" "sam@domain.tld" + test "$(_read_ini "$file" "email")" == "sam@domain.tld # This is a comment without quotes" + test "$(ynh_read_var_in_file "$file" "email")" == "sam@domain.tld" + + ynh_write_var_in_file "$file" "port" "5678" + test "$(_read_ini "$file" "port")" == "5678 # This is a comment without quotes" + test "$(ynh_read_var_in_file "$file" "port")" == "5678" + + ynh_write_var_in_file "$file" "url" "https://domain.tld/foobar" + test "$(_read_ini "$file" "url")" == "https://domain.tld/foobar" + test "$(ynh_read_var_in_file "$file" "url")" == "https://domain.tld/foobar" + + ynh_write_var_in_file "$file" "ldap_base" "ou=users,dc=yunohost,dc=org" + test "$(ynh_read_var_in_file "$file" "ldap_base")" == "ou=users,dc=yunohost,dc=org" + + ! ynh_write_var_in_file "$file" "nonexistent" "foobar" + ! _read_ini "$file" "nonexistent" + test "$(ynh_read_var_in_file "$file" "nonexistent")" == "YNH_NULL" + + ! ynh_write_var_in_file "$file" "enable" "foobar" + ! _read_ini "$file" "enable" + test "$(ynh_read_var_in_file "$file" "enable")" == "YNH_NULL" + +} + +############################# +# _ # +# | | # +# _ _ __ _ _ __ ___ | | # +# | | | |/ _` | '_ ` _ \| | # +# | |_| | (_| | | | | | | | # +# \__, |\__,_|_| |_| |_|_| # +# __/ | # +# |___/ # +# # +############################# + +_read_yaml() { + local file="$1" + local key="$2" + python3 -c "import yaml; print(yaml.safe_load(open('$file'))['$key'])" +} + +ynhtest_config_read_yaml() { + local dummy_dir="$(mktemp -d -p $VAR_WWW)" + file="$dummy_dir/dummy.yml" + + cat << EOF > $file +# Some comment +foo: +enabled: false +# title: old title +title: Lorem Ipsum +theme: colib'ris +email: root@example.com # This is a comment without quotes +port: 1234 # This is a comment without quotes +url: https://yunohost.org +dict: + ldap_base: ou=users,dc=yunohost,dc=org +EOF + + test "$(_read_yaml "$file" "foo")" == "None" + test "$(ynh_read_var_in_file "$file" "foo")" == "" + + test "$(_read_yaml "$file" "enabled")" == "False" + test "$(ynh_read_var_in_file "$file" "enabled")" == "false" + + test "$(_read_yaml "$file" "title")" == "Lorem Ipsum" + test "$(ynh_read_var_in_file "$file" "title")" == "Lorem Ipsum" + + test "$(_read_yaml "$file" "theme")" == "colib'ris" + test "$(ynh_read_var_in_file "$file" "theme")" == "colib'ris" + + test "$(_read_yaml "$file" "email")" == "root@example.com" + test "$(ynh_read_var_in_file "$file" "email")" == "root@example.com" + + test "$(_read_yaml "$file" "port")" == "1234" + test "$(ynh_read_var_in_file "$file" "port")" == "1234" + + test "$(_read_yaml "$file" "url")" == "https://yunohost.org" + test "$(ynh_read_var_in_file "$file" "url")" == "https://yunohost.org" + + test "$(ynh_read_var_in_file "$file" "ldap_base")" == "ou=users,dc=yunohost,dc=org" + + ! _read_yaml "$file" "nonexistent" + test "$(ynh_read_var_in_file "$file" "nonexistent")" == "YNH_NULL" + + ! _read_yaml "$file" "enable" + test "$(ynh_read_var_in_file "$file" "enable")" == "YNH_NULL" +} + + +ynhtest_config_write_yaml() { + local dummy_dir="$(mktemp -d -p $VAR_WWW)" + file="$dummy_dir/dummy.yml" + + cat << EOF > $file +# Some comment +foo: +enabled: false +# title: old title +title: Lorem Ipsum +theme: colib'ris +email: root@example.com # This is a comment without quotes +port: 1234 # This is a comment without quotes +url: https://yunohost.org +dict: + ldap_base: ou=users,dc=yunohost,dc=org +EOF + + ynh_write_var_in_file "$file" "foo" "bar" + # cat $dummy_dir/dummy.yml # to debug + ! test "$(_read_yaml "$file" "foo")" == "bar" # writing broke the yaml syntax... "foo:bar" (no space aftr :) + test "$(ynh_read_var_in_file "$file" "foo")" == "bar" + + ynh_write_var_in_file "$file" "enabled" "true" + test "$(_read_yaml "$file" "enabled")" == "True" + test "$(ynh_read_var_in_file "$file" "enabled")" == "true" + + ynh_write_var_in_file "$file" "title" "Foo Bar" + test "$(_read_yaml "$file" "title")" == "Foo Bar" + test "$(ynh_read_var_in_file "$file" "title")" == "Foo Bar" + + ynh_write_var_in_file "$file" "theme" "super-awesome-theme" + test "$(_read_yaml "$file" "theme")" == "super-awesome-theme" + test "$(ynh_read_var_in_file "$file" "theme")" == "super-awesome-theme" + + ynh_write_var_in_file "$file" "email" "sam@domain.tld" + test "$(_read_yaml "$file" "email")" == "sam@domain.tld" + test "$(ynh_read_var_in_file "$file" "email")" == "sam@domain.tld" + + ynh_write_var_in_file "$file" "port" "5678" + test "$(_read_yaml "$file" "port")" == "5678" + test "$(ynh_read_var_in_file "$file" "port")" == "5678" + + ynh_write_var_in_file "$file" "url" "https://domain.tld/foobar" + test "$(_read_yaml "$file" "url")" == "https://domain.tld/foobar" + test "$(ynh_read_var_in_file "$file" "url")" == "https://domain.tld/foobar" + + ynh_write_var_in_file "$file" "ldap_base" "ou=foobar,dc=domain,dc=tld" + test "$(ynh_read_var_in_file "$file" "ldap_base")" == "ou=foobar,dc=domain,dc=tld" + + ! ynh_write_var_in_file "$file" "nonexistent" "foobar" + test "$(ynh_read_var_in_file "$file" "nonexistent")" == "YNH_NULL" + + ! ynh_write_var_in_file "$file" "enable" "foobar" + test "$(ynh_read_var_in_file "$file" "enable")" == "YNH_NULL" + test "$(ynh_read_var_in_file "$file" "enabled")" == "true" +} + +######################### +# _ # +# (_) # +# _ ___ ___ _ __ # +# | / __|/ _ \| '_ \ # +# | \__ \ (_) | | | | # +# | |___/\___/|_| |_| # +# _/ | # +# |__/ # +# # +######################### + +_read_json() { + local file="$1" + local key="$2" + python3 -c "import json; print(json.load(open('$file'))['$key'])" +} + +ynhtest_config_read_json() { + local dummy_dir="$(mktemp -d -p $VAR_WWW)" + file="$dummy_dir/dummy.json" + + cat << EOF > $file +{ + "foo": null, + "enabled": false, + "title": "Lorem Ipsum", + "theme": "colib'ris", + "email": "root@example.com", + "port": 1234, + "url": "https://yunohost.org", + "dict": { + "ldap_base": "ou=users,dc=yunohost,dc=org" + } +} +EOF + + + test "$(_read_json "$file" "foo")" == "None" + test "$(ynh_read_var_in_file "$file" "foo")" == "null" + + test "$(_read_json "$file" "enabled")" == "False" + test "$(ynh_read_var_in_file "$file" "enabled")" == "false" + + test "$(_read_json "$file" "title")" == "Lorem Ipsum" + test "$(ynh_read_var_in_file "$file" "title")" == "Lorem Ipsum" + + test "$(_read_json "$file" "theme")" == "colib'ris" + test "$(ynh_read_var_in_file "$file" "theme")" == "colib'ris" + + test "$(_read_json "$file" "email")" == "root@example.com" + test "$(ynh_read_var_in_file "$file" "email")" == "root@example.com" + + test "$(_read_json "$file" "port")" == "1234" + test "$(ynh_read_var_in_file "$file" "port")" == "1234" + + test "$(_read_json "$file" "url")" == "https://yunohost.org" + test "$(ynh_read_var_in_file "$file" "url")" == "https://yunohost.org" + + test "$(ynh_read_var_in_file "$file" "ldap_base")" == "ou=users,dc=yunohost,dc=org" + + ! _read_json "$file" "nonexistent" + test "$(ynh_read_var_in_file "$file" "nonexistent")" == "YNH_NULL" + + ! _read_json "$file" "enable" + test "$(ynh_read_var_in_file "$file" "enable")" == "YNH_NULL" +} + + +ynhtest_config_write_json() { + local dummy_dir="$(mktemp -d -p $VAR_WWW)" + file="$dummy_dir/dummy.json" + + cat << EOF > $file +{ + "foo": null, + "enabled": false, + "title": "Lorem Ipsum", + "theme": "colib'ris", + "email": "root@example.com", + "port": 1234, + "url": "https://yunohost.org", + "dict": { + "ldap_base": "ou=users,dc=yunohost,dc=org" + } +} +EOF + + ynh_write_var_in_file "$file" "foo" "bar" + cat $file + test "$(_read_json "$file" "foo")" == "bar" + test "$(ynh_read_var_in_file "$file" "foo")" == "bar" + + ynh_write_var_in_file "$file" "enabled" "true" + cat $file + test "$(_read_json "$file" "enabled")" == "true" + test "$(ynh_read_var_in_file "$file" "enabled")" == "true" + + ynh_write_var_in_file "$file" "title" "Foo Bar" + cat $file + test "$(_read_json "$file" "title")" == "Foo Bar" + test "$(ynh_read_var_in_file "$file" "title")" == "Foo Bar" + + ynh_write_var_in_file "$file" "theme" "super-awesome-theme" + cat $file + test "$(_read_json "$file" "theme")" == "super-awesome-theme" + test "$(ynh_read_var_in_file "$file" "theme")" == "super-awesome-theme" + + ynh_write_var_in_file "$file" "email" "sam@domain.tld" + cat $file + test "$(_read_json "$file" "email")" == "sam@domain.tld" + test "$(ynh_read_var_in_file "$file" "email")" == "sam@domain.tld" + + ynh_write_var_in_file "$file" "port" "5678" + test "$(_read_json "$file" "port")" == "5678" + test "$(ynh_read_var_in_file "$file" "port")" == "5678" + + ynh_write_var_in_file "$file" "url" "https://domain.tld/foobar" + test "$(_read_json "$file" "url")" == "https://domain.tld/foobar" + test "$(ynh_read_var_in_file "$file" "url")" == "https://domain.tld/foobar" + + ynh_write_var_in_file "$file" "ldap_base" "ou=foobar,dc=domain,dc=tld" + test "$(ynh_read_var_in_file "$file" "ldap_base")" == "ou=foobar,dc=domain,dc=tld" + + ! ynh_write_var_in_file "$file" "nonexistent" "foobar" + test "$(ynh_read_var_in_file "$file" "nonexistent")" == "YNH_NULL" + + ! ynh_write_var_in_file "$file" "enable" "foobar" + test "$(ynh_read_var_in_file "$file" "enable")" == "YNH_NULL" + test "$(ynh_read_var_in_file "$file" "enabled")" == "true" +} + +####################### +# _ # +# | | # +# _ __ | |__ _ __ # +# | '_ \| '_ \| '_ \ # +# | |_) | | | | |_) | # +# | .__/|_| |_| .__/ # +# | | | | # +# |_| |_| # +# # +####################### + +_read_php() { + local file="$1" + local key="$2" + php -r "include '$file'; echo var_export(\$$key);" | sed "s/^'//g" | sed "s/'$//g" +} + +ynhtest_config_read_php() { + local dummy_dir="$(mktemp -d -p $VAR_WWW)" + file="$dummy_dir/dummy.php" + + cat << EOF > $file + "ou=users,dc=yunohost,dc=org", + 'ldap_conf' => [] + ]; + \$dict['ldap_conf']['user'] = 'camille'; + const DB_HOST = 'localhost'; +?> +EOF + + test "$(_read_php "$file" "foo")" == "NULL" + test "$(ynh_read_var_in_file "$file" "foo")" == "NULL" + + test "$(_read_php "$file" "enabled")" == "false" + test "$(ynh_read_var_in_file "$file" "enabled")" == "false" + + test "$(_read_php "$file" "title")" == "Lorem Ipsum" + test "$(ynh_read_var_in_file "$file" "title")" == "Lorem Ipsum" + + test "$(_read_php "$file" "theme")" == "colib\\'ris" + test "$(ynh_read_var_in_file "$file" "theme")" == "colib'ris" + + test "$(_read_php "$file" "email")" == "root@example.com" + test "$(ynh_read_var_in_file "$file" "email")" == "root@example.com" + + test "$(_read_php "$file" "port")" == "1234" + test "$(ynh_read_var_in_file "$file" "port")" == "1234" + + test "$(_read_php "$file" "url")" == "https://yunohost.org" + test "$(ynh_read_var_in_file "$file" "url")" == "https://yunohost.org" + + test "$(ynh_read_var_in_file "$file" "ldap_base")" == "ou=users,dc=yunohost,dc=org" + + test "$(ynh_read_var_in_file "$file" "user")" == "camille" + + test "$(ynh_read_var_in_file "$file" "DB_HOST")" == "localhost" + + ! _read_php "$file" "nonexistent" + test "$(ynh_read_var_in_file "$file" "nonexistent")" == "YNH_NULL" + + ! _read_php "$file" "enable" + test "$(ynh_read_var_in_file "$file" "enable")" == "YNH_NULL" +} + + +ynhtest_config_write_php() { + local dummy_dir="$(mktemp -d -p $VAR_WWW)" + file="$dummy_dir/dummy.php" + + cat << EOF > $file + "ou=users,dc=yunohost,dc=org", + ]; +?> +EOF + + ynh_write_var_in_file "$file" "foo" "bar" + test "$(_read_php "$file" "foo")" == "bar" + test "$(ynh_read_var_in_file "$file" "foo")" == "bar" + + ynh_write_var_in_file "$file" "enabled" "true" + test "$(_read_php "$file" "enabled")" == "true" + test "$(ynh_read_var_in_file "$file" "enabled")" == "true" + + ynh_write_var_in_file "$file" "title" "Foo Bar" + cat $file + test "$(_read_php "$file" "title")" == "Foo Bar" + test "$(ynh_read_var_in_file "$file" "title")" == "Foo Bar" + + ynh_write_var_in_file "$file" "theme" "super-awesome-theme" + cat $file + test "$(_read_php "$file" "theme")" == "super-awesome-theme" + test "$(ynh_read_var_in_file "$file" "theme")" == "super-awesome-theme" + + ynh_write_var_in_file "$file" "email" "sam@domain.tld" + cat $file + test "$(_read_php "$file" "email")" == "sam@domain.tld" + test "$(ynh_read_var_in_file "$file" "email")" == "sam@domain.tld" + + ynh_write_var_in_file "$file" "port" "5678" + test "$(_read_php "$file" "port")" == "5678" + test "$(ynh_read_var_in_file "$file" "port")" == "5678" + + ynh_write_var_in_file "$file" "url" "https://domain.tld/foobar" + test "$(_read_php "$file" "url")" == "https://domain.tld/foobar" + test "$(ynh_read_var_in_file "$file" "url")" == "https://domain.tld/foobar" + + ynh_write_var_in_file "$file" "ldap_base" "ou=foobar,dc=domain,dc=tld" + test "$(ynh_read_var_in_file "$file" "ldap_base")" == "ou=foobar,dc=domain,dc=tld" + + ! ynh_write_var_in_file "$file" "nonexistent" "foobar" + test "$(ynh_read_var_in_file "$file" "nonexistent")" == "YNH_NULL" + + ! ynh_write_var_in_file "$file" "enable" "foobar" + test "$(ynh_read_var_in_file "$file" "enable")" == "YNH_NULL" + test "$(ynh_read_var_in_file "$file" "enabled")" == "true" +} diff --git a/tests/test_i18n_keys.py b/tests/test_i18n_keys.py index 33c1f7b65..103241085 100644 --- a/tests/test_i18n_keys.py +++ b/tests/test_i18n_keys.py @@ -6,14 +6,7 @@ import glob import json import yaml import subprocess - -ignore = [ - "password_too_simple_", - "password_listed", - "backup_method_", - "backup_applying_method_", - "confirm_app_install_", -] +import toml ############################################################################### # Find used keys in python code # @@ -137,33 +130,23 @@ def find_expected_string_keys(): yield "backup_applying_method_%s" % method yield "backup_method_%s_finished" % method - for level in ["danger", "thirdparty", "warning"]: - yield "confirm_app_install_%s" % level + registrars = toml.load(open("data/other/registrar_list.toml")) + supported_registrars = ["ovh", "gandi", "godaddy"] + for registrar in supported_registrars: + for key in registrars[registrar].keys(): + yield f"domain_config_{key}" - for errortype in ["not_found", "error", "warning", "success", "not_found_details"]: - yield "diagnosis_domain_expiration_%s" % errortype - yield "diagnosis_domain_not_found_details" - - for errortype in ["bad_status_code", "connection_error", "timeout"]: - yield "diagnosis_http_%s" % errortype - - yield "password_listed" - for i in [1, 2, 3, 4]: - yield "password_too_simple_%s" % i - - checks = [ - "outgoing_port_25_ok", - "ehlo_ok", - "fcrdns_ok", - "blacklist_ok", - "queue_ok", - "ehlo_bad_answer", - "ehlo_unreachable", - "ehlo_bad_answer_details", - "ehlo_unreachable_details", - ] - for check in checks: - yield "diagnosis_mail_%s" % check + domain_config = toml.load(open("data/other/config_domain.toml")) + for panel in domain_config.values(): + if not isinstance(panel, dict): + continue + for section in panel.values(): + if not isinstance(section, dict): + continue + for key, values in section.items(): + if not isinstance(values, dict): + continue + yield f"domain_config_{key}" ############################################################################### diff --git a/tox.ini b/tox.ini index 0af648d63..e84644564 100644 --- a/tox.ini +++ b/tox.ini @@ -6,8 +6,10 @@ skip_install=True deps = py39-{lint,invalidcode}: flake8 py39-black-{run,check}: black + py39-mypy: mypy >= 0.900 commands = py39-lint: flake8 src doc data tests --ignore E402,E501,E203,W503 --exclude src/yunohost/vendor py39-invalidcode: flake8 src data --exclude src/yunohost/tests,src/yunohost/vendor --select F py39-black-check: black --check --diff src doc data tests py39-black-run: black src doc data tests + py39-mypy: mypy --ignore-missing-import --install-types --non-interactive --follow-imports silent src/yunohost/ --exclude (acme_tiny|data_migrations)