diff --git a/.coveragerc b/.coveragerc index fe22c8381..bc952e665 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,2 +1,2 @@ [report] -omit=src/tests/*,src/vendor/*,/usr/lib/moulinette/yunohost/* +omit=src/tests/*,src/vendor/*,/usr/lib/moulinette/yunohost/*,/usr/lib/python3/dist-packages/yunohost/tests/*,/usr/lib/python3/dist-packages/yunohost/vendor/* diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 000000000..01b917f6e --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,42 @@ +name: "CodeQL" + +on: + push: + branches: [ "dev" ] + pull_request: + # The branches below must be a subset of the branches above + branches: [ "dev" ] + paths-ignore: + - 'src/tests/**' + schedule: + - cron: '43 12 * * 3' + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: [ 'python' ] + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v2 + with: + languages: ${{ matrix.language }} + queries: security-extended,security-and-quality + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v2 + with: + category: "/language:${{matrix.language}}" diff --git a/.github/workflows/n_updater.sh b/.github/workflows/n_updater.sh deleted file mode 100644 index a8b0b0eec..000000000 --- a/.github/workflows/n_updater.sh +++ /dev/null @@ -1,78 +0,0 @@ -#!/bin/bash - -#================================================= -# N UPDATING HELPER -#================================================= - -# This script is meant to be run by GitHub Actions. -# It is derived from the Updater script from the YunoHost-Apps organization. -# It aims to automate the update of `n`, the Node version management system. - -#================================================= -# FETCHING LATEST RELEASE AND ITS ASSETS -#================================================= - -# Fetching information -source helpers/nodejs -current_version="$n_version" -repo="tj/n" -# Some jq magic is needed, because the latest upstream release is not always the latest version (e.g. security patches for older versions) -version=$(curl --silent "https://api.github.com/repos/$repo/releases" | jq -r '.[] | select( .prerelease != true ) | .tag_name' | sort -V | tail -1) - -# Later down the script, we assume the version has only digits and dots -# Sometimes the release name starts with a "v", so let's filter it out. -if [[ ${version:0:1} == "v" || ${version:0:1} == "V" ]]; then - version=${version:1} -fi - -# Setting up the environment variables -echo "Current version: $current_version" -echo "Latest release from upstream: $version" -echo "VERSION=$version" >> $GITHUB_ENV -# For the time being, let's assume the script will fail -echo "PROCEED=false" >> $GITHUB_ENV - -# Proceed only if the retrieved version is greater than the current one -if ! dpkg --compare-versions "$current_version" "lt" "$version" ; then - echo "::warning ::No new version available" - exit 0 -# Proceed only if a PR for this new version does not already exist -elif git ls-remote -q --exit-code --heads https://github.com/${GITHUB_REPOSITORY:-YunoHost/yunohost}.git ci-auto-update-n-v$version ; then - echo "::warning ::A branch already exists for this update" - exit 0 -fi - -#================================================= -# UPDATE SOURCE FILES -#================================================= - -asset_url="https://github.com/tj/n/archive/v${version}.tar.gz" - -echo "Handling asset at $asset_url" - -# Create the temporary directory -tempdir="$(mktemp -d)" - -# Download sources and calculate checksum -filename=${asset_url##*/} -curl --silent -4 -L $asset_url -o "$tempdir/$filename" -checksum=$(sha256sum "$tempdir/$filename" | head -c 64) - -# Delete temporary directory -rm -rf $tempdir - -echo "Calculated checksum for n v${version} is $checksum" - -#================================================= -# GENERIC FINALIZATION -#================================================= - -# Replace new version in helper -sed -i -E "s/^n_version=.*$/n_version=$version/" helpers/nodejs - -# Replace checksum in helper -sed -i -E "s/^n_checksum=.*$/n_checksum=$checksum/" helpers/nodejs - -# The Action will proceed only if the PROCEED environment variable is set to true -echo "PROCEED=true" >> $GITHUB_ENV -exit 0 diff --git a/.github/workflows/n_updater.yml b/.github/workflows/n_updater.yml index 35afd8ae7..ce3e9c925 100644 --- a/.github/workflows/n_updater.yml +++ b/.github/workflows/n_updater.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Fetch the source code - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: token: ${{ secrets.GITHUB_TOKEN }} - name: Run the updater script @@ -21,7 +21,8 @@ jobs: git config --global user.name 'yunohost-bot' git config --global user.email 'yunohost-bot@users.noreply.github.com' # Run the updater script - /bin/bash .github/workflows/n_updater.sh + wget https://raw.githubusercontent.com/tj/n/master/bin/n --output-document=helpers/vendor/n/n + [[ -z "$(git diff helpers/vendor/n/n)" ]] || echo "PROCEED=true" >> $GITHUB_ENV - name: Commit changes id: commit if: ${{ env.PROCEED == 'true' }} diff --git a/.gitignore b/.gitignore index eae46b4c5..91b5b56e4 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,10 @@ src/locales # Test src/tests/apps + +# Tmp/local doc stuff +doc/bash-completion.sh +doc/bash_completion.d +doc/openapi.js +doc/openapi.json +doc/swagger diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 4d0f30679..3e030940b 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -16,6 +16,9 @@ default: code_quality: tags: - docker + rules: + - if: $CI_COMMIT_TAG # Only for tags + code_quality_html: extends: code_quality @@ -23,6 +26,9 @@ code_quality_html: REPORT_FORMAT: html artifacts: paths: [gl-code-quality-report.html] + rules: + - if: $CI_COMMIT_TAG # Only for tags + # see: https://docs.gitlab.com/ee/ci/yaml/#switch-between-branch-pipelines-and-merge-request-pipelines workflow: @@ -37,7 +43,7 @@ workflow: - when: always variables: - YNH_BUILD_DIR: "ynh-build" + YNH_BUILD_DIR: "/ynh-build" include: - template: Code-Quality.gitlab-ci.yml diff --git a/.gitlab/ci/build.gitlab-ci.yml b/.gitlab/ci/build.gitlab-ci.yml index db691b9d2..610580dac 100644 --- a/.gitlab/ci/build.gitlab-ci.yml +++ b/.gitlab/ci/build.gitlab-ci.yml @@ -8,7 +8,7 @@ - DEBIAN_FRONTEND=noninteractive apt update artifacts: paths: - - $YNH_BUILD_DIR/*.deb + - ./*.deb .build_script: &build_script - DEBIAN_FRONTEND=noninteractive apt --assume-yes -o Dpkg::Options::="--force-confold" install devscripts --no-install-recommends @@ -17,6 +17,8 @@ - VERSION_NIGHTLY="${VERSION}+$(date +%Y%m%d%H%M)" - dch --package "${PACKAGE}" --force-bad-version -v "${VERSION_NIGHTLY}" -D "unstable" --force-distribution "Daily build." - debuild --no-lintian -us -uc + - cp $YNH_BUILD_DIR/*.deb ${CI_PROJECT_DIR}/ + - cd ${CI_PROJECT_DIR} ######################################## # BUILD DEB @@ -31,7 +33,7 @@ build-yunohost: - mkdir -p $YNH_BUILD_DIR/$PACKAGE - cat archive.tar.gz | tar -xz -C $YNH_BUILD_DIR/$PACKAGE - rm archive.tar.gz - - DEBIAN_FRONTEND=noninteractive apt --assume-yes -o Dpkg::Options::="--force-confold" build-dep $(pwd)/$YNH_BUILD_DIR/$PACKAGE + - DEBIAN_FRONTEND=noninteractive apt --assume-yes -o Dpkg::Options::="--force-confold" build-dep $YNH_BUILD_DIR/$PACKAGE - *build_script @@ -42,7 +44,7 @@ build-ssowat: script: - DEBIAN_DEPENDS=$(cat debian/control | tr "," "\n" | grep -Po "ssowat \([>,=,<]+ .*\)" | grep -Po "[0-9\.]+") - git clone $YNH_SOURCE/$PACKAGE -b $CI_COMMIT_REF_NAME $YNH_BUILD_DIR/$PACKAGE --depth 1 || git clone $YNH_SOURCE/$PACKAGE -b $DEBIAN_DEPENDS $YNH_BUILD_DIR/$PACKAGE --depth 1 || git clone $YNH_SOURCE/$PACKAGE $YNH_BUILD_DIR/$PACKAGE --depth 1 - - DEBIAN_FRONTEND=noninteractive apt --assume-yes -o Dpkg::Options::="--force-confold" build-dep $(pwd)/$YNH_BUILD_DIR/$PACKAGE + - DEBIAN_FRONTEND=noninteractive apt --assume-yes -o Dpkg::Options::="--force-confold" build-dep $YNH_BUILD_DIR/$PACKAGE - *build_script build-moulinette: @@ -52,5 +54,5 @@ build-moulinette: script: - DEBIAN_DEPENDS=$(cat debian/control | tr "," "\n" | grep -Po "moulinette \([>,=,<]+ .*\)" | grep -Po "[0-9\.]+") - git clone $YNH_SOURCE/$PACKAGE -b $CI_COMMIT_REF_NAME $YNH_BUILD_DIR/$PACKAGE --depth 1 || git clone $YNH_SOURCE/$PACKAGE -b $DEBIAN_DEPENDS $YNH_BUILD_DIR/$PACKAGE --depth 1 || git clone $YNH_SOURCE/$PACKAGE $YNH_BUILD_DIR/$PACKAGE --depth 1 - - DEBIAN_FRONTEND=noninteractive apt --assume-yes -o Dpkg::Options::="--force-confold" build-dep $(pwd)/$YNH_BUILD_DIR/$PACKAGE + - DEBIAN_FRONTEND=noninteractive apt --assume-yes -o Dpkg::Options::="--force-confold" build-dep $YNH_BUILD_DIR/$PACKAGE - *build_script diff --git a/.gitlab/ci/doc.gitlab-ci.yml b/.gitlab/ci/doc.gitlab-ci.yml index 528d8f5aa..4f6ea6ba1 100644 --- a/.gitlab/ci/doc.gitlab-ci.yml +++ b/.gitlab/ci/doc.gitlab-ci.yml @@ -13,15 +13,18 @@ generate-helpers-doc: script: - cd doc - python3 generate_helper_doc.py + - python3 generate_resource_doc.py > resources.md - hub clone https://$GITHUB_TOKEN:x-oauth-basic@github.com/YunoHost/doc.git doc_repo - - cp helpers.md doc_repo/pages/06.contribute/10.packaging_apps/11.helpers/packaging_apps_helpers.md + - cp helpers.md doc_repo/pages/06.contribute/10.packaging_apps/80.resources/11.helpers/packaging_apps_helpers.md + - cp resources.md doc_repo/pages/06.contribute/10.packaging_apps/80.resources/15.appresources/packaging_apps_resources.md - cd doc_repo # replace ${CI_COMMIT_REF_NAME} with ${CI_COMMIT_TAG} ? - hub checkout -b "${CI_COMMIT_REF_NAME}" - - hub commit -am "[CI] Helper for ${CI_COMMIT_REF_NAME}" - - hub pull-request -m "[CI] Helper for ${CI_COMMIT_REF_NAME}" -p # GITHUB_USER and GITHUB_TOKEN registered here https://gitlab.com/yunohost/yunohost/-/settings/ci_cd + - hub commit -am "[CI] Update app helpers/resources for ${CI_COMMIT_REF_NAME}" + - hub pull-request -m "[CI] Update app helpers/resources for ${CI_COMMIT_REF_NAME}" -p # GITHUB_USER and GITHUB_TOKEN registered here https://gitlab.com/yunohost/yunohost/-/settings/ci_cd artifacts: paths: - doc/helpers.md + - doc/resources.md only: - tags diff --git a/.gitlab/ci/install.gitlab-ci.yml b/.gitlab/ci/install.gitlab-ci.yml index e2662e9e2..65409c6eb 100644 --- a/.gitlab/ci/install.gitlab-ci.yml +++ b/.gitlab/ci/install.gitlab-ci.yml @@ -17,7 +17,7 @@ upgrade: image: "after-install" script: - apt-get update -o Acquire::Retries=3 - - DEBIAN_FRONTEND=noninteractive SUDO_FORCE_REMOVE=yes apt --assume-yes -o Dpkg::Options::="--force-confold" --allow-downgrades install ./$YNH_BUILD_DIR/*.deb + - DEBIAN_FRONTEND=noninteractive SUDO_FORCE_REMOVE=yes apt --assume-yes -o Dpkg::Options::="--force-confold" --allow-downgrades install ${CI_PROJECT_DIR}/*.deb install-postinstall: @@ -25,5 +25,5 @@ install-postinstall: image: "before-install" script: - apt-get update -o Acquire::Retries=3 - - DEBIAN_FRONTEND=noninteractive SUDO_FORCE_REMOVE=yes apt --assume-yes -o Dpkg::Options::="--force-confold" --allow-downgrades install ./$YNH_BUILD_DIR/*.deb - - yunohost tools postinstall -d domain.tld -p the_password --ignore-dyndns --force-diskspace + - DEBIAN_FRONTEND=noninteractive SUDO_FORCE_REMOVE=yes apt --assume-yes -o Dpkg::Options::="--force-confold" --allow-downgrades install ${CI_PROJECT_DIR}/*.deb + - yunohost tools postinstall -d domain.tld -u syssa -F 'Syssa Mine' -p the_password --ignore-dyndns --force-diskspace diff --git a/.gitlab/ci/lint.gitlab-ci.yml b/.gitlab/ci/lint.gitlab-ci.yml index 2c2bdcc1d..7a8fbf1fb 100644 --- a/.gitlab/ci/lint.gitlab-ci.yml +++ b/.gitlab/ci/lint.gitlab-ci.yml @@ -42,7 +42,6 @@ black: - '[ $(git diff | wc -l) != 0 ] || exit 0' # stop if there is nothing to commit - git commit -am "[CI] Format code with Black" || true - git push -f origin "ci-format-${CI_COMMIT_REF_NAME}":"ci-format-${CI_COMMIT_REF_NAME}" - - hub pull-request -m "[CI] Format code with Black" -b Yunohost:$CI_COMMIT_REF_NAME -p || true # GITHUB_USER and GITHUB_TOKEN registered here https://gitlab.com/yunohost/yunohost/-/settings/ci_cd + - hub pull-request -m "[CI] Format code with Black" -b Yunohost:dev -p || true # GITHUB_USER and GITHUB_TOKEN registered here https://gitlab.com/yunohost/yunohost/-/settings/ci_cd only: - variables: - - $CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH + - tags diff --git a/.gitlab/ci/test.gitlab-ci.yml b/.gitlab/ci/test.gitlab-ci.yml index 27b9b4913..b0ffd3db5 100644 --- a/.gitlab/ci/test.gitlab-ci.yml +++ b/.gitlab/ci/test.gitlab-ci.yml @@ -1,7 +1,7 @@ .install_debs: &install_debs - apt-get update -o Acquire::Retries=3 - - DEBIAN_FRONTEND=noninteractive SUDO_FORCE_REMOVE=yes apt --assume-yes -o Dpkg::Options::="--force-confold" --allow-downgrades install ./$YNH_BUILD_DIR/*.deb - - pip3 install -U mock pip pytest pytest-cov pytest-mock pytest-sugar requests-mock tox ansi2html black jinja2 + - DEBIAN_FRONTEND=noninteractive SUDO_FORCE_REMOVE=yes apt --assume-yes -o Dpkg::Options::="--force-confold" --allow-downgrades install ${CI_PROJECT_DIR}/*.deb + - pip3 install -U mock pip pytest pytest-cov pytest-mock pytest-sugar requests-mock tox ansi2html black jinja2 "packaging<22" .test-stage: stage: test @@ -34,9 +34,9 @@ full-tests: PYTEST_ADDOPTS: "--color=yes" before_script: - *install_debs - - yunohost tools postinstall -d domain.tld -p the_password --ignore-dyndns --force-diskspace + - yunohost tools postinstall -d domain.tld -u syssa -F 'Syssa Mine' -p the_password --ignore-dyndns --force-diskspace script: - - python3 -m pytest --cov=yunohost tests/ src/tests/ src/diagnosers/ --junitxml=report.xml + - python3 -m pytest --cov=yunohost tests/ src/tests/ --junitxml=report.xml - cd tests - bash test_helpers.sh needs: @@ -46,6 +46,7 @@ full-tests: artifacts: true - job: build-moulinette artifacts: true + coverage: '/TOTAL.*\s+(\d+%)/' artifacts: reports: junit: report.xml @@ -125,6 +126,15 @@ test-app-config: - src/app.py - src/utils/config.py +test-app-resources: + extends: .test-stage + script: + - python3 -m pytest src/tests/test_app_resources.py + only: + changes: + - src/app.py + - src/utils/resources.py + test-changeurl: extends: .test-stage script: diff --git a/.gitlab/ci/translation.gitlab-ci.yml b/.gitlab/ci/translation.gitlab-ci.yml index b6c683f57..83db2b5a4 100644 --- a/.gitlab/ci/translation.gitlab-ci.yml +++ b/.gitlab/ci/translation.gitlab-ci.yml @@ -26,7 +26,7 @@ autofix-translated-strings: - git checkout -b "ci-autofix-translated-strings-${CI_COMMIT_REF_NAME}" --no-track - python3 maintenance/missing_i18n_keys.py --fix - python3 maintenance/autofix_locale_format.py - - '[ $(git diff | wc -l) != 0 ] || exit 0' # stop if there is nothing to commit + - '[ $(git diff --ignore-blank-lines --ignore-all-space --ignore-space-at-eol --ignore-cr-at-eol | 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-autofix-translated-strings-${CI_COMMIT_REF_NAME}":"ci-remove-stale-translated-strings-${CI_COMMIT_REF_NAME}" - hub pull-request -m "[CI] Reformat / remove stale translated strings" -b Yunohost:$CI_COMMIT_REF_NAME -p || true # GITHUB_USER and GITHUB_TOKEN registered here https://gitlab.com/yunohost/yunohost/-/settings/ci_cd diff --git a/.lgtm.yml b/.lgtm.yml deleted file mode 100644 index 8fd57e49e..000000000 --- a/.lgtm.yml +++ /dev/null @@ -1,4 +0,0 @@ -extraction: - python: - python_setup: - version: "3" \ No newline at end of file diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md deleted file mode 100644 index 0a9ac7527..000000000 --- a/CONTRIBUTORS.md +++ /dev/null @@ -1,101 +0,0 @@ -YunoHost core contributors -========================== - -YunoHost is built and maintained by the YunoHost project community. -Everyone is encouraged to submit issues and changes, and to contribute in other ways -- see https://yunohost.org/contribute to find out how. - --- - -Initial YunoHost core was built by Kload & beudbeud, for YunoHost v2. - -Most of code was written by Kload and jerome, with help of numerous contributors. - -Translation is made by a bunch of lovely people all over the world. - -We would like to thank anyone who ever helped the YunoHost project <3 - - -YunoHost core Contributors --------------------------- - -- Jérôme Lebleu -- Kload -- Laurent 'Bram' Peuch -- Julien 'ju' Malik -- opi -- Aleks -- Adrien 'beudbeud' Beudin -- M5oul -- Valentin 'zamentur' / 'ljf' Grimaud -- Jocelyn Delalande -- infertux -- Taziden -- ZeHiro -- Josue-T -- nahoj -- a1ex -- JimboJoe -- vetetix -- jellium -- Sebastien 'sebian' Badia -- lmangani -- Julien Vaubourg -- thardev -- zimo2001 - - -YunoHost core Translators -------------------------- - -If you want to help translation, please visit https://translate.yunohost.org/projects/yunohost/yunohost/ - - -### Dutch - -- DUBWiSE -- Jeroen Keerl -- marut - -### English - -- Bugsbane -- rokaz - -### French - -- aoz roon -- Genma -- Jean-Baptiste Holcroft -- Jean P. -- Jérôme Lebleu -- Lapineige -- paddy - - -### German - -- david.bartke -- Fabian Gruber -- Felix Bartels -- Jeroen Keerl -- martin kistner -- Philip Gatzka - -### Hindi - -- Anmol - -### Italian - -- bricabrac -- Thomas Bille - -### Portuguese - -- Deleted User -- Trollken - -### Spanish - -- Juanu - diff --git a/README.md b/README.md index 969651eee..07ee04de0 100644 --- a/README.md +++ b/README.md @@ -7,10 +7,10 @@
{item}
مُدرَج ضمن قائمة سوداء على {blacklist_name}",
+ "diagnosis_mail_outgoing_port_25_ok": "خادم بريد SMTP قادر على إرسال رسائل البريد الإلكتروني (منفذ البريد الصادر 25 غير محظور).",
+ "user_already_exists": "المستخدم '{user}' موجود مِن قَبل"
+}
\ No newline at end of file
diff --git a/locales/ca.json b/locales/ca.json
index b660032d2..821e5c3eb 100644
--- a/locales/ca.json
+++ b/locales/ca.json
@@ -1,8 +1,6 @@
{
"action_invalid": "Acció '{action}' invàlida",
"admin_password": "Contrasenya d'administració",
- "admin_password_change_failed": "No es pot canviar la contrasenya",
- "admin_password_changed": "S'ha canviat la contrasenya d'administració",
"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",
@@ -22,7 +20,6 @@
"app_not_properly_removed": "{app} no s'ha pogut suprimir correctament",
"app_removed": "{app} ha estat suprimida",
"app_requirements_checking": "Verificació dels paquets requerits per {app}...",
- "app_requirements_unmeet": "No es compleixen els requeriments per {app}, el paquet {pkgname} ({version}) ha de ser {spec}",
"app_sources_fetch_failed": "No s'han pogut carregar els fitxers font, l'URL és correcta?",
"app_unknown": "Aplicació desconeguda",
"app_unsupported_remote_type": "El tipus remot utilitzat per l'aplicació no està suportat",
@@ -30,8 +27,6 @@
"app_upgrade_failed": "No s'ha pogut actualitzar {app}: {error}",
"app_upgrade_some_app_failed": "No s'han pogut actualitzar algunes aplicacions",
"app_upgraded": "S'ha actualitzat {app}",
- "ask_firstname": "Nom",
- "ask_lastname": "Cognom",
"ask_main_domain": "Domini principal",
"ask_new_admin_password": "Nova contrasenya d'administrador",
"ask_password": "Contrasenya",
@@ -113,7 +108,6 @@
"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».",
"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 {wrong_ehlo}
{right_ehlo}
/etc/resolv.conf
kein Eintrag auf 127.0.0.1
zeigt.",
"diagnosis_ip_weird_resolvconf_details": "Die Datei /etc/resolv.conf
muss ein Symlink auf /etc/resolvconf/run/resolv.conf
sein, welcher auf 127.0.0.1
(dnsmasq) zeigt. Falls du die DNS-Resolver manuell konfigurieren möchtest, bearbeite bitte /etc/resolv.dnsmasq.conf
.",
"diagnosis_dns_good_conf": "DNS Einträge korrekt konfiguriert für die Domäne {domain} (Kategorie {category})",
@@ -303,7 +281,7 @@
"backup_archive_corrupted": "Das Backup-Archiv '{archive}' scheint beschädigt: {error}",
"backup_archive_cant_retrieve_info_json": "Die Informationen für das Archiv '{archive}' konnten nicht geladen werden... Die Datei info.json wurde nicht gefunden (oder ist kein gültiges json).",
"app_packaging_format_not_supported": "Diese App kann nicht installiert werden da das Paketformat nicht von der YunoHost-Version unterstützt wird. Am besten solltest du dein System aktualisieren.",
- "certmanager_domain_not_diagnosed_yet": "Für die Domain {domain} gibt es noch keine Diagnose-Resultate. Bitte widerhole die Diagnose für die Kategorien 'DNS records' und 'Web' im Diagnose-Bereich um zu überprüfen ob die Domain für Let's Encrypt bereit ist. (Wenn du weißt was du tust, kannst du --no-checks benutzen, um diese Überprüfung zu überspringen.)",
+ "certmanager_domain_not_diagnosed_yet": "Für die Domäne {domain} gibt es noch keine Diagnose-Resultate. Bitte wiederholen Sie die Diagnose für die Kategorien 'DNS-Einträge' und 'Web' im Diagnose-Bereich um zu überprüfen ob die Domäne für Let's Encrypt bereit ist. (Wenn Sie wissen was Sie tun, können Sie --no-checks benutzen, um diese Überprüfung zu überspringen.)",
"mail_unavailable": "Diese E-Mail Adresse ist reserviert und wird dem ersten Konto automatisch zugewiesen",
"diagnosis_services_conf_broken": "Die Konfiguration für den Dienst {service} ist fehlerhaft!",
"diagnosis_services_running": "Dienst {service} läuft!",
@@ -313,7 +291,7 @@
"diagnosis_domain_not_found_details": "Die Domäne {domain} existiert nicht in der WHOIS-Datenbank oder sie ist abgelaufen!",
"diagnosis_domain_expiration_not_found": "Das Ablaufdatum einiger Domains kann nicht überprüft werden",
"diagnosis_dns_try_dyndns_update_force": "Die DNS-Konfiguration dieser Domäne sollte automatisch von YunoHost verwaltet werden. Andernfalls könntest Du mittels {type}
{name}
{current}
{value}
",
"diagnosis_dns_missing_record": "Gemäß der empfohlenen DNS-Konfiguration solltest du einen DNS-Eintrag mit den folgenden Informationen hinzufügen.{type}
{name}
{value}
",
"diagnosis_dns_bad_conf": "Einige DNS-Einträge für die Domäne {domain} fehlen oder sind nicht korrekt (Kategorie {category})",
@@ -324,14 +302,14 @@
"diagnosis_services_bad_status": "Der Dienst {service} ist {status} :(",
"diagnosis_diskusage_verylow": "Der Speicher {mountpoint}
(auf Gerät {device}
) hat nur noch {free} ({free_percent}%) freien Speicherplatz (von ingesamt {total}). Du solltest ernsthaft in Betracht ziehen, etwas Seicherplatz frei zu machen!",
"diagnosis_http_ok": "Die Domäne {domain} ist über HTTP von außerhalb des lokalen Netzwerks erreichbar.",
- "diagnosis_mail_outgoing_port_25_blocked_relay_vpn": "Einige Hosting-Anbieter werden es dir nicht gestatten, den ausgehenden Port 25 zu öffnen, da diese sich nicht um die Netzneutralität kümmern.{ehlo_domain}
zu konfigurieren. (Gewisse Hosting-Anbieter können dafür möglicherweise verlangen, dass du dafür ein Support-Ticket erstellst).",
- "diagnosis_mail_fcrdns_dns_missing": "Es wurde kein Reverse-DNS-Eintrag definiert für IPv{ipversion}. Einige E-Mails könnten möglicherweise zurückgewiesen oder als Spam markiert werden.",
+ "diagnosis_mail_fcrdns_nok_details": "Sie sollten zuerst versuchen, auf Ihrer Internet-Router-Oberfläche, in Ihrer Internet-Box oder auf Ihrer Hosting-Anbieter-Oberfläche den Reverse-DNS-Eintrag mit {ehlo_domain}
zu konfigurieren. (Gewisse Hosting-Anbieter können möglicherweise verlangen, dass Sie dafür ein Support-Ticket erstellen).",
+ "diagnosis_mail_fcrdns_dns_missing": "Kein Reverse-DNS-Eintrag ist definiert für IPv{ipversion}. Einige E-Mails könnten eventuell nicht zugestellt oder als Spam markiert werden.",
"diagnosis_mail_fcrdns_ok": "Dein Reverse-DNS-Eintrag ist korrekt konfiguriert!",
"diagnosis_mail_ehlo_could_not_diagnose_details": "Fehler: {error}",
- "diagnosis_mail_ehlo_could_not_diagnose": "Konnte nicht überprüfen, ob der Postfix-Mail-Server von aussen per IPv{ipversion} erreichbar ist.",
+ "diagnosis_mail_ehlo_could_not_diagnose": "Es war nicht möglich zu diagnostizieren, ob der Postfix-Mailserver von Aussen über IPv{ipversion} erreichbar ist.",
"diagnosis_mail_ehlo_wrong_details": "Die vom Remote-Diagnose-Server per IPv{ipversion} empfangene EHLO weicht von der Domäne deines Servers ab. {wrong_ehlo}
{right_ehlo}
{item}
ist auf der Blacklist auf {blacklist_name}",
"diagnosis_mail_blacklist_ok": "Die IP-Adressen und die Domänen, welche von diesem Server verwendet werden, scheinen nicht auf einer Blacklist zu sein",
"diagnosis_mail_fcrdns_different_from_ehlo_domain_details": "Aktueller Reverse-DNS-Eintrag: {rdns_domain}
{ehlo_domain}
",
- "diagnosis_mail_fcrdns_different_from_ehlo_domain": "Der Reverse-DNS-Eintrag für IPv{ipversion} ist nicht korrekt konfiguriert. Einige E-Mails könnten abgewiesen oder als Spam markiert werden.",
- "diagnosis_mail_fcrdns_nok_alternatives_6": "Einige Provider werden es dir nicht erlauben, deinen Reverse-DNS-Eintrag zu konfigurieren (oder ihre Funktionalität könnte defekt sein ...). Falls du deinen Reverse-DNS-Eintrag für IPv4 korrekt konfiguiert ist, kannst du versuchen, die Verwendung von IPv6 für das Versenden von E-Mails auszuschalten, indem du den Befehl {type}
{name}
{current}
{value}
",
"diagnosis_dns_good_conf": "DNS records are correctly configured for domain {domain} (category {category})",
"diagnosis_dns_missing_record": "According to the recommended DNS configuration, you should add a DNS record with the following info.{type}
{name}
{value}
",
- "diagnosis_dns_point_to_doc": "Please check the documentation at https://yunohost.org/dns_config if you need help about configuring DNS records.",
+ "diagnosis_dns_point_to_doc": "Please check the documentation at https://yunohost.org/dns_config if you need help configuring DNS records.",
"diagnosis_dns_specialusedomain": "Domain {domain} is based on a special-use top-level domain (TLD) such as .local or .test and is therefore not expected to have actual DNS records.",
"diagnosis_dns_try_dyndns_update_force": "This domain's DNS configuration should automatically be managed by YunoHost. If that's not the case, you can try to force an update using /etc/resolv.conf
not pointing to 127.0.0.1
.",
"diagnosis_ip_connected_ipv4": "The server is connected to the Internet through IPv4!",
"diagnosis_ip_connected_ipv6": "The server is connected to the Internet through IPv6!",
@@ -231,32 +255,33 @@
"diagnosis_ip_no_ipv4": "The server does not have working IPv4.",
"diagnosis_ip_no_ipv6": "The server does not have working IPv6.",
"diagnosis_ip_no_ipv6_tip": "Having a working IPv6 is not mandatory for your server to work, but it is better for the health of the Internet as a whole. IPv6 should usually be automatically configured by the system or your provider if it's available. Otherwise, you might need to configure a few things manually as explained in the documentation here: https://yunohost.org/#/ipv6. If you cannot enable IPv6 or if it seems too technical for you, you can also safely ignore this warning.",
+ "diagnosis_ip_no_ipv6_tip_important": "IPv6 should usually be automatically configured by the system or your provider if it's available. Otherwise, you might need to configure a few things manually as explained in the documentation here: https://yunohost.org/#/ipv6.",
"diagnosis_ip_not_connected_at_all": "The server does not seem to be connected to the Internet at all!?",
"diagnosis_ip_weird_resolvconf": "DNS resolution seems to be working, but it looks like you're using a custom /etc/resolv.conf
.",
"diagnosis_ip_weird_resolvconf_details": "The file /etc/resolv.conf
should be a symlink to /etc/resolvconf/run/resolv.conf
itself pointing to 127.0.0.1
(dnsmasq). If you want to manually configure DNS resolvers, please edit /etc/resolv.dnsmasq.conf
.",
"diagnosis_mail_blacklist_listed_by": "Your IP or domain {item}
is blacklisted on {blacklist_name}",
"diagnosis_mail_blacklist_ok": "The IPs and domains used by this server do not appear to be blacklisted",
"diagnosis_mail_blacklist_reason": "The blacklist reason is: {reason}",
- "diagnosis_mail_blacklist_website": "After identifying why you are listed and fixed it, feel free to ask for your IP or domaine to be removed on {blacklist_website}",
+ "diagnosis_mail_blacklist_website": "After identifying why you are listed and fixing it, feel free to ask for your IP or domain to be removed on {blacklist_website}",
"diagnosis_mail_ehlo_bad_answer": "A non-SMTP service answered on port 25 on IPv{ipversion}",
- "diagnosis_mail_ehlo_bad_answer_details": "It could be due to an other machine answering instead of your server.",
- "diagnosis_mail_ehlo_could_not_diagnose": "Could not diagnose if postfix mail server is reachable from outside in IPv{ipversion}.",
+ "diagnosis_mail_ehlo_bad_answer_details": "It could be due to an another machine answering instead of your server.",
+ "diagnosis_mail_ehlo_could_not_diagnose": "Could not diagnose if postfix mail server is reachable from the outside in IPv{ipversion}.",
"diagnosis_mail_ehlo_could_not_diagnose_details": "Error: {error}",
"diagnosis_mail_ehlo_ok": "The SMTP mail server is reachable from the outside and therefore is able to receive emails!",
"diagnosis_mail_ehlo_unreachable": "The SMTP mail server is unreachable from the outside on IPv{ipversion}. It won't be able to receive emails.",
"diagnosis_mail_ehlo_unreachable_details": "Could not open a connection on port 25 to your server in IPv{ipversion}. It appears to be unreachable.{wrong_ehlo}
{right_ehlo}
{rdns_domain}
{ehlo_domain}
",
- "diagnosis_mail_fcrdns_dns_missing": "No reverse DNS is defined in IPv{ipversion}. Some emails may fail to get delivered or may get flagged as spam.",
+ "diagnosis_mail_fcrdns_dns_missing": "No reverse DNS is defined in IPv{ipversion}. Some emails may fail to get delivered or be flagged as spam.",
"diagnosis_mail_fcrdns_nok_alternatives_4": "Some providers won't let you configure your reverse DNS (or their feature might be broken...). If you are experiencing issues because of this, consider the following solutions:{ehlo_domain}
in your internet router interface or your hosting provider interface. (Some hosting provider may require you to send them a support ticket for this).",
+ "diagnosis_mail_fcrdns_nok_alternatives_6": "Some providers won't let you configure your reverse DNS (or their feature might be broken...). If your reverse DNS is correctly configured for IPv4, you can try disabling the use of IPv6 when sending emails by running {ehlo_domain}
in your internet router interface or your hosting provider interface. (Some hosting providers may require you to send them a support ticket for this).",
"diagnosis_mail_fcrdns_ok": "Your reverse DNS is correctly configured!",
"diagnosis_mail_outgoing_port_25_blocked": "The SMTP mail server cannot send emails to other servers because outgoing port 25 is blocked in IPv{ipversion}.",
- "diagnosis_mail_outgoing_port_25_blocked_details": "You should first try to unblock outgoing port 25 in your internet router interface or your hosting provider interface. (Some hosting provider may require you to send them a support ticket for this).",
- "diagnosis_mail_outgoing_port_25_blocked_relay_vpn": "Some providers won't let you unblock outgoing port 25 because they don't care about Net Neutrality.{file}
appears to have been manually modified.",
"diagnosis_regenconf_manually_modified_details": "This is probably OK if you know what you're doing! YunoHost will stop updating this file automatically... But beware that YunoHost upgrades could contain important recommended changes. If you want to, you can inspect the differences with {mountpoint}
(sur aparato {device}
) nur restas {free} ({free_percent}%) spaco restanta (el {total}). Estu zorgema.",
"diagnosis_diskusage_ok": "Stokado {mountpoint}
(sur aparato {device}
) ankoraŭ restas {free} ({free_percent}%) spaco (el {total})!",
- "global_settings_setting_pop3_enabled": "Ebligu la protokolon POP3 por la poŝta servilo",
"diagnosis_unknown_categories": "La jenaj kategorioj estas nekonataj: {categories}",
"diagnosis_services_running": "Servo {service} funkcias!",
"diagnosis_ports_unreachable": "Haveno {port} ne atingeblas de ekstere.",
@@ -516,7 +491,6 @@
"diagnosis_http_partially_unreachable": "Domajno {domain} ŝajnas neatingebla per HTTP de ekster la loka reto en IPv {failed}, kvankam ĝi funkcias en IPv {passed}.",
"diagnosis_http_nginx_conf_not_up_to_date": "La nginx-agordo de ĉi tiu domajno ŝajnas esti modifita permane, kaj malhelpas YunoHost diagnozi ĉu ĝi atingeblas per HTTP.",
"diagnosis_http_nginx_conf_not_up_to_date_details": "Por solvi la situacion, inspektu la diferencon per la komandlinio per /etc/resolv.conf
no apunta a 127.0.0.1
.",
- "diagnosis_dns_missing_record": "Según la configuración DNS recomendada, deberías añadir un registro DNS\ntipo: {type}\nnombre: {name}\nvalor: {value}",
+ "diagnosis_dns_missing_record": "Según la configuración DNS recomendada, deberías añadir un registro DNS con las informaciones siguientes. Tipo: {type}
{name}
{value}
",
"diagnosis_diskusage_low": "El almacenamiento {mountpoint}
(en el dispositivo {device}
) solo tiene {free} ({free_percent}%) de espacio disponible (de {total}). Ten cuidado.",
"diagnosis_services_bad_status_tip": "Puedes intentar reiniciar el servicio, y si no funciona, echar un vistazo a los logs del serviciode la administración web (desde la línea de comandos puedes hacerlo con {file}
parece que ha sido modificado manualmente.",
"diagnosis_regenconf_manually_modified_details": "¡Esto probablemente esta BIEN si sabes lo que estás haciendo! YunoHost dejará de actualizar este fichero automáticamente... Pero ten en cuenta que las actualizaciones de YunoHost pueden contener importantes cambios que están recomendados. Si quieres puedes comprobar las diferencias mediante {global}
",
"diagnosis_mail_outgoing_port_25_ok": "El servidor de email SMTP puede mandar emails (puerto saliente 25 no está bloqueado).",
- "diagnosis_mail_outgoing_port_25_blocked_details": "Primeramente deberías intentar desbloquear el puerto de salida 25 en la interfaz de control de tu router o en la interfaz de tu provedor de hosting. (Algunos hosting pueden necesitar que les abras un ticket de soporte para esto).",
- "diagnosis_swap_tip": "Por favor tenga cuidado y sepa que si el servidor contiene swap en una tarjeta SD o un disco duro de estado sólido, esto reducirá drásticamente la vida útil del dispositivo.",
+ "diagnosis_mail_outgoing_port_25_blocked_details": "Primeramente, deberías intentar desbloquear el puerto de salida 25 en la interfaz de control de tu router o en la interfaz de tu proveedor de hosting. (Algunos hostings pueden necesitar que les abras un ticket de soporte para esto).",
+ "diagnosis_swap_tip": "Por favor, tenga cuidado y sepa que si el servidor contiene swap en una tarjeta SD o un disco duro de estado sólido, esto reducirá drásticamente la vida útil del dispositivo.",
"diagnosis_domain_expires_in": "{domain} expira en {days} días.",
"diagnosis_domain_expiration_error": "¡Algunos dominios expirarán MUY PRONTO!",
"diagnosis_domain_expiration_warning": "¡Algunos dominios expirarán pronto!",
@@ -510,13 +485,10 @@
"app_label_deprecated": "Este comando está depreciado! Favor usar el nuevo comando 'yunohost user permission update' para administrar la etiqueta de app.",
"app_argument_password_no_default": "Error al interpretar argumento de contraseña'{name}': El argumento de contraseña no puede tener un valor por defecto por razón de seguridad",
"invalid_regex": "Regex no valido: «{regex}»",
- "global_settings_setting_backup_compress_tar_archives": "Cuando se creen nuevas copias de respaldo, comprimir los archivos (.tar.gz) en lugar de descomprimir los archivos (.tar). N.B.: activar esta opción quiere decir que los archivos serán más pequeños pero que el proceso tardará más y utilizará más CPU.",
"global_settings_setting_smtp_relay_password": "Clave de uso del SMTP",
"global_settings_setting_smtp_relay_user": "Cuenta de uso de SMTP",
"global_settings_setting_smtp_relay_port": "Puerto de envio / relay SMTP",
- "global_settings_setting_smtp_relay_host": "El servidor relay de SMTP para enviar correo en lugar de esta instalación YunoHost. Útil si estás en una de estas situaciones: tu puerto 25 esta bloqueado por tu ISP o VPS, si estás en usado una IP marcada como residencial o DUHL, si no puedes configurar un DNS inverso o si el servidor no está directamente expuesto a internet y quieres utilizar otro servidor para enviar correos.",
- "global_settings_setting_smtp_allow_ipv6": "Permitir el uso de IPv6 para enviar y recibir correo",
- "diagnosis_processes_killed_by_oom_reaper": "Algunos procesos fueron terminados por el sistema recientemente porque se quedó sin memoria. Típicamente es sintoma de falta de memoria o de un proceso que se adjudicó demasiada memoria.{item}
está marcado como maligno en {blacklist_name}",
"diagnosis_mail_blacklist_ok": "Las IP y los dominios utilizados en este servidor no parece que estén en ningún listado maligno (blacklist)",
"diagnosis_mail_fcrdns_different_from_ehlo_domain_details": "El DNS inverso actual es: {rdns_domain}
{ehlo_domain}
",
"diagnosis_mail_fcrdns_different_from_ehlo_domain": "La resolución de DNS inverso no está correctamente configurada mediante IPv{ipversion}. Algunos correos pueden fallar al ser enviados o pueden ser marcados como basura.",
- "diagnosis_mail_fcrdns_nok_alternatives_6": "Algunos proveedores no permiten configurar el DNS inverso (o su funcionalidad puede estar rota...). Si tu DNS inverso está configurado correctamente para IPv4, puedes intentar deshabilitarlo para IPv6 cuando envies correos mediante el comando {ehlo_domain}
en la interfaz de internet de tu router o en la de tu proveedor de internet. (Algunos proveedores de internet en ocasiones necesitan que les solicites un ticket de soporte para ello).",
"diagnosis_mail_fcrdns_dns_missing": "No hay definida ninguna DNS inversa mediante IPv{ipversion}. Algunos correos puede que fallen al enviarse o puede que se marquen como basura.",
@@ -547,11 +519,11 @@
"diagnosis_mail_ehlo_unreachable_details": "No pudo abrirse la conexión en el puerto 25 de tu servidor mediante IPv{ipversion}. Parece que no se puede contactar.{global}
",
- "app_argument_password_no_default": "Errorea egon da '{name}' pasahitzaren argumentua ikuskatzean: pasahitzak ezin du balio hori izan segurtasuna dela-eta",
+ "app_argument_password_no_default": "Errorea egon da '{name}' pasahitzaren argumentua ikuskatzean: pasahitzak ezin du balio hori izan segurtasun arrazoiengatik",
"app_extraction_failed": "Ezinezkoa izan da instalazio fitxategiak ateratzea",
- "app_requirements_unmeet": "{app}(e)k behar dituen baldintzak ez dira betetzen, {pkgname} ({version}) paketea {spec} izan behar da",
- "backup_deleted": "Babeskopia ezabatuta",
+ "backup_deleted": "Babeskopia ezabatu da: {name}",
"app_argument_required": "'{name}' argumentua ezinbestekoa da",
- "certmanager_acme_not_configured_for_domain": "Ezinezkoa da ACME azterketa {domain} domeinurako burutzea une honetan nginx ezarpenek ez dutelako beharrezko kodea… Egiaztatu nginx ezarpenak egunean daudela 'yunohost tools regen-conf nginx --dry-run --with-diff' komandoa exekutatuz.",
- "certmanager_domain_dns_ip_differs_from_public_ip": "'{domain}' domeinurako DNS balioak ez datoz bat zerbitzariaren IParekin. Mesedez, egiaztatu 'DNS balioak' (oinarrizkoa) kategoria diagnostikoen atalean. A balioak duela gutxi aldatu badituzu, itxaron hedatu daitezen (badaude DNSen hedapena ikusteko erramintak interneten). (Zertan ari zeren baldin badakizu, erabili '--no-checks' egiaztapen horiek desgaitzeko.)",
- "confirm_app_install_thirdparty": "KONTUZ! Aplikazio hau ez da YunoHosten aplikazioen katalogokoa. Kanpoko aplikazioek sistemaren integritate eta segurtasuna arriskuan jarri dezakete. Ziur asko EZ zenuke instalatu beharko zertan ari zaren ez badakizu. Aplikazio hau ez badabil edo sistema kaltetzen badu EZ DA LAGUNTZARIK EMANGO… aurrera jarraitu nahi duzu hala ere? Aukeratu '{answers}'",
+ "certmanager_acme_not_configured_for_domain": "Une honetan ezinezkoa da ACME azterketa {domain} domeinurako burutzea nginx ezarpenek ez dutelako beharrezko kodea… Egiaztatu nginx ezarpenak egunean daudela 'yunohost tools regen-conf nginx --dry-run --with-diff' komandoa exekutatuz.",
+ "certmanager_domain_dns_ip_differs_from_public_ip": "'{domain}' domeinurako DNS balioak ez datoz bat zerbitzariaren IParekin. Egiaztatu 'DNS balioak' (oinarrizkoa) kategoria diagnostikoen atalean. 'A' balioak duela gutxi aldatu badituzu, itxaron hedatu daitezen (badaude DNSen hedapena ikusteko erramintak interneten). (Zertan ari zeren baldin badakizu, erabili '--no-checks' egiaztapen horiek desgaitzeko.)",
+ "confirm_app_install_thirdparty": "KONTUZ! Aplikazio hau ez da YunoHosten aplikazioen katalogokoa. Kanpoko aplikazioek sistemaren integritate eta segurtasuna arriskuan jar dezakete. Ziur asko EZ zenuke instalatu beharko zertan ari zaren ez badakizu. Aplikazio hau ez badabil edo sistema kaltetzen badu EZ DA LAGUNTZARIK EMANGO… aurrera jarraitu nahi duzu hala ere? Hautatu '{answers}'",
"app_start_remove": "{app} ezabatzen…",
"diagnosis_http_hairpinning_issue_details": "Litekeena da erantzulea zure kable-modem / routerra izatea. Honen eraginez, saretik kanpo daudenek zerbitzaria arazorik gabe erabili ahal izango dute, baina sare lokalean bertan daudenek (ziur asko zure kasua) ezingo dute kanpoko IPa edo domeinu izena erabili zerbitzarira konektatzeko. Egoera hobetu edo guztiz konpontzeko, irakurri dokumentazioa",
"diagnosis_http_special_use_tld": "{domain} domeinua top-level domain (TLD) motakoa da .local edo .test bezala eta ez du sare lokaletik kanpo eskuragarri zertan egon.",
- "diagnosis_ip_weird_resolvconf_details": "/etc/resolv.conf
fitxategia symlink bat izan beharko litzateke 127.0.0.1
ra adi dagoen /etc/resolvconf/run/resolv.conf
fitxategira (dnsmasq). DNS ebazleak eskuz konfiguratu nahi badituzu, mesedez aldatu /etc/resolv.dnsmasq.conf
fitxategia.",
+ "diagnosis_ip_weird_resolvconf_details": "/etc/resolv.conf
fitxategia symlink bat izan beharko litzateke 127.0.0.1
ra adi dagoen /etc/resolvconf/run/resolv.conf
fitxategira (dnsmasq). DNS ebazleak eskuz konfiguratu nahi badituzu, aldatu /etc/resolv.dnsmasq.conf
fitxategia.",
"diagnosis_ip_connected_ipv4": "Zerbitzaria IPv4 bidez dago internetera konektatuta!",
"diagnosis_basesystem_ynh_inconsistent_versions": "YunoHost paketeen bertsioak ez datoz bat… ziur asko noizbait eguneraketa batek kale egin edo erabat amaitu ez zuelako.",
"diagnosis_high_number_auth_failures": "Azken aldian kale egin duten saio-hasiera saiakera ugari egon dira. Egiaztatu fail2ban martxan dabilela eta egoki konfiguratuta dagoela, edo erabili beste ataka bat SSHrako dokumentazioan azaldu bezala.",
@@ -28,7 +25,7 @@
"app_install_files_invalid": "Ezin dira fitxategi hauek instalatu",
"diagnosis_description_ip": "Internet konexioa",
"diagnosis_description_dnsrecords": "DNS erregistroak",
- "app_label_deprecated": "Komando hau zaharkitua dago! Mesedez, erabili 'yunohost user permission update' komando berria aplikazioaren etiketa kudeatzeko.",
+ "app_label_deprecated": "Komando hau zaharkitua dago! Erabili 'yunohost user permission update' komando berria aplikazioaren etiketa kudeatzeko.",
"confirm_app_install_danger": "KONTUZ! Aplikazio hau esperimentala da (edo ez dabil)! Ez zenuke instalatu beharko zertan ari zaren ez badakizu. Aplikazio hau ez badabil edo sistema kaltetzen badu, EZ DA LAGUNTZARIK EMANGO… aurrera jarraitu nahi al duzu hala ere? Aukeratu '{answers}'",
"diagnosis_description_systemresources": "Sistemaren baliabideak",
"backup_csv_addition_failed": "Ezinezkoa izan da fitxategiak CSV fitxategira kopiatzea",
@@ -44,7 +41,6 @@
"diagnosis_ip_not_connected_at_all": "Badirudi zerbitzaria ez dagoela internetera konektatuta!?",
"app_already_up_to_date": "{app} egunean da dagoeneko",
"app_change_url_success": "{app} aplikazioaren URLa {domain}{path} da orain",
- "admin_password_too_long": "Mesedez, aukeratu 127 karaktere baino laburragoa den pasahitz bat",
"app_action_broke_system": "Eragiketa honek {services} zerbitzu garrantzitsua(k) hondatu d(it)uela dirudi",
"diagnosis_basesystem_hardware_model": "Zerbitzariaren modeloa {model} da",
"already_up_to_date": "Ez dago egiteko ezer. Guztia dago egunean.",
@@ -53,7 +49,6 @@
"config_validate_email": "Benetazko posta elektronikoa izan behar da",
"config_validate_time": "OO:MM formatua duen ordu bat izan behar da",
"config_validate_url": "Benetazko URL bat izan behar da",
- "config_version_not_supported": "Ezinezkoa da konfigurazio-panelaren '{version}' bertsioa erabiltzea.",
"app_restore_script_failed": "Errorea gertatu da aplikazioa lehengoratzeko aginduan",
"app_upgrade_some_app_failed": "Ezinezkoa izan da aplikazio batzuk eguneratzea",
"app_install_failed": "Ezinezkoa izan da {app} instalatzea: {error}",
@@ -84,8 +79,6 @@
"app_upgrade_failed": "Ezinezkoa izan da {app} eguneratzea: {error}",
"app_upgrade_app_name": "Orain {app} eguneratzen…",
"app_upgraded": "{app} eguneratu da",
- "ask_firstname": "Izena",
- "ask_lastname": "Abizena",
"ask_main_domain": "Domeinu nagusia",
"config_forbidden_keyword": "'{keyword}' etiketa sistemak bakarrik erabil dezake; ezin da ID hau daukan baliorik sortu edo erabili.",
"config_unknown_filter_key": "'{filter_key}' filtroaren kakoa ez da zuzena.",
@@ -118,12 +111,12 @@
"apps_catalog_updating": "Aplikazioen katalogoa eguneratzen…",
"certmanager_cert_signing_failed": "Ezinezkoa izan da ziurtagiri berria sinatzea",
"certmanager_cert_renew_success": "Let's Encrypt ziurtagiria berriztu da '{domain}' domeinurako",
- "app_requirements_checking": "{app}(e)k behar dituen paketeak ikuskatzen…",
+ "app_requirements_checking": "{app}(e)k behar dituen betekizunak egiaztatzen…",
"certmanager_unable_to_parse_self_CA_name": "Ezinezkoa izan da norberak sinatutako ziurtagiriaren izena prozesatzea (fitxategia: {file})",
"app_remove_after_failed_install": "Aplikazioa ezabatzen instalatzerakoan errorea dela-eta…",
"diagnosis_basesystem_ynh_single_version": "{package} bertsioa: {version} ({repo})",
"diagnosis_failed_for_category": "'{category}' ataleko diagnostikoak kale egin du: {error}",
- "diagnosis_cache_still_valid": "(Cachea oraindik baliogarria da {category} ataleko diagnosirako. Ez da berrabiaraziko!)",
+ "diagnosis_cache_still_valid": "(Katxea oraindik baliogarria da {category} ataleko diagnosirako. Ez da berrabiaraziko!)",
"diagnosis_found_errors": "{category} atalari dago(z)kion {errors} arazo aurkitu d(ir)a!",
"diagnosis_found_warnings": "{category} atalari dagokion eta hobetu daite(z)keen {warnings} abisu aurkitu d(ir)a.",
"diagnosis_ip_connected_ipv6": "Zerbitzaria IPv6 bidez dago internetera konektatuta!",
@@ -141,12 +134,12 @@
"diagnosis_http_unreachable": "Badirudi {domain} domeinua ez dagoela eskuragarri HTTP bidez sare lokaletik kanpo.",
"apps_catalog_failed_to_download": "Ezinezkoa izan da {apps_catalog} aplikazioen zerrenda eskuratzea: {error}",
"apps_catalog_init_success": "Abiarazi da aplikazioen katalogo sistema!",
- "apps_catalog_obsolete_cache": "Aplikazioen katalogoaren cachea hutsik edo zaharkituta dago.",
+ "apps_catalog_obsolete_cache": "Aplikazioen katalogoaren katxea hutsik edo zaharkituta dago.",
"diagnosis_description_mail": "Posta elektronikoa",
"diagnosis_http_connection_error": "Arazoa konexioan: ezin izan da domeinu horretara konektatu, litekeena da eskuragarri ez egotea.",
"diagnosis_description_web": "Weba",
"diagnosis_display_tip": "Aurkitu diren arazoak ikusteko joan administrazio-atariko Diagnostikoak atalera, edo exekutatu 'yunohost diagnosis show --issues --human-readable' komandoak nahiago badituzu.",
- "diagnosis_dns_point_to_doc": "Mesedez, irakurri dokumentazioa DNS erregistroekin laguntza behar baduzu.",
+ "diagnosis_dns_point_to_doc": "Irakurri dokumentazioa DNS erregistroekin laguntza behar baduzu.",
"diagnosis_mail_ehlo_unreachable": "SMTP posta zerbitzaria ez dago eskuragarri IPv{ipversion}ko sare lokaletik kanpo eta, beraz, ez da posta elektronikoa jasotzeko gai.",
"diagnosis_mail_ehlo_bad_answer_details": "Litekeena da zure zerbitzaria ez den beste gailu batek erantzun izana.",
"diagnosis_mail_blacklist_listed_by": "Zure domeinua edo {item}
IPa {blacklist_name} zerrenda beltzean ageri da",
@@ -154,10 +147,10 @@
"diagnosis_http_could_not_diagnose_details": "Errorea: {error}",
"diagnosis_http_hairpinning_issue": "Dirudienez zure sareak ez du hairpinninga gaituta.",
"diagnosis_http_partially_unreachable": "Badirudi {domain} domeinua ezin dela bisitatu HTTP bidez IPv{failed} sare lokaletik kanpo, bai ordea IPv{passed} erabiliz.",
- "backup_archive_cant_retrieve_info_json": "Ezinezkoa izan da '{archive}' fitxategiko informazioa eskuratzea… info.json ezin izan da eskuratu (edo ez da baliozko jsona).",
+ "backup_archive_cant_retrieve_info_json": "Ezinezkoa izan da '{archive}' fitxategiko informazioa eskuratzea… info.json fitxategia ezin izan da eskuratu (edo ez da baliozko jsona).",
"diagnosis_domain_expiration_not_found": "Ezinezkoa izan da domeinu batzuen iraungitze data egiaztatzea",
"diagnosis_domain_expiration_not_found_details": "Badirudi {domain} domeinuari buruzko WHOIS informazioak ez duela zehazten noiz iraungiko den.",
- "certmanager_domain_not_diagnosed_yet": "Oraindik ez dago {domain} domeinurako diagnostikorik. Mesedez, berrabiarazi diagnostikoak 'DNS balioak' eta 'Web' ataletarako diagnostikoen gunean Let's Encrypt ziurtagirirako prest ote dagoen egiaztatzeko. (Edo zertan ari zaren baldin badakizu, erabili '--no-checks' egiaztatzea desgaitzeko.)",
+ "certmanager_domain_not_diagnosed_yet": "Oraindik ez dago {domain} domeinurako diagnostikorik. Berrabiarazi diagnostikoak 'DNS balioak' eta 'Web' ataletarako diagnostikoen gunean Let's Encrypt ziurtagirirako prest ote dagoen egiaztatzeko. (Edo zertan ari zaren baldin badakizu, erabili '--no-checks' egiaztatzea desgaitzeko.)",
"diagnosis_domain_expiration_warning": "Domeinu batzuk iraungitzear daude!",
"app_packaging_format_not_supported": "Aplikazio hau ezin da instalatu YunoHostek ez duelako paketea ezagutzen. Sistema eguneratzea hausnartu beharko zenuke ziur asko.",
"diagnosis_dns_try_dyndns_update_force": "Domeinu honen DNS konfigurazioa YunoHostek kudeatu beharko luke automatikoki. Gertatuko ez balitz, eguneratzera behartu zenezake /etc/resolv.conf
fitxategia erabiltzen ari zara.",
"diagnosis_dns_bad_conf": "DNS balio batzuk falta dira edo ez dira zuzenak {domain} domeinurako ({category} atala)",
- "diagnosis_diskusage_ok": "{mountpoint}
fitxategi-sistemak ({device}
euskarrian) edukieraren {free} (%{free_percent}a) ditu erabilgarri oraindik ({total} orotara)!",
+ "diagnosis_diskusage_ok": "{mountpoint}
fitxategi-sistemak ({device}
euskarrian) edukieraren {free} (%{free_percent}a) ditu oraindik erabilgarri ({total} orotara)!",
"apps_catalog_update_success": "Aplikazioen katalogoa eguneratu da!",
"certmanager_warning_subdomain_dns_record": "'{subdomain}' azpidomeinuak ez dauka '{domain}'(e)k duen IP bera. Ezaugarri batzuk ez dira erabilgarri egongo hau zuzendu arte eta ziurtagiri bat birsortu arte.",
"app_argument_choice_invalid": "Hautatu ({choices}) aukeretako bat '{name}' argumenturako: '{value}' ez dago aukera horien artean",
@@ -206,7 +199,7 @@
"backup_archive_writing_error": "Ezinezkoa izan da '{source}' ('{dest}' fitxategiak eskatu dituenak) fitxategia '{archive}' konprimatutako babeskopian sartzea",
"backup_ask_for_copying_if_needed": "Behin-behinean {size}MB erabili nahi dituzu babeskopia gauzatu ahal izateko? (Horrela egiten da fitxategi batzuk ezin direlako modu eraginkorragoan prestatu.)",
"backup_cant_mount_uncompress_archive": "Ezinezkoa izan da deskonprimatutako fitxategia muntatzea idazketa-babesa duelako",
- "backup_created": "Babeskopia sortu da",
+ "backup_created": "Babeskopia sortu da: {name}",
"backup_copying_to_organize_the_archive": "{size}MB kopiatzen fitxategia antolatzeko",
"backup_couldnt_bind": "Ezin izan da {src} {dest}-ra lotu.",
"backup_output_directory_forbidden": "Aukeratu beste katalogo bat emaitza gordetzeko. Babeskopiak ezin dira sortu /bin, /boot, /dev, /etc, /lib, /root, /run, /sbin, /sys, /usr, /var edo /home/yunohost.backup/archives azpi-katalogoetan",
@@ -223,8 +216,8 @@
"certmanager_cert_install_success_selfsigned": "Norberak sinatutako ziurtagiria instalatu da '{domain}' domeinurako",
"certmanager_domain_cert_not_selfsigned": "{domain} domeinurako ziurtagiria ez da norberak sinatutakoa. Ziur al zaude ordezkatu nahi duzula? (Erabili '--force' hori egiteko.)",
"certmanager_certificate_fetching_or_enabling_failed": "{domain} domeinurako ziurtagiri berriak kale egin du…",
- "certmanager_domain_http_not_working": "Ez dirudi {domain} domeinua HTTP bidez ikusgai dagoenik. Mesedez, egiaztatu 'Weba' atala diagnosien gunean informazio gehiagorako. (Zertan ari zaren baldin badakizu, erabili '--no-checks' egiaztapen horiek desgaitzeko.)",
- "certmanager_hit_rate_limit": "{domain} domeinu-multzorako ziurtagiri gehiegi jaulki dira dagoeneko. Mesedez, saia saitez geroago. Ikus https://letsencrypt.org/docs/rate-limits/ xehetasun gehiagorako",
+ "certmanager_domain_http_not_working": "Ez dirudi {domain} domeinua HTTP bidez ikusgai dagoenik. Egiaztatu 'Weba' atala diagnosien gunean informazio gehiagorako. (Zertan ari zaren baldin badakizu, erabili '--no-checks' egiaztapen horiek desgaitzeko.)",
+ "certmanager_hit_rate_limit": "{domain} domeinu-multzorako ziurtagiri gehiegi jaulki dira dagoeneko. Saia saitez geroago. Ikus https://letsencrypt.org/docs/rate-limits/ xehetasun gehiagorako",
"certmanager_no_cert_file": "Ezinezkoa izan da {domain} domeinurako ziurtagiri fitxategia irakurrtzea (fitxategia: {file})",
"certmanager_self_ca_conf_file_not_found": "Ezinezkoa izan da konfigurazio-fitxategia aurkitzea norberak sinatutako ziurtagirirako (fitxategia: {file})",
"confirm_app_install_warning": "Adi: litekeena da aplikazio hau ibiltzea baina ez dago YunoHostera egina. Ezaugarri batzuk, SSO edo babeskopia/lehengoratzea esaterako, desgaituta egon daitezke. Instalatu hala ere? [{answers}] ",
@@ -237,13 +230,13 @@
"certmanager_attempt_to_replace_valid_cert": "{domain} domeinurako egokia eta baliogarria den ziurtagiri bat ordezkatzen saiatzen ari zara! (Erabili --force mezu hau deuseztatu eta ziurtagiria ordezkatzeko)",
"diagnosis_backports_in_sources_list": "Dirudienez apt (pakete kudeatzailea) backports biltegia erabiltzeko konfiguratuta dago. Zertan ari zaren ez badakizu, ez zenuke backports biltegietako aplikaziorik instalatu beharko, ezegonkortasun eta gatazkak eragin ditzaketelako sistemarekin.",
"app_restore_failed": "Ezinezkoa izan da {app} lehengoratzea: {error}",
- "diagnosis_apps_allgood": "Instalatutako aplikazioek oinarrizko pakete-jarraibideekin bat egiten dute",
+ "diagnosis_apps_allgood": "Instalatutako aplikazioak bat datoz oinarrizko pakete-jarraibideekin",
"diagnosis_apps_bad_quality": "Aplikazio hau hondatuta dagoela dio YunoHosten aplikazioen katalogoak. Agian behin-behineko kontua da arduradunak arazoa konpondu bitartean. Oraingoz, ezin da aplikazioa eguneratu.",
"diagnosis_apps_broken": "Aplikazio hau YunoHosten aplikazioen katalogoan hondatuta dagoela ageri da. Agian behin-behineko kontua da arduradunak konpondu bitartean. Oraingoz, ezin da aplikazioa eguneratu.",
"diagnosis_apps_deprecated_practices": "Instalatutako aplikazio honen bertsioak oraindik darabiltza zaharkitutako pakete-jarraibideak. Eguneratzea hausnartu beharko zenuke.",
"diagnosis_apps_issue": "Arazo bat dago {app} aplikazioarekin",
- "diagnosis_apps_not_in_app_catalog": "Aplikazio hau ez da YunoHosten aplikazioen katalogokoa. Iraganean egon bazen eta ezabatu izan balitz, desinstalatzea litzateke onena, ez baitu eguneraketarik jasoko eta sistemaren integritate eta segurtasuna arriskuan jarri lezakeelako.",
- "diagnosis_apps_outdated_ynh_requirement": "Instalatutako aplikazio honen bertsioak yunohost >= 2.x baino ez du behar, eta horrek egungo pakete-jardunbideekin bat ez datorrela iradokitzen du. Eguneratzen saiatu beharko zinateke.",
+ "diagnosis_apps_not_in_app_catalog": "Aplikazio hau ez da YunoHosten aplikazioen katalogokoa. Iraganean egon bazen eta orain ez badago, desinstalatzea litzateke onena, ez baitu eguneraketarik jasoko eta sistemaren integritate eta segurtasuna arriskuan jar lezakeelako.",
+ "diagnosis_apps_outdated_ynh_requirement": "Instalatutako aplikazio honen bertsioak yunohost >= 2.x edo 3.x baino ez du behar, eta horrek eguneratua izan ez dela eta egungo pakete-jardunbideekin bat ez datorrela iradokitzen du. Eguneratzen saiatu beharko zinateke.",
"diagnosis_description_apps": "Aplikazioak",
"domain_dns_conf_special_use_tld": "Domeinu hau top-level domain (TLD) erabilera bereziko motakoa da .local edo .test bezala eta ez du DNS ezarpenik behar.",
"log_permission_create": "Sortu '{}' baimena",
@@ -253,10 +246,7 @@
"group_user_already_in_group": "{user} erabiltzailea {group} taldean dago dagoeneko",
"firewall_reloaded": "Suebakia birkargatu da",
"domain_unknown": "'{domain}' domeinua ezezaguna da",
- "global_settings_cant_serialize_settings": "Ezinezkoa izan da konfikurazio-datuak serializatzea, zergatia: {reason}",
- "global_settings_setting_security_nginx_redirect_to_https": "Birbideratu HTTP eskaerak HTTPSra (EZ ITZALI hau ez badakizu zertan ari zaren!)",
"group_deleted": "'{group}' taldea ezabatu da",
- "invalid_password": "Pasahitza ez da zuzena",
"log_domain_main_domain": "Lehenetsi '{}' domeinua",
"log_user_group_update": "Moldatu '{}' taldea",
"dyndns_could_not_check_available": "Ezinezkoa izan da {domain} {provider}(e)n eskuragarri dagoen egiaztatzea.",
@@ -280,22 +270,15 @@
"extracting": "Ateratzen…",
"diagnosis_ports_unreachable": "{port}. ataka ez dago eskuragarri kanpotik.",
"diagnosis_regenconf_manually_modified_details": "Ez dago arazorik zertan ari zaren baldin badakizu! YunoHostek fitxategi hau automatikoki eguneratzeari utziko dio… Baina kontuan izan YunoHosten eguneraketek aldaketa garrantzitsuak izan ditzaketela. Nahi izatekotan, desberdintasunak aztertu ditzakezu {rdns_domain}
{ehlo_domain}
",
"diagnosis_mail_queue_too_big": "Mezu gehiegi posta elektronikoaren ilaran: ({nb_pending} mezu)",
"diagnosis_ports_could_not_diagnose_details": "Errorea: {error}",
- "diagnosis_swap_tip": "Mesedez, kontuan hartu zerbitzari honen swap memoria SD edo SSD euskarri batean gordetzeak euskarri horren bizi-iraupena izugarri laburtu dezakeela.",
+ "diagnosis_swap_tip": "Kontuan hartu zerbitzari honen swap memoria SD edo SSD euskarri batean gordetzeak euskarri horren bizi-iraupena izugarri laburtu dezakeela.",
"invalid_regex": "'Regexa' ez da zuzena: '{regex}'",
"group_creation_failed": "Ezinezkoa izan da '{group}' taldea sortzea: {error}",
"log_user_permission_reset": "Berrezarri '{}' baimena",
@@ -416,31 +393,23 @@
"diagnosis_ports_forwarding_tip": "Arazoa konpontzeko, litekeena da operadorearen routerrean ataken birbideraketa konfiguratu behar izatea, https://yunohost.org/isp_box_config-n agertzen den bezala",
"domain_creation_failed": "Ezinezkoa izan da {domain} domeinua sortzea: {error}",
"domains_available": "Erabilgarri dauden domeinuak:",
- "global_settings_setting_pop3_enabled": "Gaitu POP3 protokoloa posta zerbitzarirako",
- "global_settings_setting_security_ssh_port": "SSH ataka",
- "global_settings_unknown_type": "Gertaera ezezaguna, {setting} ezarpenak {unknown_type} mota duela dirudi baina mota hori ez da sistemarekin bateragarria.",
"group_already_exist_on_system": "{group} taldea existitzen da dagoeneko sistemaren taldeetan",
"diagnosis_processes_killed_by_oom_reaper": "Memoria agortu eta sistemak prozesu batzuk amaituarazi behar izan ditu. Honek esan nahi du sistemak ez duela memoria nahikoa edo prozesuren batek memoria gehiegi behar duela. Amaituarazi d(ir)en prozesua(k):\n{kills_summary}",
"hook_exec_not_terminated": "Aginduak ez du behar bezala amaitu: {path}",
"log_corrupted_md_file": "Erregistroei lotutako YAML metadatu fitxategia kaltetuta dago: '{md_file}\nErrorea: {error}'",
"log_letsencrypt_cert_renew": "Berriztu '{}' Let's Encrypt ziurtagiria",
- "log_remove_on_failed_restore": "Ezabatu '{}' babeskopia baten lehengoratzeak huts egin eta gero",
"diagnosis_package_installed_from_sury_details": "Sury izena duen kanpoko biltegi batetik instalatu dira pakete batzuk, nahi gabe. YunoHosten taldeak hobekuntzak egin ditu pakete hauek kudeatzeko, baina litekeena da PHP7.3 aplikazioak Stretch sistema eragilean instalatu zituzten kasu batzuetan arazoak sortzea. Egoera hau konpontzeko, honako komando hau exekutatu beharko zenuke: {type}
{name}
{value}
",
"diagnosis_diskusage_ok": "L'espace de stockage {mountpoint}
(sur le périphérique {device}
) a encore {free} ({free_percent}%) d'espace restant (sur {total}) !",
"diagnosis_ram_ok": "Le système dispose encore de {available} ({available_percent}%) de RAM sur {total}.",
- "diagnosis_regenconf_allgood": "Tous les fichiers de configuration sont conformes à la configuration recommandée !",
- "diagnosis_security_vulnerable_to_meltdown": "Vous semblez vulnérable à la vulnérabilité de sécurité critique de Meltdown",
+ "diagnosis_regenconf_allgood": "Tous les fichiers de configuration sont conformes aux préconisations !",
+ "diagnosis_security_vulnerable_to_meltdown": "Vous semblez vulnérable à la faille de sécurité majeure qu'est Meltdown",
"diagnosis_basesystem_host": "Le serveur utilise Debian {debian_version}",
"diagnosis_basesystem_kernel": "Le serveur utilise le noyau Linux {kernel_version}",
"diagnosis_basesystem_ynh_single_version": "{package} version : {version} ({repo})",
"diagnosis_basesystem_ynh_main_version": "Le serveur utilise YunoHost {main_version} ({repo})",
"diagnosis_basesystem_ynh_inconsistent_versions": "Vous exécutez des versions incohérentes des packages YunoHost ... très probablement en raison d'une mise à niveau échouée ou partielle.",
- "diagnosis_failed_for_category": "Échec du diagnostic pour la catégorie '{category}': {error}",
+ "diagnosis_failed_for_category": "Échec du diagnostic pour la catégorie '{category}' : {error}",
"diagnosis_cache_still_valid": "(Le cache est encore valide pour le diagnostic {category}. Il ne sera pas re-diagnostiqué pour le moment !)",
"diagnosis_ignored_issues": "(+ {nb_ignored} problème(s) ignoré(s))",
"diagnosis_found_warnings": "Trouvé {warnings} objet(s) pouvant être amélioré(s) pour {category}.",
@@ -423,7 +399,7 @@
"diagnosis_ip_connected_ipv6": "Le serveur est connecté à Internet en IPv6 !",
"diagnosis_ip_no_ipv6": "Le serveur ne dispose pas d'une adresse IPv6.",
"diagnosis_ip_dnsresolution_working": "La résolution de nom de domaine fonctionne !",
- "diagnosis_ip_broken_dnsresolution": "La résolution du nom de domaine semble interrompue pour une raison quelconque ... Un pare-feu bloque-t-il les requêtes DNS ?",
+ "diagnosis_ip_broken_dnsresolution": "La résolution du nom de domaine semble bloquée ou interrompue pour une raison quelconque ... Un pare-feu bloque-t-il les requêtes DNS ?",
"diagnosis_ip_broken_resolvconf": "La résolution du nom de domaine semble être cassée sur votre serveur, ce qui semble lié au fait que /etc/resolv.conf
ne pointe pas vers 127.0.0.1
.",
"diagnosis_dns_good_conf": "Les enregistrements DNS sont correctement configurés pour le domaine {domain} (catégorie {category})",
"diagnosis_dns_bad_conf": "Certains enregistrements DNS sont manquants ou incorrects pour le domaine {domain} (catégorie {category})",
@@ -442,7 +418,7 @@
"apps_catalog_failed_to_download": "Impossible de télécharger le catalogue des applications {apps_catalog} : {error}",
"diagnosis_mail_outgoing_port_25_blocked": "Le port sortant 25 semble être bloqué. Vous devriez essayer de le débloquer dans le panneau de configuration de votre fournisseur de services Internet (ou hébergeur). En attendant, le serveur ne pourra pas envoyer des emails à d'autres serveurs.",
"domain_cannot_remove_main_add_new_one": "Vous ne pouvez pas supprimer '{domain}' car il s'agit du domaine principal et de votre seul domaine. Vous devez d'abord ajouter un autre domaine à l'aide de 'yunohost domain add {ehlo_domain}
dans votre interface de routeur Internet ou votre interface d'hébergement. (Certains hébergeurs peuvent vous demander de leur envoyer un ticket de support pour cela).",
- "diagnosis_mail_fcrdns_different_from_ehlo_domain": "Le DNS inverse n'est pas correctement configuré en IPv{ipversion}. Certains emails seront peut-être refusés ou considérés comme des spam.",
+ "diagnosis_mail_fcrdns_nok_details": "Vous devez d'abord essayer de configurer le reverse-DNS avec {ehlo_domain}
dans l'interface de votre routeur, box Internet ou votre interface d'hébergement. (Certains hébergeurs peuvent vous demander d'ouvrir un ticket sur leur support d'assistance pour cela).",
+ "diagnosis_mail_fcrdns_different_from_ehlo_domain": "Le reverse-DNS n'est pas correctement configuré en IPv{ipversion}. Il se peut que certains emails ne soient pas acheminés ou soient considérés comme du spam.",
"diagnosis_mail_blacklist_ok": "Les adresses IP et les domaines utilisés par ce serveur ne semblent pas être sur liste noire",
"diagnosis_mail_blacklist_reason": "La raison de la liste noire est : {reason}",
- "diagnosis_mail_blacklist_website": "Après avoir identifié la raison pour laquelle vous êtes répertorié et l'avoir corrigé, n'hésitez pas à demander le retrait de votre IP ou domaine sur {blacklist_website}",
+ "diagnosis_mail_blacklist_website": "Après avoir identifié la raison pour laquelle vous êtes répertorié sur cette liste et l'avoir corrigée, n'hésitez pas à demander le retrait de votre IP ou de votre domaine sur {blacklist_website}",
"diagnosis_mail_queue_ok": "{nb_pending} emails en attente dans les files d'attente de messagerie",
"diagnosis_mail_queue_unavailable_details": "Erreur : {error}",
"diagnosis_mail_queue_too_big": "Trop d'emails en attente dans la file d'attente ({nb_pending} emails)",
- "global_settings_setting_smtp_allow_ipv6": "Autoriser l'utilisation d'IPv6 pour recevoir et envoyer du courrier",
"diagnosis_display_tip": "Pour voir les problèmes détectés, vous pouvez accéder à la section Diagnostic du webadmin ou exécuter 'yunohost diagnosis show --issues --human-readable' à partir de la ligne de commande.",
"diagnosis_ip_global": "IP globale : {global}
",
"diagnosis_ip_local": "IP locale : {local}
",
- "diagnosis_dns_point_to_doc": "Veuillez consulter la documentation sur https://yunohost.org/dns_config si vous avez besoin d'aide pour configurer les enregistrements DNS.",
- "diagnosis_mail_outgoing_port_25_blocked_relay_vpn": "Certains fournisseurs ne vous laisseront pas débloquer le port sortant 25 parce qu'ils ne se soucient pas de la neutralité du Net. {wrong_ehlo}
{right_ehlo}
{rdns_domain}
{ehlo_domain}
",
"diagnosis_mail_blacklist_listed_by": "Votre IP ou domaine {item}
est sur liste noire sur {blacklist_name}",
"diagnosis_mail_queue_unavailable": "Impossible de consulter le nombre d'emails en attente dans la file d'attente",
- "diagnosis_ports_partially_unreachable": "Le port {port} n'est pas accessible de l'extérieur en IPv{failed}.",
+ "diagnosis_ports_partially_unreachable": "Le port {port} n'est pas accessible depuis l'extérieur en IPv{failed}.",
"diagnosis_http_hairpinning_issue": "Votre réseau local ne semble pas supporter l'hairpinning.",
"diagnosis_http_hairpinning_issue_details": "C'est probablement à cause de la box/routeur de votre fournisseur d'accès internet. Par conséquent, les personnes extérieures à votre réseau local pourront accéder à votre serveur comme prévu, mais pas les personnes internes au réseau local (comme vous, probablement ?) si elles utilisent le nom de domaine ou l'IP globale. Vous pourrez peut-être améliorer la situation en consultant https://yunohost.org/dns_local_network",
"diagnosis_http_partially_unreachable": "Le domaine {domain} semble inaccessible en HTTP depuis l'extérieur du réseau local en IPv{failed}, bien qu'il fonctionne en IPv{passed}.",
"diagnosis_http_nginx_conf_not_up_to_date": "La configuration Nginx de ce domaine semble avoir été modifiée manuellement et empêche YunoHost de diagnostiquer si elle est accessible en HTTP.",
- "diagnosis_http_nginx_conf_not_up_to_date_details": "Pour corriger la situation, inspectez la différence avec la ligne de commande en utilisant les outils /etc/resolv.conf
debería ser unha ligazón simbólica a /etc/resolvconf/run/resolv.conf
apuntando el mesmo a 127.0.0.1
(dnsmasq). Se queres configurar manualmente a resolución DNS, por favor edita /etc/resolv.dnsmasq.conf
.",
"diagnosis_ip_weird_resolvconf": "A resolución DNS semella funcionar, mais parecese que estás a utilizar un /etc/resolv.conf
personalizado.",
"diagnosis_ip_broken_resolvconf": "A resolución de nomes de dominio semella non funcionar no teu servidor, que parece ter relación con que /etc/resolv.conf
non sinala a 127.0.0.1
.",
- "diagnosis_ip_broken_dnsresolution": "A resolución de nomes de dominio semella que por algunha razón non funciona... Pode estar o cortalumes bloqueando as peticións DNS?",
+ "diagnosis_ip_broken_dnsresolution": "A resolución de nomes de dominio semella que non funciona... Está o cortalumes bloqueando as peticións DNS?",
"diagnosis_ip_dnsresolution_working": "A resolución de nomes de dominio está a funcionar!",
"diagnosis_ip_not_connected_at_all": "O servidor semella non ter ningún tipo de conexión a internet!?",
"diagnosis_ip_local": "IP local: {local}
",
@@ -214,7 +208,7 @@
"diagnosis_mail_ehlo_bad_answer": "Un servizo non-SMTP respondeu no porto 25 en IPv{ipversion}",
"diagnosis_mail_ehlo_unreachable_details": "Non se puido abrir unha conexión no porto 25 do teu servidor en IPv{ipversion}. Non semella accesible.{ehlo_domain}
na interface do teu rúter de internet ou na interface do teu provedor de hospedaxe. (Algúns provedores de hospedaxe poderían pedirche que lle fagas unha solicitude por escrito para isto).",
- "diagnosis_mail_fcrdns_dns_missing": "Non hai DNS inverso definido en IPv{ipversion}. Algúns emails poderían non ser entregrado ou ser marcados como spam.",
+ "diagnosis_mail_fcrdns_dns_missing": "Non hai DNS inverso definido en IPv{ipversion}. Algúns emails poderían non ser entregados ou ser marcados como spam.",
"diagnosis_mail_fcrdns_ok": "O DNS inverso está correctamente configurado!",
"diagnosis_mail_ehlo_could_not_diagnose_details": "Erro: {error}",
"diagnosis_mail_ehlo_could_not_diagnose": "Non se puido determinar se o servidor de email postfix é accesible desde o exterior en IPv{ipversion}.",
@@ -232,7 +226,7 @@
"diagnosis_mail_blacklist_ok": "Os IPs e dominios utilizados neste servidor non parecen estar en listas de bloqueo",
"diagnosis_mail_fcrdns_different_from_ehlo_domain_details": "DNS inverso actual: {rdns_domain}
{ehlo_domain}
",
"diagnosis_mail_fcrdns_different_from_ehlo_domain": "O DNS inverso non está correctamente configurado para IPv{ipversion}. É posible que non se entreguen algúns emails ou sexan marcados como spam.",
- "diagnosis_mail_fcrdns_nok_alternatives_6": "Algúns provedores non che permiten configurar DNS inverso (ou podería non funcionar...). Se o teu DNS inverso está correctamente configurado para IPv4, podes intentar desactivar o uso de IPv6 ao enviar os emails executando {global}
",
+ "diagnosis_ip_dnsresolution_working": "Resolusi nama domain bekerja!",
+ "diagnosis_ip_local": "IP Lokal: {local}
",
+ "diagnosis_ip_no_ipv4": "Peladen ini sepertinya tidak memiliki IPv4.",
+ "diagnosis_mail_ehlo_could_not_diagnose_details": "Galat: {error}",
+ "global_settings_setting_ssh_password_authentication_help": "Izinkan autentikasi kata sandi untuk SSH",
+ "password_listed": "Kata sandi ini termasuk dalam daftar kata sandi yang sering digunakan di dunia. Mohon untuk memilih yang lebih unik.",
+ "permission_not_found": "Izin '{permission}' tidak ditemukan",
+ "restore_not_enough_disk_space": "Ruang tidak cukup (ruang: {free_space} B, ruang yang dibutuhkan: {needed_space} B, margin aman: {margin} B)",
+ "server_reboot": "Peladen akan dimulai ulang",
+ "service_description_nginx": "Menyediakan akses untuk semua situs yang dihos di peladen Anda",
+ "service_description_rspamd": "Filter spam dan fitur terkait surel lainnya",
+ "service_remove_failed": "Tidak dapat menghapus layanan '{service}'",
+ "user_unknown": "Pengguna tidak diketahui: {user}",
+ "user_update_failed": "Tidak dapat memperbarui pengguna {user}: {error}",
+ "yunohost_configured": "YunoHost sudah terkonfigurasi",
+ "global_settings_setting_pop3_enabled": "Aktifkan POP3",
+ "log_user_import": "Mengimpor pengguna",
+ "app_start_backup": "Mengumpulkan berkas untuk dicadangkan untuk {app}...",
+ "app_upgrade_script_failed": "Galat terjadi di skrip pembaruan aplikasi",
+ "backup_csv_creation_failed": "Tidak dapat membuat berkas CSV yang dibutuhkan untuk pemulihan",
+ "certmanager_attempt_to_renew_valid_cert": "Sertifikat untuk domain '{domain}' belum akan kedaluwarsa! (Anda bisa menggunakan --force jika Anda tahu apa yang Anda lakukan)",
+ "extracting": "Mengekstrak...",
+ "system_username_exists": "Nama pengguna telah ada di daftar pengguna sistem",
+ "upgrade_complete": "Pembaruan selesai",
+ "upgrading_packages": "Memperbarui paket...",
+ "diagnosis_description_apps": "Aplikasi",
+ "diagnosis_description_basesystem": "Basis sistem",
+ "global_settings_setting_pop3_enabled_help": "Aktifkan protokol POP3 untuk peladen surel",
+ "password_confirmation_not_the_same": "Kata sandi dan untuk konfirmasinya tidak sama",
+ "restore_complete": "Pemulihan selesai",
+ "user_import_success": "Pengguna berhasil diimpor",
+ "user_updated": "Informasi pengguna diubah",
+ "visitors": "Pengunjung",
+ "yunohost_already_installed": "YunoHost sudah terpasang",
+ "yunohost_installing": "Memasang YunoHost...",
+ "yunohost_not_installed": "YunoHost tidak terpasang dengan benar. Jalankan 'yunohost tools postinstall'",
+ "restore_removing_tmp_dir_failed": "Tidak dapat menghapus direktori sementara yang dulu",
+ "app_sources_fetch_failed": "Tidak dapat mengambil berkas sumber, apakah URL-nya benar?",
+ "installation_complete": "Pemasangan selesai",
+ "app_arch_not_supported": "Aplikasi ini hanya bisa dipasang pada arsitektur {required}, tapi arsitektur peladen Anda adalah {current}",
+ "diagnosis_basesystem_hardware_model": "Model peladen adalah {model}",
+ "app_yunohost_version_not_supported": "Aplikasi ini memerlukan YunoHost >= {required}, tapi versi yang terpasang adalah {current}",
+ "ask_new_path": "Jalur baru",
+ "backup_cleaning_failed": "Tidak dapat menghapus folder cadangan sementara",
+ "diagnosis_description_mail": "Surel",
+ "diagnosis_description_regenconf": "Konfigurasi sistem",
+ "diagnosis_display_tip": "Untuk melihat masalah yang ditemukan, Anda bisa ke bagian Diagnosis di administrasi web atau jalankan 'yunohost diagnosis show --issues --human-readable'.",
+ "diagnosis_domain_expiration_success": "Domain Anda sudah terdaftar dan belum akan kedaluwarsa dalam waktu dekat.",
+ "diagnosis_failed": "Gagal mengambil hasil diagnosis untuk kategori '{category}': {error}",
+ "global_settings_setting_portal_theme": "Tema portal",
+ "global_settings_setting_portal_theme_help": "Informasi lebih lanjut tentang tema portal kustom ada di https://yunohost.org/theming",
+ "global_settings_setting_ssh_password_authentication": "Autentikasi kata sandi",
+ "certmanager_attempt_to_renew_nonLE_cert": "Sertifikat untuk domain '{domain}' tidak diterbitkan oleh Let's Encrypt. Tidak dapat memperbarui secara otomatis!",
+ "certmanager_cert_install_failed": "Pemasangan sertifikat Let's Encrypt gagal untuk {domains}",
+ "certmanager_cert_install_failed_selfsigned": "Pemasangan sertifikat ditandai sendiri (self-signed) gagal untuk {domains}",
+ "config_validate_email": "Harus surel yang valid",
+ "config_apply_failed": "Gagal menerapkan konfigurasi baru: {error}",
+ "diagnosis_basesystem_ynh_main_version": "Peladen memakai YunoHost {main_version} ({repo})",
+ "diagnosis_cant_run_because_of_dep": "Tidak dapat menjalankan diagnosis untuk {category} ketika ada masalah utama yang terkait dengan {dep}.",
+ "diagnosis_services_conf_broken": "Konfigurasi rusak untuk layanan {service}!",
+ "diagnosis_services_running": "Layanan {service} berjalan!",
+ "diagnosis_swap_ok": "Sistem ini memiliki {total} swap!",
+ "downloading": "Mengunduh...",
+ "pattern_password": "Harus paling tidak 3 karakter",
+ "pattern_password_app": "Maaf, kata sandi tidak dapat mengandung karakter berikut: {forbidden_chars}",
+ "pattern_port_or_range": "Harus angka porta yang valid (cth. 0-65535) atau jangkauan porta (cth. 100:200)",
+ "permission_already_exist": "Izin '{permission}' sudah ada",
+ "permission_cant_add_to_all_users": "Izin '{permission}' tidak dapat ditambahkan ke semua pengguna.",
+ "permission_created": "Izin '{permission}' dibuat",
+ "permission_creation_failed": "Tidak dapat membuat izin '{permission}': {error}",
+ "permission_deleted": "Izin '{permission}' dihapus",
+ "service_description_mysql": "Menyimpan data aplikasi (basis data SQL)",
+ "mailbox_disabled": "Surel dimatikan untuk pengguna {user}",
+ "log_user_update": "Memperbarui informasi untuk pengguna '{}'",
+ "apps_catalog_obsolete_cache": "Tembolok katalog aplikasi kosong atau sudah tua.",
+ "backup_actually_backuping": "Membuat arsip cadangan dari berkas yang dikumpulkan...",
+ "backup_applying_method_copy": "Menyalin semua berkas ke cadangan...",
+ "backup_archive_app_not_found": "Tidak dapat menemukan {app} di arsip cadangan",
+ "config_validate_date": "Harus tanggal yang valid seperti format YYYY-MM-DD",
+ "config_validate_time": "Harus waktu yang valid seperti HH:MM",
+ "diagnosis_ip_connected_ipv6": "Peladen ini terhubung ke internet lewat IPv6!",
+ "diagnosis_services_bad_status": "Layanan {service} {status} :(",
+ "global_settings_setting_root_password": "Kata sandi root baru",
+ "log_app_action_run": "Menjalankan tindakan dari aplikasi '{}'",
+ "log_settings_reset_all": "Atur ulang semua pengaturan",
+ "log_settings_set": "Terapkan pengaturan",
+ "service_removed": "Layanan '{service}' dihapus",
+ "service_restart_failed": "Tidak dapat memulai ulang layanan '{service}'\n\nLog layanan baru-baru ini:{logs}",
+ "ssowat_conf_generated": "Konfigurasi SSOwat diperbarui",
+ "system_upgraded": "Sistem diperbarui",
+ "tools_upgrade": "Memperbarui paket sistem",
+ "upnp_dev_not_found": "Tidak ada perangkat UPnP yang ditemukan",
+ "upnp_enabled": "UPnP dinyalakan",
+ "upnp_port_open_failed": "Tidak dapat membuka porta lewat UPnP",
+ "app_change_url_failed": "Tidak dapat mengubah URL untuk {app}: {error}",
+ "app_restore_script_failed": "Galat terjadi di skrip pemulihan aplikasi",
+ "app_label_deprecated": "Perintah ini sudah usang! Silakan untuk menggunakan perintah baru 'yunohost user permission update' untuk mengelola label aplikasi.",
+ "app_make_default_location_already_used": "Tidak dapat membuat '{app}' menjadi aplikasi baku untuk domain, '{domain}' telah dipakai oleh '{other_app}'",
+ "app_manifest_install_ask_is_public": "Bolehkan aplikasi ini dibuka untuk pengunjung awanama?",
+ "upnp_disabled": "UPnP dimatikan",
+ "global_settings_setting_smtp_allow_ipv6_help": "Perbolehkan penggunaan IPv6 untuk menerima dan mengirim surel",
+ "domain_config_default_app": "Aplikasi baku",
+ "diagnosis_diskusage_verylow": "Penyimpanan {mountpoint}
(di perangkat {device}
) hanya tinggal memiliki {free} ({free_percent}%) ruang kosong yang tersedia (dari {total}). Direkomendasikan untuk membersihkan ruang penyimpanan!",
+ "domain_config_api_protocol": "Protokol API",
+ "domain_config_cert_summary_letsencrypt": "Bagus! Anda menggunakan sertifikat Let's Encrypt yang valid!",
+ "domain_config_mail_out": "Surel keluar",
+ "domain_deletion_failed": "Tidak dapat menghapus domain {domain}: {error}",
+ "backup_copying_to_organize_the_archive": "Menyalin {size}MB untuk menyusun arsip",
+ "backup_method_copy_finished": "Salinan cadangan telah selesai",
+ "certmanager_domain_cert_not_selfsigned": "Sertifikat untuk domain {domain} bukan disertifikasi sendiri. Apakah Anda yakin ingin mengubahnya? (Gunakan '--force' jika iya)",
+ "diagnosis_diskusage_ok": "Penyimpanan {mountpoint}
(di perangkat {device}
) masih memiliki {free} ({free_percent}%) ruang kosong yang tersedia (dari {total})!",
+ "diagnosis_http_nginx_conf_not_up_to_date": "Konfigurasi nginx domain ini sepertinya diubah secara manual, itu mencegah YunoHost untuk mendiagnosis apakah domain ini terhubung ke HTTP.",
+ "domain_created": "Domain dibuat",
+ "migrations_running_forward": "Menjalankan migrasi {id}...",
+ "permission_deletion_failed": "Tidak dapat menghapus izin '{permission}': {error}",
+ "domain_config_cert_no_checks": "Abaikan pemeriksaan diagnosis",
+ "domain_config_cert_renew": "Perbarui sertifikat Let's Encrypt",
+ "domain_config_cert_summary": "Status sertifikat",
+ "domain_config_cert_summary_expired": "PENTING: Sertifikat saat ini tidak valid! HTTPS tidak akan bekerja sama sekali!",
+ "port_already_opened": "Porta {port} telah dibuka untuk koneksi {ip_version}",
+ "migrations_success_forward": "Migrasi {id} selesai",
+ "not_enough_disk_space": "Ruang kosong tidak cukup di '{path}'",
+ "password_too_long": "Pilih kata sandi yang lebih pendek dari 127 karakter",
+ "regenconf_file_backed_up": "Berkas konfigurasi '{conf}' dicadangkan ke '{backup}'",
+ "domain_creation_failed": "Tidak dapat membuat domain {domain}: {error}",
+ "domain_deleted": "Domain dihapus",
+ "regex_with_only_domain": "Anda tidak dapat menggunakan regex untuk domain, hanya untuk jalur",
+ "diagnosis_diskusage_low": "Penyimpanan {mountpoint}
(di perangkat {device}
) hanya tinggal memiliki {free} ({free_percent}%) ruang kosong yang tersedia (dari {total}).",
+ "domain_config_cert_summary_ok": "Oke, sertifikat saat ini terlihat bagus!",
+ "app_failed_to_upgrade_but_continue": "Gagal memperbarui aplikasi {failed_app}, melanjutkan pembaruan berikutnya seperti yang diminta. Jalankan 'yunohost log show {operation_logger_name}' untuk melihat log kegagalan",
+ "certmanager_attempt_to_replace_valid_cert": "Anda sedang mencoba untuk menimpa sertifikat yang valid untuk domain {domain}! (Gunakan --force untuk melewati ini)",
+ "permission_protected": "Izin {permission} dilindungi. Anda tidak dapat menambahkan atau menghapus kelompok pengunjung ke/dari izin ini.",
+ "permission_require_account": "Izin {permission} hanya masuk akal untuk pengguna yang memiliki akun, maka ini tidak dapat diaktifkan untuk pengunjung.",
+ "permission_update_failed": "Tidak dapat memperbarui izin '{permission}': {error}",
+ "apps_failed_to_upgrade": "Aplikasi berikut gagal untuk diperbarui:{apps}",
+ "backup_archive_name_unknown": "Arsip cadangan lokal tidak diketahui yang bernama '{name}'",
+ "diagnosis_http_nginx_conf_not_up_to_date_details": "Untuk memperbaiki ini, periksa perbedaannya dari CLI menggunakan {file}
sepertinya telah diubah manual.",
+ "backup_with_no_restore_script_for_app": "{app} tidak memiliki skrip pemulihan, Anda tidak akan bisa secara otomatis memulihkan cadangan aplikasi ini.",
+ "config_no_panel": "Tidak dapat menemukan panel konfigurasi.",
+ "confirm_app_install_warning": "Peringatan: Aplikasi ini mungkin masih bisa bekerja, tapi tidak terintegrasi dengan baik dengan YunoHost. Beberapa fitur seperti SSO dan pencadangan mungkin tidak tersedia. Tetap pasang? [{answers}] ",
+ "diagnosis_ports_ok": "Porta {port} tercapai dari luar.",
+ "diagnosis_ports_partially_unreachable": "Porta {port} tidak tercapai dari luar lewat IPv{failed}.",
+ "domain_remove_confirm_apps_removal": "Menghapus domain ini akan melepas aplikasi berikut:\n{apps}\n\nApakah Anda yakin? [{answers}]",
+ "domains_available": "Domain yang tersedia:",
+ "global_settings_reset_success": "Atur ulang pengaturan global",
+ "group_created": "Kelompok '{group}' dibuat",
+ "group_deletion_failed": "Tidak dapat menghapus kelompok '{group}': {error}",
+ "group_updated": "Kelompok '{group}' diperbarui",
+ "invalid_credentials": "Nama pengguna atau kata sandi salah",
+ "log_letsencrypt_cert_renew": "Memperbarui sertifikat Let's Encrypt '{}'",
+ "log_selfsigned_cert_install": "Memasang sertifikat ditandai sendiri pada domain '{}'",
+ "log_user_permission_reset": "Mengatur ulang izin '{}'",
+ "domain_config_xmpp": "Pesan Langsung (XMPP)"
+}
diff --git a/locales/it.json b/locales/it.json
index 844b756ea..21fb52367 100644
--- a/locales/it.json
+++ b/locales/it.json
@@ -23,8 +23,6 @@
"upgrading_packages": "Aggiornamento dei pacchetti...",
"user_deleted": "Utente cancellato",
"admin_password": "Password dell'amministrazione",
- "admin_password_change_failed": "Impossibile cambiare la password",
- "admin_password_changed": "La password d'amministrazione è stata cambiata",
"app_install_files_invalid": "Questi file non possono essere installati",
"app_not_correctly_installed": "{app} sembra di non essere installata correttamente",
"app_not_properly_removed": "{app} non è stata correttamente rimossa",
@@ -34,9 +32,6 @@
"app_upgrade_failed": "Impossibile aggiornare {app}: {error}",
"app_upgraded": "{app} aggiornata",
"app_requirements_checking": "Controllo i pacchetti richiesti per {app}...",
- "app_requirements_unmeet": "Requisiti non soddisfatti per {app}, il pacchetto {pkgname} ({version}) deve essere {spec}",
- "ask_firstname": "Nome",
- "ask_lastname": "Cognome",
"ask_main_domain": "Dominio principale",
"ask_new_admin_password": "Nuova password dell'amministrazione",
"backup_app_failed": "Non è possibile fare il backup {app}",
@@ -187,7 +182,6 @@
"certmanager_cannot_read_cert": "Qualcosa è andato storto nel tentativo di aprire il certificato attuale per il dominio {domain} (file: {file}), motivo: {reason}",
"certmanager_cert_install_success": "Certificato Let's Encrypt per il dominio {domain} installato",
"aborting": "Annullamento.",
- "admin_password_too_long": "Per favore scegli una password più corta di 127 caratteri",
"app_not_upgraded": "Impossibile aggiornare le applicazioni '{failed_app}' e di conseguenza l'aggiornamento delle seguenti applicazione è stato cancellato: {apps}",
"app_start_install": "Installando '{app}'...",
"app_start_remove": "Rimozione di {app}...",
@@ -222,28 +216,12 @@
"domain_dns_conf_is_just_a_recommendation": "Questo comando ti mostra la configurazione *raccomandata*. Non ti imposta la configurazione DNS al tuo posto. È tua responsabilità configurare la tua zona DNS nel tuo registrar in accordo con queste raccomandazioni.",
"dyndns_could_not_check_available": "Impossibile controllare se {domain} è disponibile su {provider}.",
"dyndns_domain_not_provided": "Il fornitore DynDNS {provider} non può fornire il dominio {domain}.",
- "experimental_feature": "Attenzione: Questa funzionalità è sperimentale e non è considerata stabile, non dovresti utilizzarla a meno che tu non sappia cosa stai facendo.",
"file_does_not_exist": "Il file {path} non esiste.",
- "global_settings_bad_choice_for_enum": "Scelta sbagliata per l'impostazione {setting}, ricevuta '{choice}', ma le scelte disponibili sono: {available_choices}",
- "global_settings_bad_type_for_setting": "Tipo errato per l'impostazione {setting}, ricevuto {received_type}, atteso {expected_type}",
- "global_settings_cant_open_settings": "Apertura del file delle impostazioni non riuscita, motivo: {reason}",
- "global_settings_cant_serialize_settings": "Serializzazione dei dati delle impostazioni non riuscita, motivo: {reason}",
- "global_settings_cant_write_settings": "Scrittura del file delle impostazioni non riuscita, motivo: {reason}",
- "global_settings_key_doesnt_exists": "La chiave '{settings_key}' non esiste nelle impostazioni globali, puoi vedere tutte le chiavi disponibili eseguendo 'yunohost settings list'",
- "global_settings_reset_success": "Le impostazioni precedenti sono state salvate in {path}",
"already_up_to_date": "Niente da fare. Tutto è già aggiornato.",
- "global_settings_setting_security_nginx_compatibility": "Bilanciamento tra compatibilità e sicurezza per il server web NGIX. Riguarda gli algoritmi di cifratura (e altri aspetti legati alla sicurezza)",
- "global_settings_setting_security_password_admin_strength": "Complessità della password di amministratore",
- "global_settings_setting_security_password_user_strength": "Complessità della password utente",
- "global_settings_setting_security_ssh_compatibility": "Bilanciamento tra compatibilità e sicurezza per il server SSH. Riguarda gli algoritmi di cifratura (e altri aspetti legati alla sicurezza)",
- "global_settings_unknown_setting_from_settings_file": "Chiave sconosciuta nelle impostazioni: '{setting_key}', scartata e salvata in /etc/yunohost/settings-unknown.json",
- "global_settings_setting_service_ssh_allow_deprecated_dsa_hostkey": "Consenti l'uso del hostkey DSA (deprecato) per la configurazione del demone SSH",
- "global_settings_unknown_type": "Situazione inaspettata, l'impostazione {setting} sembra essere di tipo {unknown_type} ma non è un tipo supportato dal sistema.",
"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}'",
- "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}'",
"log_does_exists": "Non esiste nessun registro delle operazioni chiamato '{log}', usa 'yunohost log list' per vedere tutti i registri delle operazioni disponibili",
@@ -255,7 +233,6 @@
"log_available_on_yunopaste": "Questo registro è ora disponibile via {url}",
"log_backup_restore_system": "Ripristina sistema da un archivio di backup",
"log_backup_restore_app": "Ripristina '{}' da un archivio di backup",
- "log_remove_on_failed_restore": "Rimuovi '{}' dopo un ripristino fallito da un archivio di backup",
"log_remove_on_failed_install": "Rimuovi '{}' dopo un'installazione fallita",
"log_domain_add": "Aggiungi il dominio '{}' nella configurazione di sistema",
"log_domain_remove": "Rimuovi il dominio '{}' dalla configurazione di sistema",
@@ -414,7 +391,6 @@
"server_reboot": "Il server si riavvierà",
"server_shutdown_confirm": "Il server si spegnerà immediatamente, sei sicuro? [{answers}]",
"server_shutdown": "Il server si spegnerà",
- "root_password_replaced_by_admin_password": "La tua password di root è stata sostituita dalla tua password d'amministratore.",
"root_password_desynchronized": "La password d'amministratore è stata cambiata, ma YunoHost non ha potuto propagarla alla password di root!",
"restore_system_part_failed": "Impossibile ripristinare la sezione di sistema '{part}'",
"restore_removing_tmp_dir_failed": "Impossibile rimuovere una vecchia directory temporanea",
@@ -506,13 +482,9 @@
"group_already_exist_on_system_but_removing_it": "Il gruppo {group} esiste già tra i gruppi di sistema, ma YunoHost lo cancellerà...",
"group_already_exist_on_system": "Il gruppo {group} esiste già tra i gruppi di sistema",
"group_already_exist": "Il gruppo {group} esiste già",
- "global_settings_setting_backup_compress_tar_archives": "Quando creo nuovi backup, usa un archivio (.tar.gz) al posto di un archivio non compresso (.tar). NB: abilitare quest'opzione significa create backup più leggeri, ma la procedura durerà di più e il carico CPU sarà maggiore.",
"global_settings_setting_smtp_relay_password": "Password del relay SMTP",
"global_settings_setting_smtp_relay_user": "User account del relay SMTP",
"global_settings_setting_smtp_relay_port": "Porta del relay SMTP",
- "global_settings_setting_smtp_relay_host": "Utilizza SMTP relay per inviare mail al posto di questa instanza yunohost. Utile se sei in una di queste situazioni: la tua porta 25 è bloccata dal tuo provider ISP o VPS; hai un IP residenziale listato su DUHL; non sei puoi configurare il DNS inverso; oppure questo server non è direttamente esposto a Internet e vuoi usarne un'altro per spedire email.",
- "global_settings_setting_smtp_allow_ipv6": "Permetti l'utilizzo di IPv6 per ricevere e inviare mail",
- "global_settings_setting_pop3_enabled": "Abilita il protocollo POP3 per il server mail",
"dyndns_provider_unreachable": "Incapace di raggiungere il provider DynDNS {provider}: o il tuo YunoHost non è connesso ad internet o il server dynette è down.",
"dpkg_lock_not_available": "Impossibile eseguire il comando in questo momento perché un altro programma sta bloccando dpkg (il package manager di sistema)",
"domain_cannot_remove_main_add_new_one": "Non puoi rimuovere '{domain}' visto che è il dominio principale nonché il tuo unico dominio, devi prima aggiungere un altro dominio eseguendo 'yunohost domain add /etc/resolv.conf
を使用しているようです。",
+ "diagnosis_ip_weird_resolvconf_details": "ファイルは/etc/resolv.conf
、(dnsmasq)を指す127.0.0.1
それ自体への/etc/resolvconf/run/resolv.conf
シンボリックリンクである必要があります。DNSリゾルバーを手動で設定する場合は、編集/etc/resolv.dnsmasq.conf
してください。",
+ "diagnosis_mail_blacklist_listed_by": "あなたのIPまたはドメイン {item}
はブラックリスト {blacklist_name} に登録されています",
+ "diagnosis_mail_blacklist_ok": "このサーバーが使用するIPとドメインはブラックリストに登録されていないようです",
+ "diagnosis_mail_ehlo_could_not_diagnose_details": "エラー: {error}",
+ "diagnosis_mail_fcrdns_ok": "逆引きDNSが正しく構成されています!",
+ "diagnosis_mail_fcrdns_nok_alternatives_4": "一部のプロバイダーでは、逆引きDNSを構成できません(または機能が壊れている可能性があります…)。そのせいで問題が発生している場合は、次の解決策を検討してください。<0>0>
(デバイス<1>1>
上)には、( )残りの領域({free_percent} )しかありません{free}。{total}注意してください。",
+ "diagnosis_diskusage_ok": "ストレージ<0>0>
(デバイス<1>1>
上)にはまだ({free_percent}%)スペースが{free}残っています(から{total})!",
+ "diagnosis_diskusage_verylow": "ストレージ<0>0>
(デバイス<1>1>
上)には、( )残りの領域({free_percent} )しかありません{free}。{total}あなたは本当にいくつかのスペースをきれいにすることを検討する必要があります!",
+ "diagnosis_display_tip": "見つかった問題を確認するには、ウェブ管理者の診断セクションに移動するか、コマンドラインから「yunohost診断ショー--問題--人間が読める」を実行します。",
+ "diagnosis_dns_bad_conf": "一部の DNS レコードが見つからないか、ドメイン {domain} (カテゴリ {category}) が正しくない",
+ "diagnosis_dns_discrepancy": "次の DNS レコードは、推奨される構成に従っていないようです。<0>0>
<1>1>
<2>2>
<3>3>
",
+ "diagnosis_dns_good_conf": "DNS レコードがドメイン {domain} (カテゴリ {category}) 用に正しく構成されている",
+ "diagnosis_dns_missing_record": "推奨される DNS 構成に従って、次の情報を含む DNS レコードを追加する必要があります。<0>0>
<1>1>
<2>2>
",
+ "diagnosis_dns_point_to_doc": "DNS レコードの構成についてサポートが必要な場合は 、https://yunohost.org/dns_config のドキュメントを確認してください。",
+ "diagnosis_dns_specialusedomain": "ドメイン {domain} は、.local や .test などの特殊な用途のトップレベル ドメイン (TLD) に基づいているため、実際の DNS レコードを持つことは想定されていません。",
+ "diagnosis_dns_try_dyndns_update_force": "このドメインのDNS設定は、YunoHostによって自動的に管理されます。そうでない場合は、 /etc/resolv.conf
で127.0.0.1
を指定していないことに関連しているようです。",
+ "diagnosis_ip_connected_ipv4": "サーバーはIPv4経由でインターネットに接続されています!",
+ "diagnosis_ip_connected_ipv6": "サーバーはIPv6経由でインターネットに接続されています!",
+ "diagnosis_ip_global": "グローバルIP: {global}
",
+ "diagnosis_ip_local": "ローカル IP: {local}
",
+ "diagnosis_ip_no_ipv4": "サーバーに機能している IPv4 がありません。",
+ "diagnosis_ip_no_ipv6": "サーバーに機能している IPv6 がありません。",
+ "diagnosis_ip_no_ipv6_tip": "IPv6を機能させることは、サーバーが機能するために必須ではありませんが、インターネット全体の健全性にとってはより良いことです。IPv6 は通常、システムまたはプロバイダー (使用可能な場合) によって自動的に構成されます。それ以外の場合は、こちらのドキュメントで説明されているように、いくつかのことを手動で構成する必要があります。 https://yunohost.org/#/ipv6。IPv6を有効にできない場合、または技術的に難しすぎると思われる場合は、この警告を無視しても問題ありません。",
+ "diagnosis_mail_blacklist_reason": "ブラックリストの登録理由は次のとおりです: {reason}",
+ "diagnosis_mail_blacklist_website": "リストされている理由を特定して修正した後、IPまたはドメインを削除するように依頼してください: {blacklist_website}",
+ "diagnosis_mail_ehlo_bad_answer": "SMTP 以外のサービスが IPv{ipversion} のポート 25 で応答しました",
+ "diagnosis_mail_ehlo_bad_answer_details": "あなたのサーバーの代わりに別のマシンが応答していることが原因である可能性があります。",
+ "diagnosis_mail_ehlo_could_not_diagnose": "メール サーバ(postfix)が IPv{ipversion} の外部から到達可能かどうかを診断できませんでした。",
+ "diagnosis_mail_ehlo_ok": "SMTPメールサーバーは外部から到達可能であるため、電子メールを受信できます!",
+ "diagnosis_mail_ehlo_unreachable": "SMTP メール サーバは、IPv{ipversion} の外部から到達できません。メールを受信できません。",
+ "diagnosis_mail_ehlo_unreachable_details": "ポート 25 で IPv{ipversion} のサーバーへの接続を開くことができませんでした。到達できないようです。<1>1>
<2>2>
<0>0>
<1>1>
",
+ "diagnosis_mail_fcrdns_dns_missing": "IPv{ipversion} では逆引き DNS は定義されていません。一部のメールは配信されないか、スパムとしてフラグが立てられる場合があります。",
+ "diagnosis_mail_fcrdns_nok_alternatives_6": "一部のプロバイダーでは、逆引きDNSを構成できません(または機能が壊れている可能性があります...)。逆引きDNSがIPv4用に正しく設定されている場合は、 <0>0>
逆引きDNSを構成してみてください。(一部のホスティングプロバイダーでは、このためのサポートチケットを送信する必要がある場合があります)。",
+ "diagnosis_mail_outgoing_port_25_blocked": "送信ポート 25 が IPv{ipversion} でブロックされているため、SMTP メール サーバーは他のサーバーに電子メールを送信できません。",
+ "diagnosis_mail_outgoing_port_25_blocked_details": "まず、インターネットルーターインターフェイスまたはホスティングプロバイダーインターフェイスの送信ポート25のブロックを解除する必要があります。(一部のホスティングプロバイダーでは、このために問い合わせを行う必要がある場合があります)。",
+ "diagnosis_never_ran_yet": "このサーバーは最近セットアップされたようで、表示する診断レポートはまだありません。Web管理画面またはコマンドラインから ’yunohost diagnosis run’ を実行して、完全な診断を実行することから始める必要があります。",
+ "diagnosis_package_installed_from_sury": "一部のシステムパッケージはダウングレードする必要があります",
+ "diagnosis_processes_killed_by_oom_reaper": "一部のプロセスは、メモリが不足したため、最近システムによって強制終了されました。これは通常、システム上のメモリ不足、またはプロセスがメモリを消費しすぎていることを示しています。強制終了されたプロセスの概要:\n{kills_summary}",
+ "diagnosis_ram_low": "システムには{available}({available_percent}%)の使用可能なRAMがあります({total}のうち)。注意してください。",
+ "diagnosis_package_installed_from_sury_details": "一部のパッケージは、Suryと呼ばれるサードパーティのリポジトリから誤ってインストールされました。YunoHostチームはこれらのパッケージを処理する戦略を改善しましたが、Stretchを使用している間にPHP7.3アプリをインストールした一部のセットアップには、いくつかの矛盾が残っていると予想されます。この状況を修正するには、次のコマンドを実行してみてください。 {file}
構成ファイルが手動で変更されたようです。",
+ "diagnosis_regenconf_manually_modified_details": "あなたが何をしているのかを知っていれば、これはおそらく大丈夫です!YunoHostはこのファイルの自動更新を停止します... ただし、YunoHostのアップグレードには重要な推奨変更が含まれている可能性があることに注意してください。必要に応じて、{global}
",
+ "global_settings_setting_admin_strength": "管理者パスワードの強度要件",
+ "global_settings_setting_admin_strength_help": "これらの要件は、パスワードを初期化または変更する場合にのみ適用されます",
+ "global_settings_setting_backup_compress_tar_archives": "バックアップの圧縮",
+ "global_settings_setting_backup_compress_tar_archives_help": "新しいバックアップを作成するときは、圧縮されていないアーカイブ (.tar) ではなく、アーカイブを圧縮 (.tar.gz) します。注意:このオプションを有効にすると、バックアップアーカイブの作成が軽くなりますが、最初のバックアップ手順が大幅に長くなり、CPUに負担がかかります。",
+ "global_settings_setting_dns_exposure": "DNS の構成と診断で考慮すべき IP バージョン",
+ "global_settings_setting_dns_exposure_help": "注意:これは、推奨されるDNS構成と診断チェックにのみ影響します。これはシステム構成には影響しません。",
+ "global_settings_setting_nginx_compatibility": "NGINXの互換性",
+ "global_settings_setting_nginx_compatibility_help": "WebサーバーNGINXの互換性とセキュリティのトレードオフ。暗号(およびその他のセキュリティ関連の側面)に影響します",
+ "global_settings_setting_nginx_redirect_to_https": "HTTPSを強制",
+ "global_settings_setting_nginx_redirect_to_https_help": "デフォルトでHTTPリクエストをHTTPにリダイレクトします(あなたが何をしているのか本当にわからない限り、オフにしないでください!",
+ "global_settings_setting_passwordless_sudo": "管理者がパスワードを再入力せずに「sudo」を使用できるようにする",
+ "global_settings_setting_portal_theme_help": "カスタム ポータル テーマの作成の詳細については、https://yunohost.org/theming を参照してください。",
+ "global_settings_setting_postfix_compatibility": "後置の互換性",
+ "global_settings_setting_pop3_enabled": "POP3 を有効にする",
+ "global_settings_setting_pop3_enabled_help": "メール サーバーの POP3 プロトコルを有効にする",
+ "global_settings_setting_portal_theme": "ユーザーポータルでタイルに表示する",
+ "global_settings_setting_root_access_explain": "Linux システムでは、「ルート」が絶対管理者です。YunoHost のコンテキストでは、サーバーのローカルネットワークからを除き、直接の「ルート」SSH ログインはデフォルトで無効になっています。'admins' グループのメンバーは、sudo コマンドを使用して、コマンドラインから root として動作できます。ただし、何らかの理由で通常の管理者がログインできなくなった場合に、システムをデバッグするための(堅牢な)rootパスワードがあると便利です。",
+ "global_settings_setting_security_experimental_enabled": "実験的なセキュリティ機能",
+ "global_settings_setting_security_experimental_enabled_help": "実験的なセキュリティ機能を有効にします(何をしているのかわからない場合は有効にしないでください)。",
+ "global_settings_setting_smtp_allow_ipv6_help": "IPv6 を使用したメールの送受信を許可する",
+ "global_settings_setting_smtp_relay_enabled": "SMTP リレーを有効にする",
+ "global_settings_setting_smtp_relay_enabled_help": "この yunohost インスタンスの代わりにメールを送信するために使用する SMTP リレーを有効にします。このような状況のいずれかにある場合に便利です:25ポートがISPまたはVPSプロバイダーによってブロックされている、DUHLにリストされている住宅用IPがある、逆引きDNSを構成できない、またはこのサーバーがインターネットに直接公開されておらず、他のものを使用してメールを送信したい。",
+ "global_settings_setting_smtp_relay_host": "SMTP リレー ホスト",
+ "global_settings_setting_smtp_relay_password": "SMTP リレー パスワード",
+ "global_settings_setting_smtp_relay_port": "SMTP リレー ポート",
+ "global_settings_setting_smtp_relay_user": "SMTP リレー ユーザー",
+ "global_settings_setting_ssh_compatibility": "SSH の互換性",
+ "global_settings_setting_ssh_compatibility_help": "SSHサーバーの互換性とセキュリティのトレードオフ。暗号(およびその他のセキュリティ関連の側面)に影響します。詳細については、https://infosec.mozilla.org/guidelines/openssh を参照してください。",
+ "global_settings_setting_ssh_password_authentication": "パスワード認証",
+ "global_settings_setting_ssh_password_authentication_help": "SSH のパスワード認証を許可する",
+ "global_settings_setting_ssh_port": "SSH ポート",
+ "global_settings_setting_ssowat_panel_overlay_enabled": "アプリで小さな「YunoHost」ポータルショートカットの正方形を有効にします",
+ "global_settings_setting_user_strength": "ユーザー パスワードの強度要件",
+ "global_settings_setting_webadmin_allowlist_help": "ウェブ管理者へのアクセスを許可されたIPアドレス。",
+ "global_settings_setting_webadmin_allowlist": "ウェブ管理者 IP 許可リスト",
+ "global_settings_setting_webadmin_allowlist_enabled": "ウェブ管理 IP 許可リストを有効にする",
+ "global_settings_setting_webadmin_allowlist_enabled_help": "一部の IP のみにウェブ管理者へのアクセスを許可します。",
+ "good_practices_about_admin_password": "次に、新しい管理パスワードを定義しようとしています。パスワードは8文字以上である必要がありますが、より長いパスワード(パスフレーズなど)を使用したり、さまざまな文字(大文字、小文字、数字、特殊文字)を使用したりすることをお勧めします。",
+ "good_practices_about_user_password": "次に、新しいユーザー・パスワードを定義しようとしています。パスワードは少なくとも8文字の長さである必要がありますが、より長いパスワード(パスフレーズなど)や、さまざまな文字(大文字、小文字、数字、特殊文字)を使用することをお勧めします。",
+ "group_already_exist": "グループ {group} は既に存在します",
+ "group_already_exist_on_system": "グループ {group} はシステム グループに既に存在します。",
+ "group_already_exist_on_system_but_removing_it": "グループ{group}はすでにシステムグループに存在しますが、YunoHostはそれを削除します...",
+ "group_cannot_edit_all_users": "グループ 'all_users' は手動で編集できません。これは、YunoHostに登録されているすべてのユーザーを含むことを目的とした特別なグループです",
+ "invalid_shell": "無効なシェル: {shell}",
+ "ip6tables_unavailable": "ここではip6tablesを使うことはできません。あなたはコンテナ内にいるか、カーネルがサポートしていません",
+ "group_cannot_edit_primary_group": "グループ '{group}' を手動で編集することはできません。これは、特定のユーザーを 1 人だけ含むためのプライマリ グループです。",
+ "group_cannot_edit_visitors": "グループの「訪問者」を手動で編集することはできません。匿名の訪問者を代表する特別なグループです",
+ "group_creation_failed": "グループ '{group}' を作成できませんでした: {error}",
+ "group_deleted": "グループ '{group}' が削除されました",
+ "group_deletion_failed": "グループ '{group}' を削除できませんでした: {error}",
+ "group_update_aliases": "グループ '{group}' のエイリアスの更新",
+ "group_update_failed": "グループ '{group}' を更新できませんでした: {error}",
+ "group_updated": "グループ '{group}' が更新されました",
+ "group_user_add": "ユーザー '{user}' がグループ '{group}' に追加されます。",
+ "hook_json_return_error": "フック{path}からリターンを読み取れませんでした。エラー: {msg}. 生のコンテンツ: {raw_content}",
+ "hook_list_by_invalid": "このプロパティは、フックを一覧表示するために使用することはできません",
+ "hook_name_unknown": "不明なフック名 '{name}'",
+ "installation_complete": "インストールが完了しました",
+ "invalid_credentials": "無効なパスワードまたはユーザー名",
+ "invalid_number": "数値にする必要があります",
+ "invalid_number_max": "{max}より小さくする必要があります",
+ "invalid_number_min": "{min}より大きい値にする必要があります",
+ "invalid_regex": "無効な正規表現: '{regex}'",
+ "iptables_unavailable": "ここではiptablesを使うことはできません。あなたはコンテナ内にいるか、カーネルがサポートしていません",
+ "ldap_attribute_already_exists": "LDAP 属性 '{attribute}' は、値 '{value}' で既に存在します。",
+ "ldap_server_down": "LDAP サーバーに到達できません",
+ "ldap_server_is_down_restart_it": "LDAP サービスがダウンしています。再起動を試みます...",
+ "log_app_action_run": "{} アプリのアクションの実行",
+ "log_app_change_url": "{} アプリのアクセスURLを変更",
+ "log_app_config_set": "‘{}’ アプリに設定を適用する",
+ "log_app_makedefault": "‘{}’ をデフォルトのアプリにする",
+ "log_app_remove": "「{}」アプリを削除する",
+ "log_app_upgrade": "「{}」アプリをアップグレードする",
+ "log_available_on_yunopaste": "このログは、{url}",
+ "log_backup_create": "バックアップアーカイブを作成する",
+ "log_backup_restore_app": "バックアップを復元する ‘{name}’",
+ "log_backup_restore_system": "収集したファイルからバックアップアーカイブを作成しています...",
+ "log_corrupted_md_file": "ログに関連付けられている YAML メタデータ ファイルが破損しています: '{md_file}\nエラー: {error}'",
+ "log_does_exists": "「{log}」という名前の操作ログはありません。「yunohostログリスト」を使用して、利用可能なすべての操作ログを表示します",
+ "log_domain_add": "ドメイン ‘{name}’ を追加する",
+ "log_domain_config_set": "ドメイン '{}' の構成を更新する",
+ "log_domain_dns_push": "‘{name}’ DNSレコードを登録する",
+ "log_domain_main_domain": "「{}」をメインドメインにする",
+ "log_domain_remove": "システム構成から「{}」ドメインを削除する",
+ "log_dyndns_subscribe": "YunoHostコアのアップグレードを開始しています...",
+ "log_dyndns_update": "YunoHostサブドメイン「{}」に関連付けられているIPを更新します",
+ "log_help_to_get_failed_log": "操作 '{desc}' を完了できませんでした。ヘルプを取得するには、「yunohostログ共有{name}」コマンドを使用してこの操作の完全なログを共有してください",
+ "log_help_to_get_log": "操作「{desc}」のログを表示するには、「yunohostログショー{name}」コマンドを使用します。",
+ "log_letsencrypt_cert_install": "「{}」ドメインにLet's Encrypt証明書をインストールする",
+ "log_letsencrypt_cert_renew": "Let’s Encrypt証明書を更新する",
+ "log_link_to_failed_log": "操作 '{desc}' を完了できませんでした。ヘルプを取得するには、 ここをクリックして この操作の完全なログを提供してください",
+ "log_link_to_log": "この操作の完全なログ: ''{desc}",
+ "log_operation_unit_unclosed_properly": "操作ユニットが正しく閉じられていません",
+ "log_permission_create": "作成権限 '{}'",
+ "log_permission_delete": "削除権限 '{}'",
+ "log_permission_url": "権限 '{}' に関連する URL を更新する",
+ "log_regen_conf": "システム設定",
+ "log_remove_on_failed_install": "インストールに失敗した後に「{}」を削除します",
+ "log_resource_snippet": "リソースのプロビジョニング/プロビジョニング解除/更新",
+ "log_selfsigned_cert_install": "「{}」ドメインに自己署名証明書をインストールする",
+ "log_user_create": "「{}」ユーザーを追加する",
+ "log_user_delete": "「{}」ユーザーの削除",
+ "log_user_group_create": "「{}」グループの作成",
+ "log_settings_reset": "設定をリセット",
+ "log_settings_reset_all": "すべての設定をリセット",
+ "log_settings_set": "設定を適用",
+ "log_tools_migrations_migrate_forward": "移行を実行する",
+ "log_tools_postinstall": "YunoHostサーバーをポストインストールします",
+ "log_tools_reboot": "サーバーを再起動",
+ "log_tools_shutdown": "サーバーをシャットダウン",
+ "log_tools_upgrade": "システムパッケージのアップグレード",
+ "log_user_group_delete": "「{}」グループの削除",
+ "log_user_group_update": "'{}' グループを更新",
+ "log_user_import": "ユーザーのインポート",
+ "mailbox_used_space_dovecot_down": "使用済みメールボックススペースをフェッチする場合は、Dovecotメールボックスサービスが稼働している必要があります",
+ "log_user_permission_reset": "アクセス許可 '{}' をリセットします",
+ "mailbox_disabled": "ユーザーの{user}に対して電子メールがオフになっている",
+ "main_domain_change_failed": "メインドメインを変更できません",
+ "main_domain_changed": "メインドメインが変更されました",
+ "migration_0021_cleaning_up": "キャッシュとパッケージのクリーンアップはもう役に立たなくなりました...",
+ "migration_0021_general_warning": "この移行はデリケートな操作であることに注意してください。YunoHostチームはそれをレビューしてテストするために最善を尽くしましたが、移行によってシステムまたはそのアプリの一部が破損する可能性があります。\n\nしたがって、次のことをお勧めします。\n - 重要なデータやアプリのバックアップを実行します。関する詳細情報: https://yunohost.org/backup\n - 移行を開始した後はしばらくお待ちください: インターネット接続とハードウェアによっては、すべてがアップグレードされるまでに最大数時間かかる場合があります。",
+ "migration_0021_main_upgrade": "メインアップグレードを開始しています...",
+ "migration_0021_not_enough_free_space": "/var/の空き容量はかなり少ないです!この移行を実行するには、少なくとも 1 GB の空き容量が必要です。",
+ "migration_0021_modified_files": "次のファイルは手動で変更されていることが判明し、アップグレード後に上書きされる可能性があることに注意してください: {manually_modified_files}",
+ "migration_0021_not_buster2": "現在の Debian ディストリビューションは Buster ではありません!すでにBuster->Bullseyeの移行を実行している場合、このエラーは移行手順が100% s成功しなかったという事実の兆候です(そうでなければ、YunoHostは完了のフラグを立てます)。Webadminのツール>ログにある移行の**完全な**ログを必要とするサポートチームで何が起こったのかを調査することをお勧めします。",
+ "migration_0021_patch_yunohost_conflicts": "競合の問題を回避するためにパッチを適用しています...",
+ "migration_0021_patching_sources_list": "sources.listsにパッチを適用しています...",
+ "migration_0021_problematic_apps_warning": "以下の問題のあるインストール済みアプリが検出されました。これらはYunoHostアプリカタログからインストールされていないか、「working」としてフラグが立てられていないようです。したがって、アップグレード後も動作することを保証することはできません: {problematic_apps}",
+ "migration_0021_still_on_buster_after_main_upgrade": "メインのアップグレード中に問題が発生しましたが、システムはまだDebian Busterです",
+ "migration_0021_system_not_fully_up_to_date": "システムが完全に最新ではありません。Bullseyeへの移行を実行する前に、まずは通常のアップグレードを実行してください。",
+ "migration_0023_not_enough_space": "移行を実行するのに十分な領域を {path} で使用できるようにします。",
+ "migration_0023_postgresql_11_not_installed": "PostgreSQL がシステムにインストールされていません。何もすることはありません。",
+ "migration_0023_postgresql_13_not_installed": "PostgreSQL 11はインストールされていますが、PostgreSQL 13はインストールされてい!?:(システムで何か奇妙なことが起こった可能性があります...",
+ "migration_0024_rebuild_python_venv_broken_app": "このアプリ用にvirtualenvを簡単に再構築できないため、{app}スキップします。代わりに、「yunohostアプリのアップグレード-{app}を強制」を使用してこのアプリを強制的にアップグレードして、状況を修正する必要があります。",
+ "migration_0024_rebuild_python_venv_disclaimer_base": "Debian Bullseye へのアップグレード後、Debian に同梱されている新しい Python バージョンに変換するために、いくつかの Python アプリケーションを部分的に再構築する必要があります (技術的には、「virtualenv」と呼ばれるものを再作成する必要があります)。それまでの間、これらのPythonアプリケーションは機能しない可能性があります。YunoHostは、以下に詳述するように、それらのいくつかについて仮想環境の再構築を試みることができます。他のアプリの場合、または再構築の試行が失敗した場合は、それらのアプリのアップグレードを手動で強制する必要があります。",
+ "migration_0024_rebuild_python_venv_disclaimer_ignored": "これらのアプリに対して Virtualenvs を自動的に再構築することはできません。あなたはそれらのアップグレードを強制する必要があります、それはコマンドラインから行うことができます: 'yunohostアプリのアップグレード - -force APP':{ignored_apps}",
+ "migration_0024_rebuild_python_venv_disclaimer_rebuild": "virtualenvの再構築は、次のアプリに対して試行されます(注意:操作には時間がかかる場合があります)。 {rebuild_apps}",
+ "migration_0024_rebuild_python_venv_failed": "{app} の Python virtualenv の再構築に失敗しました。これが解決されない限り、アプリは機能しない場合があります。「yunohostアプリのアップグレード--強制{app}」を使用してこのアプリのアップグレードを強制して、状況を修正する必要があります。",
+ "migration_0024_rebuild_python_venv_in_progress": "現在、 '{app}'のPython仮想環境を再構築しようとしています",
+ "migration_description_0021_migrate_to_bullseye": "システムを Debian ブルズアイと YunoHost 11.x にアップグレードする",
+ "migration_description_0022_php73_to_php74_pools": "php7.3-fpm 'pool' conf ファイルを php7.4 に移行します。",
+ "migration_description_0023_postgresql_11_to_13": "PostgreSQL 11 から 13 へのデータベースの移行",
+ "migration_description_0024_rebuild_python_venv": "ブルズアイ移行後にPythonアプリを修復する",
+ "migration_description_0025_global_settings_to_configpanel": "従来のグローバル設定の命名法を新しい最新の命名法に移行する",
+ "migration_ldap_rollback_success": "システムがロールバックされました。",
+ "migrations_already_ran": "これらの移行は既に完了しています: {ids}",
+ "migrations_dependencies_not_satisfied": "移行{id}の前に、次の移行を実行します: '{dependencies_id}'。",
+ "migrations_exclusive_options": "'--auto'、'--skip'、および '--force-rerun' は相互に排他的なオプションです。",
+ "migrations_failed_to_load_migration": "移行{id}を読み込めませんでした: {error}",
+ "migrations_list_conflict_pending_done": "'--previous' と '--done' の両方を同時に使用することはできません。",
+ "migrations_loading_migration": "移行{id}を読み込んでいます...",
+ "migrations_migration_has_failed": "移行{id}が完了しなかったため、中止されました。エラー: {exception}",
+ "migrations_must_provide_explicit_targets": "'--skip' または '--force-rerun' を使用する場合は、明示的なターゲットを指定する必要があります。",
+ "migrations_need_to_accept_disclaimer": "移行{id}を実行するには、次の免責事項に同意する必要があります。\n---\n{disclaimer}\n---\n移行の実行に同意する場合は、'--accept-disclaimer' オプションを指定してコマンドを再実行してください。",
+ "migrations_running_forward": "移行{id}を実行しています...",
+ "migrations_skip_migration": "移行{id}スキップしています...",
+ "migrations_success_forward": "移行{id}完了しました",
+ "migrations_to_be_ran_manually": "移行{id}は手動で実行する必要があります。ウェブ管理ページの移行→ツールに移動するか、「yunohostツールの移行実行」を実行してください。",
+ "not_enough_disk_space": "'{path}'に十分な空き容量がありません",
+ "operation_interrupted": "操作は手動で中断されたようですね?",
+ "migrations_no_migrations_to_run": "実行する移行はありません",
+ "migrations_no_such_migration": "「{id}」と呼ばれる移行はありません",
+ "other_available_options": "...および{n}個の表示されない他の使用可能なオプション",
+ "migrations_not_pending_cant_skip": "これらの移行は保留中ではないため、スキップすることはできません。 {ids}",
+ "migrations_pending_cant_rerun": "これらの移行はまだ保留中であるため、再度実行することはできません{ids}",
+ "password_confirmation_not_the_same": "パスワードが一致しません",
+ "password_listed": "このパスワードは、世界で最も使用されているパスワードの1つです。もっとユニークなものを選んでください。",
+ "password_too_long": "127文字未満のパスワードを選択してください",
+ "password_too_simple_2": "パスワードは8文字以上で、数字、大文字、小文字を含める必要があります",
+ "password_too_simple_3": "パスワードは8文字以上で、数字、大文字、小文字、特殊文字を含める必要があります",
+ "password_too_simple_4": "パスワードは12文字以上で、数字、大文字、小文字、特殊文字を含める必要があります",
+ "pattern_backup_archive_name": "最大 30 文字、英数字、-_ を含む有効なファイル名である必要があります。文字のみ",
+ "pattern_domain": "有効なドメイン名である必要があります(例:my-domain.org)",
+ "pattern_email": "「+」記号のない有効な電子メールアドレスである必要があります(例:someone@example.com)",
+ "pattern_email_forward": "有効な電子メールアドレスである必要があり、「+」記号が受け入れられます(例:someone+tag@example.com)",
+ "pattern_firstname": "有効な名前(3 文字以上)である必要があります。",
+ "pattern_fullname": "有効なフルネーム (3 文字以上) である必要があります。",
+ "pattern_lastname": "有効な姓 (3 文字以上) である必要があります。",
+ "pattern_mailbox_quota": "クォータを持たない場合は、接尾辞が b/k/M/G/T または 0 を含むサイズである必要があります",
+ "pattern_password": "3 文字以上である必要があります",
+ "pattern_password_app": "申し訳ありませんが、パスワードに次の文字を含めることはできません: {forbidden_chars}",
+ "pattern_port_or_range": "有効なポート番号(例:0-65535)またはポート範囲(例:100:200)である必要があります",
+ "pattern_username": "小文字の英数字とアンダースコア(_)のみにする必要があります",
+ "permission_already_allowed": "グループ '{group}' には既にアクセス許可 '{permission}' が有効になっています",
+ "permission_already_disallowed": "グループ '{group}' には既にアクセス許可 '{permission}' が無効になっています",
+ "permission_already_exist": "アクセス許可 '{permission}' は既に存在します",
+ "permission_already_up_to_date": "追加/削除要求が既に現在の状態と一致しているため、アクセス許可は更新されませんでした。",
+ "permission_cannot_remove_main": "メイン権限の削除は許可されていません",
+ "permission_cant_add_to_all_users": "権限{permission}すべてのユーザーに追加することはできません。",
+ "permission_created": "アクセス許可 '{permission}' が作成されました",
+ "permission_creation_failed": "アクセス許可 '{permission}' を作成できませんでした: {error}",
+ "permission_currently_allowed_for_all_users": "このアクセス許可は現在、他のユーザーに加えてすべてのユーザーに付与されています。「all_users」権限を削除するか、現在付与されている他のグループを削除することをお勧めします。",
+ "permission_deleted": "権限 '{permission}' が削除されました",
+ "permission_deletion_failed": "アクセス許可 '{permission}' を削除できませんでした: {error}",
+ "permission_not_found": "アクセス許可 '{permission}' が見つかりません",
+ "permission_protected": "アクセス許可{permission}は保護されています。このアクセス許可に対して訪問者グループを追加または削除することはできません。",
+ "permission_require_account": "権限{permission}は、アカウントを持つユーザーに対してのみ意味があるため、訪問者に対して有効にすることはできません。",
+ "permission_update_failed": "アクセス許可 '{permission}' を更新できませんでした: {error}",
+ "port_already_closed": "ポート {port} は既に{ip_version}接続のために閉じられています",
+ "port_already_opened": "ポート {port} は既に{ip_version}接続用に開かれています",
+ "postinstall_low_rootfsspace": "ルートファイルシステムの総容量は10GB未満で、かなり気になります。ディスク容量がすぐに不足する可能性があります。ルートファイルシステム用に少なくとも16GBを用意することをお勧めします。この警告にもかかわらずYunoHostをインストールする場合は、--force-diskspaceを使用してポストインストールを再実行してください",
+ "regenconf_dry_pending_applying": "カテゴリ '{category}' に適用された保留中の構成を確認しています...",
+ "regenconf_failed": "カテゴリの設定を再生成できませんでした: {categories}",
+ "regenconf_file_backed_up": "構成ファイル '{conf}' が '{backup}' にバックアップされました",
+ "regenconf_file_copy_failed": "新しい構成ファイル '{new}' を '{conf}' にコピーできませんでした",
+ "regenconf_file_kept_back": "設定ファイル '{conf}' は regen-conf (カテゴリ {category}) によって削除される予定でしたが、元に戻されました。",
+ "regenconf_file_manually_modified": "構成ファイル '{conf}' は手動で変更されており、更新されません",
+ "regenconf_file_manually_removed": "構成ファイル '{conf}' は手動で削除され、作成されません",
+ "regenconf_file_remove_failed": "構成ファイル '{conf}' を削除できませんでした",
+ "regenconf_file_removed": "構成ファイル '{conf}' が削除されました",
+ "regenconf_file_updated": "構成ファイル '{conf}' が更新されました",
+ "regenconf_need_to_explicitly_specify_ssh": "ssh構成は手動で変更されていますが、実際に変更を適用するには、--forceでカテゴリ「ssh」を明示的に指定する必要があります。",
+ "regenconf_now_managed_by_yunohost": "設定ファイル '{conf}' が YunoHost (カテゴリ {category}) によって管理されるようになりました。",
+ "regenconf_pending_applying": "カテゴリ '{category}' に保留中の構成を適用しています...",
+ "regenconf_up_to_date": "カテゴリ '{category}' の設定は既に最新です",
+ "regenconf_updated": "このカテゴリーにログが登録されていません",
+ "regenconf_would_be_updated": "カテゴリ '{category}' の構成が更新されているはずです。",
+ "regex_incompatible_with_tile": "パッケージャー!アクセス許可 '{permission}' show_tile が 'true' に設定されているため、正規表現 URL をメイン URL として定義できません",
+ "regex_with_only_domain": "ドメインに正規表現を使用することはできませんが、パスにのみ使用できます",
+ "registrar_infos": "レジストラ情報",
+ "restore_already_installed_app": "'{name}' の ‘{id}’ パネル設定をアップデートする",
+ "restore_already_installed_apps": "次のアプリは既にインストールされているため復元できません。 {apps}",
+ "restore_backup_too_old": "このバックアップアーカイブは、古すぎるYunoHostバージョンからのものであるため、復元できません。",
+ "restore_cleaning_failed": "一時復元ディレクトリをクリーンアップできませんでした",
+ "restore_complete": "復元が完了しました",
+ "restore_may_be_not_enough_disk_space": "システムに十分なスペースがないようです(空き:{free_space} B、必要なスペース:{needed_space} B、セキュリティマージン:{margin} B)",
+ "root_password_desynchronized": "管理者パスワードが変更されましたが、YunoHostはこれをrootパスワードに伝播できませんでした!",
+ "server_reboot_confirm": "サーバーはすぐに再起動しますが、よろしいですか?[{answers}]",
+ "server_shutdown": "サーバーがシャットダウンします",
+ "service_already_stopped": "サービス '{service}' は既に停止されています",
+ "service_cmd_exec_failed": "コマンド '{command}' を実行できませんでした",
+ "service_description_nginx": "サーバーでホストされているすべてのWebサイトへのアクセスを提供または提供します",
+ "service_description_redis-server": "高速データ・アクセス、タスク・キュー、およびプログラム間の通信に使用される特殊なデータベース",
+ "service_description_rspamd": "スパムやその他の電子メール関連機能をフィルタリングします",
+ "service_description_slapd": "ユーザー、ドメイン、関連情報を格納します",
+ "service_description_ssh": "ターミナル経由でサーバーにリモート接続できます(SSHプロトコル)",
+ "service_description_yunohost-api": "YunoHostウェブインターフェイスとシステム間の相互作用を管理します",
+ "service_description_yunohost-firewall": "サービスへの接続ポートの開閉を管理",
+ "service_description_yunomdns": "ローカルネットワークで「yunohost.local」を使用してサーバーに到達できます",
+ "service_disable_failed": "起動時にサービス '{service}' を開始できませんでした。\n\n最近のサービスログ:{logs}",
+ "service_disabled": "システムの起動時にサービス '{service}' は開始されなくなります。",
+ "service_reload_failed": "サービス '{service}' をリロードできませんでした\n\n最近のサービスログ:{logs}",
+ "service_reload_or_restart_failed": "サービス '{service}' をリロードまたは再起動できませんでした\n\n最近のサービスログ:{logs}",
+ "service_reloaded_or_restarted": "サービス '{service}' が再読み込みまたは再起動されました",
+ "service_remove_failed": "サービス '{service}' を削除できませんでした",
+ "service_removed": "サービス '{service}' が削除されました",
+ "service_restart_failed": "サービス '{service}' を再起動できませんでした\n\n最近のサービスログ:{logs}",
+ "service_restarted": "サービス '{service}' が再起動しました",
+ "service_start_failed": "サービス '{service}' を開始できませんでした\n\n最近のサービスログ:{logs}",
+ "service_started": "サービス '{service}' が開始されました",
+ "service_stop_failed": "サービス '{service}' を停止できません\n\n最近のサービスログ:{logs}",
+ "service_stopped": "サービス '{service}' が停止しました",
+ "service_unknown": "不明なサービス '{service}'",
+ "system_username_exists": "ユーザー名はシステムユーザーのリストにすでに存在します",
+ "this_action_broke_dpkg": "このアクションはdpkg / APT(システムパッケージマネージャ)を壊しました...SSH経由で接続し、「sudo apt install --fix-broken」および/または「sudo dpkg --configure -a」を実行することで、この問題を解決できます。",
+ "tools_upgrade": "システムパッケージのアップグレード",
+ "tools_upgrade_failed": "パッケージをアップグレードできませんでした: {packages_list}",
+ "unbackup_app": "{app}は保存されません",
+ "unexpected_error": "予期しない問題が発生しました:{error}",
+ "unknown_main_domain_path": "'{app}' の不明なドメインまたはパス。アクセス許可の URL を指定できるようにするには、ドメインとパスを指定する必要があります。",
+ "unrestore_app": "{app}は復元されません",
+ "updating_apt_cache": "システムパッケージの利用可能なアップグレードを取得しています...",
+ "upgrade_complete": "アップグレート完了",
+ "upgrading_packages": "パッケージをアップグレードしています...",
+ "upnp_dev_not_found": "UPnP デバイスが見つかりません",
+ "upnp_disabled": "UPnP がオフになりました",
+ "upnp_enabled": "UPnP がオンになりました",
+ "upnp_port_open_failed": "UPnP 経由でポートを開けませんでした",
+ "user_already_exists": "ユーザー '{user}' は既に存在します",
+ "user_created": "ユーザーが作成されました。",
+ "user_creation_failed": "ユーザー {user}を作成できませんでした: {error}",
+ "user_deleted": "ユーザーが削除されました",
+ "user_deletion_failed": "ユーザー {user}を削除できませんでした: {error}",
+ "user_home_creation_failed": "ユーザーのホームフォルダ '{home}' を作成できませんでした",
+ "user_import_bad_file": "CSVファイルが正しくフォーマットされていないため、データ損失の可能性を回避するために無視されます",
+ "user_import_bad_line": "行{line}が正しくありません: {details}",
+ "user_import_failed": "ユーザーのインポート操作が完全に失敗しました",
+ "user_import_missing_columns": "次の列がありません: {columns}",
+ "user_import_nothing_to_do": "インポートする必要があるユーザーはいません",
+ "user_import_partial_failed": "ユーザーのインポート操作が部分的に失敗しました",
+ "user_import_success": "ユーザーが正常にインポートされました",
+ "user_unknown": "不明なユーザー: {user}",
+ "user_update_failed": "ユーザー {user}を更新できませんでした: {error}",
+ "user_updated": "ユーザー情報が変更されました",
+ "visitors": "訪問者",
+ "yunohost_already_installed": "YunoHostはすでにインストールされています",
+ "yunohost_configured": "YunoHost が構成されました",
+ "yunohost_installing": "YunoHostをインストールしています...",
+ "yunohost_not_installed": "YunoHostが正しくインストールされていません。’yunohost tools postinstall’ を実行してください",
+ "yunohost_postinstall_end_tip": "インストール後処理が完了しました!セットアップを完了するには、次の点を考慮してください。\n - ウェブ管理画面の「診断」セクション(またはコマンドラインで’yunohost diagnosis run’)を通じて潜在的な問題を診断します。\n - 管理ドキュメントの「セットアップの最終処理」と「YunoHostを知る」の部分を読む: https://yunohost.org/admindoc。",
+ "additional_urls_already_removed": "アクセス許可 ‘{permission}’ に対する追加URLで ‘{url}’ は既に削除されています"
+}
diff --git a/locales/kab.json b/locales/kab.json
index 5daa7cef0..f6e3722cf 100644
--- a/locales/kab.json
+++ b/locales/kab.json
@@ -1,14 +1,11 @@
{
- "ask_firstname": "Isem",
- "ask_lastname": "Isem n tmagit",
"ask_password": "Awal n uɛeddi",
"diagnosis_description_apps": "Isnasen",
"diagnosis_description_mail": "Imayl",
"domain_deleted": "Taɣult tettwakkes",
"done": "Immed",
- "invalid_password": "Yir awal uffir",
"user_created": "Aseqdac yettwarna",
"diagnosis_description_dnsrecords": "Ikalasen DNS",
"diagnosis_description_web": "Réseau",
"domain_created": "Taɣult tettwarna"
-}
+}
\ No newline at end of file
diff --git a/locales/lt.json b/locales/lt.json
new file mode 100644
index 000000000..9e26dfeeb
--- /dev/null
+++ b/locales/lt.json
@@ -0,0 +1 @@
+{}
\ No newline at end of file
diff --git a/locales/nb_NO.json b/locales/nb_NO.json
index e81d3af05..8cacaff6d 100644
--- a/locales/nb_NO.json
+++ b/locales/nb_NO.json
@@ -1,9 +1,6 @@
{
"aborting": "Avbryter…",
"admin_password": "Administrasjonspassord",
- "admin_password_change_failed": "Kan ikke endre passord",
- "admin_password_changed": "Administrasjonspassord endret",
- "admin_password_too_long": "Velg et passord kortere enn 127 tegn",
"app_already_installed": "{app} er allerede installert",
"app_already_up_to_date": "{app} er allerede oppdatert",
"app_argument_invalid": "Velg en gydlig verdi for argumentet '{name}': {error}",
@@ -32,7 +29,6 @@
"downloading": "Laster ned…",
"dyndns_could_not_check_available": "Kunne ikke sjekke om {domain} er tilgjengelig på {provider}.",
"mail_domain_unknown": "Ukjent e-postadresse for domenet '{domain}'",
- "log_remove_on_failed_restore": "Fjern '{}' etter mislykket gjenoppretting fra sikkerhetskopiarkiv",
"log_letsencrypt_cert_install": "Installer et Let's Encrypt-sertifikat på '{}'-domenet",
"log_letsencrypt_cert_renew": "Forny '{}'-Let's Encrypt-sertifikat",
"log_user_update": "Oppdater brukerinfo for '{}'",
@@ -86,9 +82,7 @@
"dyndns_key_generating": "Oppretter DNS-nøkkel… Dette kan ta en stund.",
"dyndns_no_domain_registered": "Inget domene registrert med DynDNS",
"dyndns_registered": "DynDNS-domene registrert",
- "global_settings_setting_security_password_admin_strength": "Admin-passordets styrke",
"dyndns_registration_failed": "Kunne ikke registrere DynDNS-domene: {error}",
- "global_settings_setting_security_password_user_strength": "Brukerpassordets styrke",
"log_backup_restore_app": "Gjenopprett '{}' fra sikkerhetskopiarkiv",
"log_remove_on_failed_install": "Fjern '{}' etter mislykket installasjon",
"log_selfsigned_cert_install": "Installer selvsignert sertifikat på '{}'-domenet",
@@ -100,8 +94,6 @@
"app_upgrade_failed": "Kunne ikke oppgradere {app}",
"app_upgrade_some_app_failed": "Noen programmer kunne ikke oppgraderes",
"app_upgraded": "{app} oppgradert",
- "ask_firstname": "Fornavn",
- "ask_lastname": "Etternavn",
"ask_main_domain": "Hoveddomene",
"ask_new_admin_password": "Nytt administrasjonspassord",
"app_upgrade_several_apps": "Følgende programmer vil oppgraderes: {apps}",
@@ -115,5 +107,7 @@
"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}"
+ "app_install_failed": "Kunne ikke installere {app}: {error}",
+ "global_settings_setting_admin_strength": "Admin-passordets styrke",
+ "global_settings_setting_user_strength": "Brukerpassordets styrke"
}
\ No newline at end of file
diff --git a/locales/nl.json b/locales/nl.json
index f8b6df327..bcfb76acd 100644
--- a/locales/nl.json
+++ b/locales/nl.json
@@ -1,7 +1,6 @@
{
"action_invalid": "Ongeldige actie '{action}'",
"admin_password": "Administrator wachtwoord",
- "admin_password_changed": "Het administratie wachtwoord is gewijzigd",
"app_already_installed": "{app} is al geïnstalleerd",
"app_argument_invalid": "Kies een geldige waarde voor '{name}': {error}",
"app_argument_required": "Het '{name}' moet ingevuld worden",
@@ -14,11 +13,9 @@
"app_unknown": "Onbekende app",
"app_upgrade_failed": "Het is niet gelukt app {app} bij te werken: {error}",
"app_upgraded": "{app} is bijgewerkt",
- "ask_firstname": "Voornaam",
- "ask_lastname": "Achternaam",
"ask_new_admin_password": "Nieuw administratorwachtwoord",
"ask_password": "Wachtwoord",
- "backup_archive_name_exists": "Een backuparchief met dezelfde naam bestaat al",
+ "backup_archive_name_exists": "Er bestaat al een backuparchief met dezelfde naam.",
"backup_cleaning_failed": "Kan tijdelijke backup map niet leeg maken",
"backup_output_directory_not_empty": "Doelmap is niet leeg",
"custom_app_url_required": "U moet een URL opgeven om uw aangepaste app {app} bij te werken",
@@ -30,13 +27,13 @@
"domain_dyndns_already_subscribed": "U heeft reeds een domein bij DynDNS geregistreerd",
"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_uninstall_app_first": "Deze applicaties zijn nog steeds op je domein geïnstalleerd:\n{apps}\n\nVerwijder ze met 'yunohost app remove the_app_id' of verplaats ze naar een ander domein met 'yunohost app change-url the_app_id' voordat je doorgaat met het verwijderen van het domein",
"done": "Voltooid",
"downloading": "Downloaden...",
"dyndns_ip_update_failed": "Kan het IP adres niet updaten bij DynDNS",
"dyndns_ip_updated": "IP adres is aangepast bij DynDNS",
"dyndns_key_generating": "DNS sleutel word aangemaakt, wacht een moment...",
- "dyndns_unavailable": "DynDNS subdomein is niet beschikbaar",
+ "dyndns_unavailable": "Domein '{domain}' is niet beschikbaar.",
"extracting": "Uitpakken...",
"installation_complete": "Installatie voltooid",
"mail_alias_remove_failed": "Kan mail-alias '{mail}' niet verwijderen",
@@ -50,10 +47,10 @@
"service_add_failed": "Kan service '{service}' niet toevoegen",
"service_already_started": "Service '{service}' draait al",
"service_cmd_exec_failed": "Kan '{command}' niet uitvoeren",
- "service_disabled": "Service '{service}' is uitgeschakeld",
+ "service_disabled": "Service '{service}' wordt niet meer gestart als het systeem opstart.",
"service_remove_failed": "Kan service '{service}' niet verwijderen",
"service_removed": "Service werd verwijderd",
- "service_stop_failed": "Kan service '{service}' niet stoppen",
+ "service_stop_failed": "Kan service '{service}' niet stoppen\n\nRecente servicelogs: {logs}",
"service_unknown": "De service '{service}' bestaat niet",
"unexpected_error": "Er is een onbekende fout opgetreden",
"unrestore_app": "App '{app}' wordt niet teruggezet",
@@ -69,12 +66,10 @@
"user_unknown": "Gebruikersnaam {user} is onbekend",
"user_update_failed": "Kan gebruiker niet bijwerken",
"yunohost_configured": "YunoHost configuratie is OK",
- "admin_password_change_failed": "Wachtwoord wijzigen is niet gelukt",
"app_argument_choice_invalid": "Kiel een geldige waarde voor argument '{name}'; {value}' komt niet voor in de keuzelijst {choices}",
"app_not_correctly_installed": "{app} schijnt niet juist geïnstalleerd te zijn",
"app_not_properly_removed": "{app} werd niet volledig verwijderd",
"app_requirements_checking": "Noodzakelijke pakketten voor {app} aan het controleren...",
- "app_requirements_unmeet": "Er wordt niet aan de aanvorderingen voldaan, het pakket {pkgname} ({version}) moet {spec} zijn",
"app_unsupported_remote_type": "Niet ondersteund besturings type voor de app",
"ask_main_domain": "Hoofd-domein",
"backup_app_failed": "Kon geen backup voor app '{app}' aanmaken",
@@ -90,7 +85,6 @@
"backup_nothings_done": "Niets om op te slaan",
"password_too_simple_1": "Het wachtwoord moet minimaal 8 tekens lang zijn",
"already_up_to_date": "Er is niets te doen, alles is al up-to-date.",
- "admin_password_too_long": "Gelieve een wachtwoord te kiezen met minder dan 127 karakters",
"app_action_cannot_be_ran_because_required_services_down": "De volgende diensten moeten actief zijn om deze actie uit te voeren: {services}. Probeer om deze te herstarten om verder te gaan (en om eventueel te onderzoeken waarom ze niet werken).",
"aborting": "Annulatie.",
"app_upgrade_app_name": "Bezig {app} te upgraden...",
@@ -120,7 +114,7 @@
"app_action_broke_system": "Deze actie lijkt de volgende belangrijke services te hebben kapotgemaakt: {services}",
"app_config_unable_to_apply": "De waarden in het configuratiescherm konden niet toegepast worden.",
"app_config_unable_to_read": "Het is niet gelukt de waarden van het configuratiescherm te lezen.",
- "app_argument_password_no_default": "Foutmelding tijdens het lezen van wachtwoordargument '{name}': het wachtwoordargument mag om veiligheidsredenen geen standaardwaarde hebben.",
+ "app_argument_password_no_default": "Foutmelding tijdens het lezen van wachtwoordargument '{name}': het wachtwoordargument mag om veiligheidsredenen geen standaardwaarde hebben",
"app_already_installed_cant_change_url": "Deze app is al geïnstalleerd. De URL kan niet veranderd worden met deze functie. Probeer of dat lukt via `app changeurl`.",
"apps_catalog_init_success": "De app-catalogus is succesvol geinitieerd!",
"apps_catalog_failed_to_download": "Het is niet gelukt de {apps_catalog} app-catalogus te downloaden: {error}",
@@ -128,7 +122,7 @@
"additional_urls_already_added": "Extra URL '{url:s}' is al toegevoegd in de extra URL voor privilege '{permission:s}'",
"additional_urls_already_removed": "Extra URL '{url}' is al verwijderd in de extra URL voor privilege '{permission}'",
"app_label_deprecated": "Dit commando is vervallen. Gebruik alsjeblieft het nieuwe commando 'yunohost user permission update' om het label van de app te beheren.",
- "app_change_url_no_script": "De app '{app_name}' ondersteunt nog geen URL-aanpassingen. Misschien wel na een upgrade.",
+ "app_change_url_no_script": "App '{app_name}' ondersteunt nog geen URL-aanpassingen. Misschien wel na een upgrade.",
"app_upgrade_some_app_failed": "Sommige apps konden niet worden bijgewerkt",
"other_available_options": "... en {n} andere beschikbare opties die niet getoond worden",
"password_listed": "Dit wachtwoord is een van de meest gebruikte wachtwoorden ter wereld. Kies alstublieft iets wat minder voor de hand ligt.",
@@ -140,5 +134,9 @@
"pattern_domain": "Moet een geldige domeinnaam zijn (mijneigendomein.nl, bijvoorbeeld)",
"pattern_firstname": "Het moet een geldige voornaam zijn",
"pattern_lastname": "Het moet een geldige achternaam zijn",
- "password_too_simple_3": "Het wachtwoord moet minimaal 8 tekens lang zijn en moet cijfers, hoofdletters, kleine letters en speciale tekens bevatten"
+ "password_too_simple_3": "Het wachtwoord moet minimaal 8 tekens lang zijn en moet cijfers, hoofdletters, kleine letters en speciale tekens bevatten",
+ "group_already_exist": "Groep {group} bestaat al",
+ "group_already_exist_on_system": "Groep {group} bestaat al in de systeemgroepen",
+ "good_practices_about_admin_password": "Je gaat nu een nieuw beheerderswachtwoordopgeven. Het wachtwoord moet minimaal 8 tekens lang zijn, hoewel het een goede gewoonte is om een langer wachtwoord te gebruiken (d.w.z. een wachtwoordzin) en/of een variatie van tekens te gebruiken (hoofdletters, kleine letters, cijfers en speciale tekens).",
+ "good_practices_about_user_password": "Je gaat nu een nieuw gebruikerswachtwoord pgeven. Het wachtwoord moet minimaal 8 tekens lang zijn, hoewel het een goede gewoonte is om een langer wachtwoord te gebruiken (d.w.z. een wachtwoordzin) en/of een variatie van tekens te gebruiken (hoofdletters, kleine letters, cijfers en speciale tekens)."
}
\ No newline at end of file
diff --git a/locales/oc.json b/locales/oc.json
index a6afa32e6..1c13fc6b5 100644
--- a/locales/oc.json
+++ b/locales/oc.json
@@ -1,7 +1,5 @@
{
"admin_password": "Senhal d’administracion",
- "admin_password_change_failed": "Impossible de cambiar lo senhal",
- "admin_password_changed": "Lo senhal d’administracion es ben estat cambiat",
"app_already_installed": "{app} es ja installat",
"app_already_up_to_date": "{app} es ja a jorn",
"installation_complete": "Installacion acabada",
@@ -16,8 +14,6 @@
"app_upgrade_failed": "Impossible d’actualizar {app} : {error}",
"app_upgrade_some_app_failed": "D’aplicacions se pòdon pas actualizar",
"app_upgraded": "{app} es estada actualizada",
- "ask_firstname": "Prenom",
- "ask_lastname": "Nom",
"ask_main_domain": "Domeni màger",
"ask_new_admin_password": "Nòu senhal administrator",
"ask_password": "Senhal",
@@ -57,7 +53,6 @@
"backup_output_directory_required": "Vos cal especificar un dorsièr de sortida per la salvagarda",
"backup_running_hooks": "Execucion dels scripts de salvagarda...",
"backup_system_part_failed": "Impossible de salvagardar la part « {part} » del sistèma",
- "app_requirements_unmeet": "Las condicions requesidas per {app} son pas complidas, lo paquet {pkgname} ({version}) deu èsser {spec}",
"backup_abstract_method": "Aqueste metòde de salvagarda es pas encara implementat",
"backup_applying_method_custom": "Crida del metòde de salvagarda personalizat « {method} »...",
"backup_couldnt_bind": "Impossible de ligar {src} amb {dest}.",
@@ -120,10 +115,6 @@
"dyndns_unavailable": "Lo domeni {domain} es pas disponible.",
"extracting": "Extraccion…",
"field_invalid": "Camp incorrècte : « {} »",
- "global_settings_cant_open_settings": "Fracàs de la dobertura del fichièr de configuracion, rason : {reason}",
- "global_settings_key_doesnt_exists": "La clau « {settings_key} » existís pas dins las configuracions globalas, podètz veire totas las claus disponiblas en executant « yunohost settings list »",
- "global_settings_reset_success": "Configuracion precedenta ara salvagarda dins {path}",
- "global_settings_unknown_setting_from_settings_file": "Clau desconeguda dins los paramètres : {setting_key}, apartada e salvagardada dins /etc/yunohost/settings-unknown.json",
"main_domain_change_failed": "Modificacion impossibla del domeni màger",
"main_domain_changed": "Lo domeni màger es estat modificat",
"migrations_list_conflict_pending_done": "Podètz pas utilizar --previous e --done a l’encòp.",
@@ -152,10 +143,6 @@
"firewall_reload_failed": "Impossible de recargar lo parafuòc",
"firewall_reloaded": "Parafuòc recargat",
"firewall_rules_cmd_failed": "Unas règlas del parafuòc an fracassat. Per mai informacions, consultatz lo jornal.",
- "global_settings_bad_choice_for_enum": "La valor del paramètre {setting} es incorrècta. Recebut : {choice}, mas las opcions esperadas son : {available_choices}",
- "global_settings_bad_type_for_setting": "Lo tipe del paramètre {setting} es incorrècte, recebut : {received_type}, esperat {expected_type}",
- "global_settings_cant_write_settings": "Fracàs de l’escritura del fichièr de configuracion, rason : {reason}",
- "global_settings_unknown_type": "Situacion inesperada, la configuracion {setting} sembla d’aver lo tipe {unknown_type} mas es pas un tipe pres en carga pel sistèma.",
"hook_exec_failed": "Fracàs de l’execucion del script : « {path} »",
"hook_exec_not_terminated": "Lo escript « {path} » a pas acabat corrèctament",
"hook_list_by_invalid": "La proprietat de tria de las accions es invalida",
@@ -216,7 +203,6 @@
"service_description_ssh": "vos permet de vos connectar a distància a vòstre servidor via un teminal (protocòl SSH)",
"service_description_yunohost-api": "permet las interaccions entre l’interfàcia web de YunoHost e le sistèma",
"service_description_yunohost-firewall": "gerís los pòrts de connexion dobèrts e tampats als servicis",
- "global_settings_cant_serialize_settings": "Fracàs de la serializacion de las donadas de parametratge, rason : {reason}",
"ip6tables_unavailable": "Podètz pas jogar amb ip6tables aquí. Siá sèts dins un contenedor, siá vòstre nuclèu es pas compatible amb aquela opcion",
"iptables_unavailable": "Podètz pas jogar amb iptables aquí. Siá sèts dins un contenedor, siá vòstre nuclèu es pas compatible amb aquela opcion",
"mail_alias_remove_failed": "Supression impossibla de l’alias de corrièl « {mail} »",
@@ -237,7 +223,6 @@
"backup_cant_mount_uncompress_archive": "Impossible de montar en lectura sola lo repertòri de l’archiu descomprimit",
"backup_no_uncompress_archive_dir": "Lo repertòri de l’archiu descomprimit existís pas",
"pattern_username": "Deu èsser compausat solament de caractèrs alfanumerics en letras minusculas e de tirets basses",
- "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} »",
@@ -253,7 +238,6 @@
"log_available_on_yunopaste": "Lo jornal es ara disponible via {url}",
"log_backup_restore_system": "Restaurar lo sistèma a partir d’una salvagarda",
"log_backup_restore_app": "Restaurar « {} » a partir d’una salvagarda",
- "log_remove_on_failed_restore": "Levar « {} » aprèp un fracàs de restauracion a partir d’una salvagarda",
"log_remove_on_failed_install": "Tirar « {} » aprèp una installacion pas reüssida",
"log_domain_add": "Ajustar lo domeni « {} » dins la configuracion sistèma",
"log_domain_remove": "Tirar lo domeni « {} » d’a la configuracion sistèma",
@@ -293,11 +277,7 @@
"backup_mount_archive_for_restore": "Preparacion de l’archiu per restauracion...",
"dyndns_could_not_check_available": "Verificacion impossibla de la disponibilitat de {domain} sus {provider}.",
"file_does_not_exist": "Lo camin {path} existís pas.",
- "global_settings_setting_security_password_admin_strength": "Fòrça del senhal administrator",
- "global_settings_setting_security_password_user_strength": "Fòrça del senhal utilizaire",
- "root_password_replaced_by_admin_password": "Lo senhal root es estat remplaçat pel senhal administrator.",
"service_restarted": "Lo servici '{service}' es estat reaviat",
- "admin_password_too_long": "Causissètz un senhal d’almens 127 caractèrs",
"service_reloaded": "Lo servici « {service} » es estat tornat cargar",
"already_up_to_date": "I a pas res a far ! Tot es ja a jorn !",
"app_action_cannot_be_ran_because_required_services_down": "Aquestas aplicacions necessitan d’èsser lançadas per poder executar aquesta accion : {services}. Abans de contunhar deuriatz ensajar de reaviar los servicis seguents (e tanben cercar perque son tombats en pana) : {services}",
@@ -308,7 +288,6 @@
"log_regen_conf": "Regenerar las configuracions del sistèma « {} »",
"service_reloaded_or_restarted": "Lo servici « {service} » es estat recargat o reaviat",
"dpkg_is_broken": "Podètz pas far aquò pel moment perque dpkg/APT (los gestionaris de paquets del sistèma) sembla èsser mal configurat… Podètz ensajar de solucionar aquò en vos connectar via SSH e en executar « sudo dpkg --configure -a ».",
- "global_settings_setting_service_ssh_allow_deprecated_dsa_hostkey": "Autorizar l’utilizacion de la clau òst DSA (obsolèta) per la configuracion del servici SSH",
"hook_json_return_error": "Fracàs de la lectura del retorn de l’script {path}. Error : {msg}. Contengut brut : {raw_content}",
"pattern_password_app": "O planhèm, los senhals devon pas conténer los caractèrs seguents : {forbidden_chars}",
"regenconf_file_backed_up": "Lo fichièr de configuracion « {conf} » es estat salvagardat dins « {backup} »",
@@ -325,9 +304,6 @@
"regenconf_dry_pending_applying": "Verificacion de la configuracion que seriá estada aplicada a la categoria « {category} »…",
"regenconf_failed": "Regeneracion impossibla de la configuracion per la(s) categoria(s) : {categories}",
"regenconf_pending_applying": "Aplicacion de la configuracion en espèra per la categoria « {category} »…",
- "global_settings_setting_security_nginx_compatibility": "Solucion de compromés entre compatibilitat e seguretat pel servidor web NGINX Afècta los criptografs (e d’autres aspèctes ligats amb la seguretat)",
- "global_settings_setting_security_ssh_compatibility": "Solucion de compromés entre compatibilitat e seguretat pel servidor SSH. Afècta los criptografs (e d’autres aspèctes ligats amb la seguretat)",
- "global_settings_setting_security_postfix_compatibility": "Solucion de compromés entre compatibilitat e seguretat pel servidor Postfix. Afècta los criptografs (e d’autres aspèctes ligats amb la seguretat)",
"service_reload_failed": "Impossible de recargar lo servici « {service} »\n\nJornal d’audit recent : {logs}",
"service_restart_failed": "Impossible de reaviar lo servici « {service} »\n\nJornal d’audit recent : {logs}",
"service_reload_or_restart_failed": "Impossible de recargar o reaviar lo servici « {service} »\n\nJornal d’audit recent : {logs}",
@@ -403,7 +379,7 @@
"diagnosis_services_bad_status": "Lo servici {service} es {status} :(",
"diagnosis_swap_ok": "Lo sistèma a {total} d’escambi !",
"diagnosis_regenconf_allgood": "Totes los fichièrs de configuracion son confòrmes a la configuracion recomandada !",
- "diagnosis_regenconf_manually_modified": "Lo fichièr de configuracion {file} foguèt modificat manualament.",
+ "diagnosis_regenconf_manually_modified": "Lo fichièr de configuracion {file}
foguèt modificat manualament.",
"diagnosis_regenconf_manually_modified_details": "Es probablament bon tan que sabètz çò que fasètz ;) !",
"diagnosis_security_vulnerable_to_meltdown": "Semblatz èsser vulnerable a la vulnerabilitat de seguretat critica de Meltdown",
"diagnosis_description_basesystem": "Sistèma de basa",
@@ -453,7 +429,6 @@
"diagnosis_ip_broken_resolvconf": "La resolucion del nom de domeni sembla copada sul servidor, poiriá èsser ligada al fait que /etc/resolv.conf
manda pas a 127.0.0.1
.",
"diagnosis_ip_weird_resolvconf": "La resolucion del nom de domeni sembla foncionar, mas sembla qu’utiilizatz un fichièr /etc/resolv.conf
personalizat.",
"diagnosis_diskusage_verylow": "Lo lòc d’emmagazinatge {mountpoint}
(sul periferic {device}
) a solament {free} ({free_percent}%). Deuriatz considerar de liberar un pauc d’espaci.",
- "global_settings_setting_pop3_enabled": "Activar lo protocòl POP3 pel servidor de corrièr",
"diagnosis_diskusage_ok": "Lo lòc d’emmagazinatge {mountpoint}
(sul periferic {device}
) a encara {free} ({free_percent}%) de liure !",
"diagnosis_swap_none": "Lo sistèma a pas cap de memòria d’escambi. Auriatz de considerar d’ajustar almens {recommended} d’escambi per evitar las situacions ont lo sistèma manca de memòria.",
"diagnosis_swap_notsomuch": "Lo sistèma a solament {total} de memòria d’escambi. Auriatz de considerar d’ajustar almens {recommended} d’escambi per evitar las situacions ont lo sistèma manca de memòria.",
@@ -488,5 +463,10 @@
"diagnosis_domain_not_found_details": "Lo domeni {domain} existís pas a la basa de donadas WHOIS o a expirat !",
"diagnosis_domain_expiration_not_found": "Impossible de verificar la data d’expiracion d’unes domenis",
"backup_create_size_estimation": "L’archiu contendrà apr’aquí {size} de donadas.",
- "app_restore_script_failed": "Una error s’es producha a l’interior del script de restauracion de l’aplicacion"
+ "app_restore_script_failed": "Una error s’es producha a l’interior del script de restauracion de l’aplicacion",
+ "global_settings_setting_nginx_compatibility_help": "Solucion de compromés entre compatibilitat e seguretat pel servidor web NGINX Afècta los criptografs (e d’autres aspèctes ligats amb la seguretat)",
+ "global_settings_setting_admin_strength": "Fòrça del senhal administrator",
+ "global_settings_setting_user_strength": "Fòrça del senhal utilizaire",
+ "global_settings_setting_postfix_compatibility_help": "Solucion de compromés entre compatibilitat e seguretat pel servidor Postfix. Afècta los criptografs (e d’autres aspèctes ligats amb la seguretat)",
+ "global_settings_setting_ssh_compatibility_help": "Solucion de compromés entre compatibilitat e seguretat pel servidor SSH. Afècta los criptografs (e d’autres aspèctes ligats amb la seguretat)"
}
\ No newline at end of file
diff --git a/locales/pl.json b/locales/pl.json
index 01cd71471..52f2de3ca 100644
--- a/locales/pl.json
+++ b/locales/pl.json
@@ -1,12 +1,278 @@
{
"password_too_simple_1": "Hasło musi mieć co najmniej 8 znaków",
"app_already_up_to_date": "{app} jest obecnie aktualna",
- "app_already_installed": "{app} jest już zainstalowane",
+ "app_already_installed": "{app} jest już zainstalowana",
"already_up_to_date": "Nic do zrobienia. Wszystko jest obecnie aktualne.",
- "admin_password_too_long": "Proszę wybrać hasło krótsze niż 127 znaków",
- "admin_password_changed": "Hasło administratora zostało zmienione",
- "admin_password_change_failed": "Nie można zmienić hasła",
"admin_password": "Hasło administratora",
"action_invalid": "Nieprawidłowe działanie '{action:s}'",
- "aborting": "Przerywanie."
-}
\ No newline at end of file
+ "aborting": "Przerywanie.",
+ "domain_config_auth_consumer_key": "Klucz konsumenta",
+ "domain_config_cert_validity": "Ważność",
+ "visitors": "Odwiedzający",
+ "app_start_install": "Instalowanie {app}...",
+ "app_unknown": "Nieznana aplikacja",
+ "ask_main_domain": "Domena główna",
+ "backup_created": "Utworzono kopię zapasową: {name}",
+ "firewall_reloaded": "Przeładowano zaporę sieciową",
+ "user_created": "Utworzono użytkownika",
+ "yunohost_installing": "Instalowanie YunoHost...",
+ "global_settings_setting_smtp_allow_ipv6": "Zezwól na IPv6",
+ "user_deleted": "Usunięto użytkownika",
+ "domain_config_default_app": "Domyślna aplikacja",
+ "restore_complete": "Przywracanie zakończone",
+ "domain_deleted": "Usunięto domenę",
+ "domains_available": "Dostępne domeny:",
+ "domain_config_api_protocol": "API protokołu",
+ "domain_config_auth_application_key": "Klucz aplikacji",
+ "diagnosis_description_systemresources": "Zasoby systemu",
+ "log_user_import": "Importuj użytkowników",
+ "system_upgraded": "Zaktualizowano system",
+ "diagnosis_description_regenconf": "Konfiguracja systemu",
+ "diagnosis_description_apps": "Aplikacje",
+ "diagnosis_description_basesystem": "Baza systemu",
+ "unlimit": "Brak limitu",
+ "global_settings_setting_pop3_enabled": "Włącz POP3",
+ "domain_created": "Utworzono domenę",
+ "ask_new_admin_password": "Nowe hasło administracyjne",
+ "ask_new_domain": "Nowa domena",
+ "ask_new_path": "Nowa ścieżka",
+ "downloading": "Pobieranie...",
+ "ask_password": "Hasło",
+ "backup_deleted": "Usunięto kopię zapasową: {name}.",
+ "done": "Gotowe",
+ "diagnosis_description_dnsrecords": "Rekordy DNS",
+ "diagnosis_description_ip": "Połączenie z internetem",
+ "diagnosis_description_mail": "Email",
+ "diagnosis_mail_ehlo_could_not_diagnose_details": "Błąd: {error}",
+ "diagnosis_mail_queue_unavailable_details": "Błąd: {error}",
+ "diagnosis_http_could_not_diagnose_details": "Błąd: {error}",
+ "installation_complete": "Instalacja zakończona",
+ "app_start_remove": "Usuwanie {app}...",
+ "app_start_restore": "Przywracanie {app}...",
+ "app_upgraded": "Zaktualizowano {app}",
+ "extracting": "Rozpakowywanie...",
+ "app_removed": "Odinstalowano {app}",
+ "upgrade_complete": "Aktualizacja zakończona",
+ "global_settings_setting_backup_compress_tar_archives": "Kompresuj kopie zapasowe",
+ "global_settings_setting_nginx_compatibility": "Kompatybilność z NGINX",
+ "global_settings_setting_nginx_redirect_to_https": "Wymuszaj HTTPS",
+ "ask_admin_username": "Nazwa użytkownika administratora",
+ "ask_fullname": "Pełne imię i nazwisko",
+ "upgrading_packages": "Aktualizowanie paczek...",
+ "admins": "Administratorzy",
+ "diagnosis_ports_could_not_diagnose_details": "Błąd: {error}",
+ "log_settings_set": "Zastosuj ustawienia",
+ "domain_config_cert_issuer": "Organ certyfikacji",
+ "domain_config_cert_summary": "Status certyfikatu",
+ "global_settings_setting_ssh_compatibility": "Kompatybilność z SSH",
+ "global_settings_setting_ssh_port": "Port SSH",
+ "log_settings_reset": "Resetuj ustawienia",
+ "log_tools_migrations_migrate_forward": "Uruchom migracje",
+ "app_action_cannot_be_ran_because_required_services_down": "Następujące usługi powinny być uruchomione, aby rozpocząć to działanie: {services}. Spróbuj uruchomić je ponownie aby kontynuować (i dowiedzieć się, dlaczego były one wyłączone)",
+ "app_argument_choice_invalid": "Wybierz poprawną wartość dla argumentu '{name}': '{value}' nie znajduje się w liście poprawnych opcji ({choices})",
+ "app_action_broke_system": "Wydaje się, że ta akcja przerwała te ważne usługi: {services}",
+ "additional_urls_already_removed": "Dodatkowy URL '{url}' już usunięty w dodatkowym URL dla uprawnienia '{permission}'",
+ "additional_urls_already_added": "Dodatkowy URL '{url}' już dodany w dodatkowym URL dla uprawnienia '{permission}'",
+ "app_arch_not_supported": "Ta aplikacja może być zainstalowana tylko na architekturach {required}, a twoja architektura serwera to {current}",
+ "app_argument_invalid": "Wybierz poprawną wartość dla argumentu '{name}': {error}",
+ "all_users": "Wszyscy użytkownicy YunoHost",
+ "app_action_failed": "Nie udało się uruchomić akcji {action} dla aplikacji {app}",
+ "app_already_installed_cant_change_url": "Ta aplikacja jest już zainstalowana. URL nie może zostać zmieniony przy użyciu tej funkcji. Sprawdź czy można zmienić w `app changeurl`",
+ "app_id_invalid": "Nieprawidłowy identyfikator aplikacji(ID)",
+ "app_change_url_require_full_domain": "Nie można przenieść aplikacji {app} na nowy adres URL, ponieważ wymaga ona pełnej domeny (tj. ze ścieżką = /)",
+ "app_install_files_invalid": "Te pliki nie mogą zostać zainstalowane.",
+ "app_make_default_location_already_used": "Nie można ustawić '{app}' jako domyślnej aplikacji w domenie '{domain}' ponieważ jest już używana przez '{other_app}'",
+ "app_change_url_identical_domains": "Stara i nowa domena/ścieżka_url są identyczne („{domain}{path}”), nic nie trzeba robić.",
+ "app_config_unable_to_read": "Nie udało się odczytać wartości panelu konfiguracji.",
+ "app_config_unable_to_apply": "Nie udało się zastosować wartości panelu konfiguracji.",
+ "app_install_failed": "Nie udało się zainstalować {app}: {error}",
+ "apps_catalog_failed_to_download": "Nie można pobrać katalogu aplikacji app catalog: {error}",
+ "app_argument_required": "Argument „{name}” jest wymagany",
+ "app_not_properly_removed": "Aplikacja {app} nie została poprawnie usunięta",
+ "app_upgrade_failed": "Nie można uaktualnić {app}: {error}",
+ "backup_abstract_method": "Ta metoda tworzenia kopii zapasowych nie została jeszcze zaimplementowana",
+ "backup_actually_backuping": "Tworzenie archiwum kopii zapasowej z zebranych plików...",
+ "backup_applying_method_copy": "Kopiowanie wszystkich plików do kopii zapasowej...",
+ "backup_applying_method_tar": "Tworzenie kopii zapasowej archiwum TAR..",
+ "backup_archive_app_not_found": "Nie można znaleźć aplikacji {app} w archiwum kopii zapasowych",
+ "backup_archive_broken_link": "Nie można uzyskać dostępu do archiwum kopii zapasowych (broken link to {path})",
+ "backup_csv_addition_failed": "Nie udało się dodać plików do kopii zapasowej do pliku CSV.",
+ "backup_creation_failed": "Nie udało się utworzyć archiwum kopii zapasowej",
+ "backup_csv_creation_failed": "Nie udało się utworzyć wymaganego pliku CSV do przywracania.",
+ "backup_custom_mount_error": "Niestandardowa metoda tworzenia kopii zapasowej nie mogła przejść etapu „mount”",
+ "backup_applying_method_custom": "Wywołuję niestandardową metodę tworzenia kopii zapasowych '{method}'...",
+ "app_remove_after_failed_install": "Usuwanie aplikacji po niepowodzeniu instalacji...",
+ "app_upgrade_script_failed": "Wystąpił błąd w skrypcie aktualizacji aplikacji",
+ "apps_catalog_init_success": "Zainicjowano system katalogu aplikacji!",
+ "apps_catalog_obsolete_cache": "Pamięć podręczna katalogu aplikacji jest pusta lub przestarzała.",
+ "app_extraction_failed": "Nie można wyodrębnić plików instalacyjnych",
+ "app_packaging_format_not_supported": "Ta aplikacja nie może zostać zainstalowana, ponieważ jej format opakowania nie jest obsługiwany przez twoją wersję YunoHost. Prawdopodobnie powinieneś rozważyć aktualizację swojego systemu.",
+ "app_manifest_install_ask_domain": "Wybierz domenę, w której ta aplikacja ma zostać zainstalowana",
+ "app_manifest_install_ask_admin": "Wybierz użytkownika administratora dla tej aplikacji",
+ "app_manifest_install_ask_password": "Wybierz hasło administratora dla tej aplikacji",
+ "app_manifest_install_ask_is_public": "Czy ta aplikacja powinna być udostępniana anonimowym użytkownikom?",
+ "ask_user_domain": "Domena używana dla adresu e-mail użytkownika i konta XMPP",
+ "app_upgrade_app_name": "Aktualizuję {app}...",
+ "app_install_script_failed": "Wystąpił błąd w skrypcie instalacyjnym aplikacji",
+ "apps_catalog_update_success": "Katalog aplikacji został zaktualizowany!",
+ "apps_catalog_updating": "Aktualizowanie katalogu aplikacji...",
+ "app_label_deprecated": "To polecenie jest przestarzałe! Użyj nowego polecenia „yunohost user permission update”, aby zarządzać etykietą aplikacji.",
+ "app_change_url_no_script": "Aplikacja „{app_name}” nie obsługuje jeszcze modyfikacji adresów URL. Możesz spróbować ją zaaktualizować.",
+ "app_change_url_success": "Adres URL aplikacji {app} to teraz {domain}{path}",
+ "app_not_upgraded": "Nie udało się zaktualizować aplikacji „{failed_app}”, w związku z czym anulowano aktualizacje następujących aplikacji: {apps}",
+ "app_upgrade_several_apps": "Następujące aplikacje zostaną uaktualnione: {apps}",
+ "app_not_correctly_installed": "Wygląda na to, że aplikacja {app} jest nieprawidłowo zainstalowana",
+ "app_not_installed": "Nie można znaleźć aplikacji {app} na liście zainstalowanych aplikacji: {all_apps}",
+ "app_requirements_checking": "Sprawdzam wymagania dla aplikacji {app}...",
+ "app_upgrade_some_app_failed": "Niektórych aplikacji nie udało się zaktualizować",
+ "backup_app_failed": "Nie udało się utworzyć kopii zapasowej {app}",
+ "backup_archive_name_exists": "Archiwum kopii zapasowych o tej nazwie już istnieje.",
+ "backup_archive_open_failed": "Nie można otworzyć archiwum kopii zapasowej",
+ "backup_archive_writing_error": "Nie udało się dodać plików '{source}' (nazwanych w archiwum '{dest}') do utworzenia kopii zapasowej skompresowanego archiwum '{archive}'",
+ "backup_ask_for_copying_if_needed": "Czy chcesz wykonać kopię zapasową tymczasowo używając {size} MB? (Ta metoda jest stosowana, ponieważ niektóre pliki nie mogły zostać przygotowane przy użyciu bardziej wydajnej metody.)",
+ "backup_cant_mount_uncompress_archive": "Nie można zamontować nieskompresowanego archiwum jako chronione przed zapisem",
+ "backup_copying_to_organize_the_archive": "Kopiowanie {size} MB w celu zorganizowania archiwum",
+ "backup_couldnt_bind": "Nie udało się powiązać {src} z {dest}.",
+ "backup_archive_corrupted": "Wygląda na to, że archiwum kopii zapasowej '{archive}' jest uszkodzone: {error}",
+ "backup_cleaning_failed": "Nie udało się wyczyścić folderu tymczasowej kopii zapasowej",
+ "backup_create_size_estimation": "Archiwum będzie zawierać około {size} danych.",
+ "app_location_unavailable": "Ten adres URL jest niedostępny lub koliduje z już zainstalowanymi aplikacjami:\n{apps}",
+ "app_restore_failed": "Nie można przywrócić {app}: {error}",
+ "app_restore_script_failed": "Wystąpił błąd w skrypcie przywracania aplikacji",
+ "app_full_domain_unavailable": "Przepraszamy, ta aplikacja musi być zainstalowana we własnej domenie, ale inna aplikacja jest już zainstalowana w tej domenie „{domain}”. Zamiast tego możesz użyć subdomeny dedykowanej tej aplikacji.",
+ "app_resource_failed": "Nie udało się zapewnić, anulować obsługi administracyjnej lub zaktualizować zasobów aplikacji {app}: {error}",
+ "app_manifest_install_ask_path": "Wybierz ścieżkę adresu URL (po domenie), w której ta aplikacja ma zostać zainstalowana",
+ "app_not_enough_disk": "Ta aplikacja wymaga {required} wolnego miejsca.",
+ "app_not_enough_ram": "Ta aplikacja wymaga {required} pamięci RAM do zainstalowania/uaktualnienia, ale obecnie dostępna jest tylko {current}.",
+ "app_start_backup": "Zbieram pliki do utworzenia kopii zapasowej dla {app}...",
+ "app_yunohost_version_not_supported": "Ta aplikacja wymaga YunoHost >= {required}, ale aktualnie zainstalowana wersja to {current}",
+ "apps_already_up_to_date": "Wszystkie aplikacje są już aktualne",
+ "backup_archive_system_part_not_available": "Część systemowa '{part}' jest niedostępna w tej kopii zapasowej",
+ "backup_custom_backup_error": "Niestandardowa metoda tworzenia kopii zapasowej nie mogła przejść kroku 'backup'.",
+ "app_argument_password_no_default": "Błąd podczas analizowania argumentu hasła „{name}”: argument hasła nie może mieć wartości domyślnej ze względów bezpieczeństwa",
+ "app_sources_fetch_failed": "Nie można pobrać plików źródłowych, czy adres URL jest poprawny?",
+ "app_manifest_install_ask_init_admin_permission": "Kto powinien mieć dostęp do funkcji administracyjnych tej aplikacji? (Można to później zmienić)",
+ "app_manifest_install_ask_init_main_permission": "Kto powinien mieć dostęp do tej aplikacji? (Można to później zmienić)",
+ "ask_admin_fullname": "Pełne imię i nazwisko administratora",
+ "app_change_url_failed": "Nie udało się zmienić adresu URL aplikacji {app}: {error}",
+ "app_change_url_script_failed": "Wystąpił błąd w skrypcie zmiany adresu URL",
+ "app_failed_to_upgrade_but_continue": "Nie udało zaktualizować się aplikacji {failed_app}, przechodzenie do następnych aktualizacji według żądania. Uruchom komendę 'yunohost log show {operation_logger_name}', aby sprawdzić logi dotyczące błędów",
+ "certmanager_cert_signing_failed": "Nie udało się zarejestrować nowego certyfikatu",
+ "certmanager_cert_renew_success": "Pomyślne odnowienie certyfikatu Let's Encrypt dla domeny '{domain}'",
+ "backup_delete_error": "Nie udało się usunąć '{path}'",
+ "certmanager_attempt_to_renew_nonLE_cert": "Certyfikat dla domeny '{domain}' nie został wystawiony przez Let's Encrypt. Automatyczne odnowienie jest niemożliwe!",
+ "backup_archive_cant_retrieve_info_json": "Nieudane wczytanie informacji dla archiwum '{archive}'... Plik info.json nie może zostać odzyskany (lub jest niepoprawny).",
+ "backup_method_custom_finished": "Tworzenie kopii zapasowej według własnej metody '{method}' zakończone",
+ "backup_nothings_done": "Brak danych do zapisania",
+ "app_unsupported_remote_type": "Niewspierany typ zdalny użyty w aplikacji",
+ "backup_archive_name_unknown": "Nieznane, lokalne archiwum kopii zapasowej o nazwie '{name}'",
+ "backup_output_directory_not_empty": "Należy wybrać pusty katalog dla danych wyjściowych",
+ "certmanager_attempt_to_renew_valid_cert": "Certyfikat dla domeny '{domain}' nie jest bliski wygaśnięciu! (Możesz użyć komendy z dopiskiem --force jeśli wiesz co robisz)",
+ "certmanager_cert_install_success": "Pomyślna instalacja certyfikatu Let's Encrypt dla domeny '{domain}'",
+ "certmanager_attempt_to_replace_valid_cert": "Właśnie zamierzasz nadpisać dobry i poprawny certyfikat dla domeny '{domain}'! (Użyj komendy z dopiskiem --force, aby ominąć)",
+ "backup_method_copy_finished": "Zakończono tworzenie kopii zapasowej",
+ "certmanager_certificate_fetching_or_enabling_failed": "Próba użycia nowego certyfikatu dla {domain} zakończyła się niepowodzeniem...",
+ "backup_method_tar_finished": "Utworzono archiwum kopii zapasowej TAR",
+ "backup_mount_archive_for_restore": "Przygotowywanie archiwum do przywrócenia...",
+ "certmanager_cert_install_failed": "Nieudana instalacja certyfikatu Let's Encrypt dla {domains}",
+ "certmanager_cert_install_failed_selfsigned": "Nieudana instalacja certyfikatu self-signed dla {domains}",
+ "certmanager_cert_install_success_selfsigned": "Pomyślna instalacja certyfikatu self-signed dla domeny '{domain}'",
+ "certmanager_cert_renew_failed": "Nieudane odnowienie certyfikatu Let's Encrypt dla {domains}",
+ "apps_failed_to_upgrade": "Nieudana aktualizacja aplikacji: {apps}",
+ "backup_output_directory_required": "Musisz wybrać katalog dla kopii zapasowej",
+ "app_failed_to_download_asset": "Nie udało się pobrać zasobu '{source_id}' ({url}) dla {app}: {out}",
+ "backup_with_no_backup_script_for_app": "Aplikacja '{app}' nie posiada skryptu kopii zapasowej. Ignorowanie.",
+ "backup_with_no_restore_script_for_app": "Aplikacja {app} nie posiada skryptu przywracania, co oznacza, że nie będzie można automatycznie przywrócić kopii zapasowej tej aplikacji.",
+ "certmanager_acme_not_configured_for_domain": "Wyzwanie ACME nie może zostać uruchomione dla domeny {domain}, ponieważ jej konfiguracja nginx nie zawiera odpowiedniego fragmentu kodu... Upewnij się, że konfiguracja nginx jest aktualna, używając polecenia yunohost tools regen-conf nginx --dry-run --with-diff.",
+ "certmanager_domain_dns_ip_differs_from_public_ip": "Rekordy DNS dla domeny '{domain}' różnią się od adresu IP tego serwera. Sprawdź kategorię 'Rekordy DNS' (podstawowe) w diagnozie, aby uzyskać więcej informacji. Jeśli niedawno dokonałeś zmiany rekordu A, poczekaj, aż zostanie on zaktualizowany (można skorzystać z narzędzi online do sprawdzania propagacji DNS). (Jeśli wiesz, co robisz, użyj opcji '--no-checks', aby wyłączyć te sprawdzania.)",
+ "confirm_app_install_danger": "UWAGA! Ta aplikacja jest wciąż w fazie eksperymentalnej (jeśli nie działa jawnie)! Prawdopodobnie NIE powinieneś jej instalować, chyba że wiesz, co robisz. NIE ZOSTANIE udzielone wsparcie, jeśli ta aplikacja nie będzie działać poprawnie lub spowoduje uszkodzenie systemu... Jeśli mimo to jesteś gotów podjąć to ryzyko, wpisz '{answers}",
+ "confirm_app_install_thirdparty": "UWAGA! Ta aplikacja nie jest częścią katalogu aplikacji YunoHost. Instalowanie aplikacji innych firm może naruszyć integralność i bezpieczeństwo systemu. Prawdopodobnie NIE powinieneś jej instalować, chyba że wiesz, co robisz. NIE ZOSTANIE udzielone wsparcie, jeśli ta aplikacja nie będzie działać poprawnie lub spowoduje uszkodzenie systemu... Jeśli mimo to jesteś gotów podjąć to ryzyko, wpisz '{answers}'",
+ "config_apply_failed": "Nie udało się zastosować nowej konfiguracji: {error}",
+ "config_cant_set_value_on_section": "Nie można ustawić pojedynczej wartości dla całej sekcji konfiguracji.",
+ "config_no_panel": "Nie znaleziono panelu konfiguracji.",
+ "config_unknown_filter_key": "Klucz filtru '{filter_key}' jest niepoprawny.",
+ "config_validate_email": "Proszę podać poprawny adres e-mail",
+ "backup_hook_unknown": "Nieznany jest hook kopii zapasowej '{hook}'.",
+ "backup_no_uncompress_archive_dir": "Nie istnieje taki katalog nieskompresowanego archiwum.",
+ "backup_output_symlink_dir_broken": "Twój katalog archiwum '{path}' to uszkodzony dowiązanie symboliczne. Być może zapomniałeś o ponownym zamontowaniu lub podłączeniu nośnika przechowującego, do którego on wskazuje.",
+ "backup_system_part_failed": "Nie można wykonać kopii zapasowej części systemu '{part}'",
+ "config_validate_color": "Powinien być poprawnym szesnastkowym kodem koloru RGB.",
+ "config_validate_date": "Data powinna być poprawna w formacie RRRR-MM-DD",
+ "config_validate_time": "Podaj poprawny czas w formacie GG:MM",
+ "certmanager_domain_not_diagnosed_yet": "Nie ma jeszcze wyników diagnozy dla domeny {domain}. Proszę ponownie uruchomić diagnozę dla kategorii 'Rekordy DNS' i 'Strona internetowa' w sekcji diagnozy, aby sprawdzić, czy domena jest gotowa do użycia Let's Encrypt. (Jeśli wiesz, co robisz, użyj opcji '--no-checks', aby wyłączyć te sprawdzania.)",
+ "certmanager_cannot_read_cert": "Wystąpił problem podczas próby otwarcia bieżącego certyfikatu dla domeny {domain} (plik: {file}), przyczyna: {reason}",
+ "certmanager_no_cert_file": "Nie można odczytać pliku certyfikatu dla domeny {domain} (plik: {file}).",
+ "certmanager_self_ca_conf_file_not_found": "Nie można znaleźć pliku konfiguracyjnego dla autorytetu samopodpisującego (plik: {file})",
+ "backup_running_hooks": "Uruchamianie hooków kopii zapasowej...",
+ "backup_permission": "Uprawnienia kopii zapasowej dla aplikacji {app}",
+ "certmanager_domain_cert_not_selfsigned": "Certyfikat dla domeny {domain} nie jest samopodpisany. Czy na pewno chcesz go zastąpić? (Użyj opcji '--force', aby to zrobić.)",
+ "config_action_disabled": "Nie można uruchomić akcji '{action}', ponieważ jest ona wyłączona. Upewnij się, że spełnione są jej ograniczenia. Pomoc: {help}",
+ "config_action_failed": "Nie udało się uruchomić akcji '{action}': {error}",
+ "config_forbidden_readonly_type": "Typ '{type}' nie może być ustawiony jako tylko do odczytu. Użyj innego typu, aby wyświetlić tę wartość (odpowiednie ID argumentu: '{id}')",
+ "config_forbidden_keyword": "Słowo kluczowe '{keyword}' jest zastrzeżone. Nie można tworzyć ani używać panelu konfiguracji z pytaniem o tym identyfikatorze.",
+ "backup_output_directory_forbidden": "Wybierz inną ścieżkę docelową. Kopie zapasowe nie mogą być tworzone w podfolderach /bin, /boot, /dev, /etc, /lib, /root, /run, /sbin, /sys, /usr, /var ani /home/yunohost.backup/archives",
+ "confirm_app_insufficient_ram": "UWAGA! Ta aplikacja wymaga {required} pamięci RAM do zainstalowania/aktualizacji, a obecnie dostępne jest tylko {current}. Nawet jeśli aplikacja mogłaby działać, proces instalacji/aktualizacji wymaga dużej ilości pamięci RAM, więc serwer może się zawiesić i niepowodzenie może być katastrofalne. Jeśli mimo to jesteś gotów podjąć to ryzyko, wpisz '{answers}'",
+ "app_not_upgraded_broken_system": "Aplikacja '{failed_app}' nie powiodła się w procesie aktualizacji i spowodowała uszkodzenie systemu. W rezultacie anulowane zostały aktualizacje następujących aplikacji: {apps}",
+ "app_not_upgraded_broken_system_continue": "Aplikacja '{failed_app}' nie powiodła się w procesie aktualizacji i spowodowała uszkodzenie systemu (parametr --continue-on-failure jest ignorowany). W rezultacie anulowane zostały aktualizacje następujących aplikacji: {apps}",
+ "certmanager_domain_http_not_working": "Domena {domain} wydaje się niedostępna przez HTTP. Sprawdź kategorię 'Strona internetowa' diagnostyki, aby uzyskać więcej informacji. (Jeśli wiesz, co robisz, użyj opcji '--no-checks', aby wyłączyć te sprawdzania.)",
+ "migration_0021_system_not_fully_up_to_date": "Twój system nie jest w pełni zaktualizowany! Proszę, wykonaj zwykłą aktualizację oprogramowania zanim rozpoczniesz migrację na system Bullseye.",
+ "global_settings_setting_smtp_relay_port": "Port przekaźnika SMTP",
+ "domain_config_cert_renew": "Odnów certyfikat Let's Encrypt",
+ "root_password_changed": "Hasło root zostało zmienione",
+ "diagnosis_services_running": "Usługa {service} działa!",
+ "global_settings_setting_admin_strength": "Wymogi dotyczące siły hasła administratora",
+ "global_settings_setting_admin_strength_help": "Wymagania te są egzekwowane tylko podczas inicjalizacji lub zmiany hasła",
+ "global_settings_setting_pop3_enabled_help": "Włącz protokołu POP3 dla serwera poczty",
+ "global_settings_setting_postfix_compatibility": "Kompatybilność Postfix",
+ "global_settings_setting_smtp_relay_user": "Nazwa użytkownika przekaźnika SMTP",
+ "global_settings_setting_ssh_password_authentication_help": "Zezwól na logowanie hasłem przez SSH",
+ "diagnosis_apps_allgood": "Wszystkie zainstalowane aplikacje są zgodne z podstawowymi zasadami pakowania",
+ "diagnosis_basesystem_hardware": "Architektura sprzętowa serwera to {virt} {arch}",
+ "diagnosis_ip_connected_ipv4": "Serwer jest połączony z Internet z użyciem IPv4!",
+ "diagnosis_ip_no_ipv6": "Serwer nie ma działającego połączenia z użyciem IPv6.",
+ "diagnosis_http_hairpinning_issue": "Wygląda na to, że sieć lokalna nie ma \"hairpinning\".",
+ "backup_unable_to_organize_files": "Nie można użyć szybkiej metody porządkowania plików w archiwum",
+ "log_letsencrypt_cert_renew": "Odnów '{}' certyfikat Let's Encrypt",
+ "global_settings_setting_passwordless_sudo": "Umożliw administratorom korzystania z 'sudo' bez konieczności ponownego wpisywania hasła",
+ "global_settings_setting_smtp_relay_enabled": "Włącz przekaźnik SMTP",
+ "global_settings_setting_smtp_relay_host": "Host przekaźnika SMTP",
+ "global_settings_setting_user_strength": "Wymagania dotyczące siły hasła użytkownika",
+ "domain_config_mail_in": "Odbieranie maili",
+ "global_settings_setting_webadmin_allowlist_enabled_help": "Zezwól tylko kilku adresom IP na dostęp do panelu webadmin.",
+ "diagnosis_basesystem_kernel": "Serwer działa pod kontrolą jądra Linuksa {kernel_version}",
+ "diagnosis_dns_good_conf": "Rekordy DNS zostały poprawnie skonfigurowane dla domeny {domain} (category {category})",
+ "diagnosis_ram_ok": "System nadal ma {available} ({available_percent}%) wolnej pamięci RAM z całej puli {total}.",
+ "diagnosis_http_ok": "Domena {domain} jest dostępna przez HTTP z poziomu sieci zewnętrznej.",
+ "diagnosis_swap_tip": "Pamiętaj, że wykorzystywanie partycji swap na karcie pamięci SD lub na dysku SSD może znacznie skrócić czas działania tego urządzenia.",
+ "diagnosis_basesystem_host": "Serwer działa pod kontrolą systemu Debian {debian_version}",
+ "diagnosis_basesystem_ynh_main_version": "Serwer działa pod kontrolą oprogramowania YunoHost {main_version} ({repo})",
+ "diagnosis_diskusage_verylow": "Przestrzeń {mountpoint}
(na dysku {device}
) ma tylko {free} ({free_percent}%) wolnego miejsca z całej puli {total}! Rozważ pozbycie się niepotrzebnych plików!",
+ "global_settings_setting_root_password": "Nowe hasło root",
+ "global_settings_setting_root_password_confirm": "Powtórz nowe hasło root",
+ "global_settings_setting_security_experimental_enabled": "Eksperymentalne funkcje bezpieczeństwa",
+ "global_settings_setting_smtp_relay_password": "Hasło przekaźnika SMTP",
+ "global_settings_setting_user_strength_help": "Wymagania te są egzekwowane tylko podczas inicjalizacji lub zmiany hasła",
+ "global_settings_setting_webadmin_allowlist_enabled": "Włącz listę dozwolonych adresów IP dla panelu webadmin",
+ "root_password_desynchronized": "Hasło administratora zostało zmienione, ale YunoHost nie mógł wykorzystać tego hasła jako hasło root!",
+ "service_already_started": "Usługa '{service}' już jest włączona",
+ "diagnosis_ip_dnsresolution_working": "Rozpoznawanie nazw domen działa!",
+ "diagnosis_regenconf_manually_modified": "Wygląda na to, że plik konfiguracyjny {file}
został zmodyfikowany ręcznie.",
+ "diagnosis_diskusage_ok": "Przestrzeń {mountpoint}
(na dysku {device}
) nadal ma {free} ({free_percent}%) wolnego miejsca z całej puli {total}!",
+ "diagnosis_diskusage_low": "Przestrzeń {mountpoint}
(na dysku {device}
) ma tylko {free} ({free_percent}%) wolnego miejsca z całej puli {total}! Uważaj na możliwe zapełnienie dysku w bliskiej przyszłości.",
+ "diagnosis_ip_connected_ipv6": "Serwer nie jest połączony z internetem z użyciem IPv6!",
+ "global_settings_setting_smtp_relay_enabled_help": "Włączenie przekaźnika SMTP, który ma być używany do wysyłania poczty zamiast tej instancji yunohost może być przydatne, jeśli znajdujesz się w jednej z następujących sytuacji: Twój port 25 jest zablokowany przez dostawcę usług internetowych lub dostawcę VPS, masz adres IP zamieszkania wymieniony w DUHL, nie jesteś w stanie skonfigurować odwrotnego DNS lub ten serwer nie jest bezpośrednio widoczny w Internecie i chcesz użyć innego do wysyłania wiadomości e-mail.",
+ "global_settings_setting_backup_compress_tar_archives_help": "Podczas tworzenia nowych kopii zapasowych archiwa będą skompresowane (.tar.gz), a nie nieskompresowane jak dotychczas (.tar). Uwaga: włączenie tej opcji oznacza tworzenie mniejszych archiwów kopii zapasowych, ale początkowa procedura tworzenia kopii zapasowej będzie znacznie dłuższa i mocniej obciąży procesor.",
+ "domain_config_mail_out": "Wysyłanie maili",
+ "domain_dns_registrar_supported": "YunoHost automatycznie wykrył, że ta domena jest obsługiwana przez rejestratora **{registrar}**. Jeśli chcesz, YunoHost automatycznie skonfiguruje rekordy DNS, ale musisz podać odpowiednie dane uwierzytelniające API. Dokumentację dotyczącą uzyskiwania poświadczeń API można znaleźć na tej stronie: https://yunohost.org/registar_api_{registrar}. (Można również ręcznie skonfigurować rekordy DNS zgodnie z dokumentacją na stronie https://yunohost.org/dns )",
+ "domain_config_cert_summary_letsencrypt": "Świetnie! Wykorzystujesz właściwy certyfikaty Let's Encrypt!",
+ "global_settings_setting_portal_theme": "Motyw portalu",
+ "global_settings_setting_portal_theme_help": "Więcej informacji na temat tworzenia niestandardowych motywów portalu można znaleźć na stronie https://yunohost.org/theming",
+ "global_settings_setting_dns_exposure": "Wersje IP do uwzględnienia w konfiguracji i diagnostyce DNS",
+ "domain_config_auth_token": "Token uwierzytelniający",
+ "global_settings_setting_dns_exposure_help": "Uwaga: Ma to wpływ tylko na zalecaną konfigurację DNS i kontrole diagnostyczne. Nie ma to wpływu na konfigurację systemu.",
+ "global_settings_setting_security_experimental_enabled_help": "Uruchom eksperymentalne funkcje bezpieczeństwa (nie włączaj, jeśli nie wiesz co robisz!)",
+ "global_settings_setting_smtp_allow_ipv6_help": "Zezwól na wykorzystywanie IPv7 do odbierania i wysyłania maili",
+ "global_settings_setting_ssh_password_authentication": "Logowanie hasłem",
+ "diagnosis_backports_in_sources_list": "Wygląda na to że apt (menedżer pakietów) został skonfigurowany tak, aby wykorzystywać repozytorium backported. Nie zalecamy wykorzystywania repozytorium backported, ponieważ może powodować problemy ze stabilnością i/lub konflikty z konfiguracją. No chyba, że wiesz co robisz.",
+ "domain_config_xmpp_help": "Uwaga: niektóre funkcje XMPP będą wymagały aktualizacji rekordów DNS i odnowienia certyfikatu Lets Encrypt w celu ich włączenia"
+}
diff --git a/locales/pt.json b/locales/pt.json
index 6b462bb6f..0aa6b8223 100644
--- a/locales/pt.json
+++ b/locales/pt.json
@@ -1,8 +1,6 @@
{
- "action_invalid": "Acção Inválida '{action}'",
+ "action_invalid": "Ação inválida '{action}'",
"admin_password": "Senha de administração",
- "admin_password_change_failed": "Não foi possível alterar a senha",
- "admin_password_changed": "A senha da administração foi alterada",
"app_already_installed": "{app} já está instalada",
"app_extraction_failed": "Não foi possível extrair os arquivos para instalação",
"app_id_invalid": "App ID invaĺido",
@@ -13,8 +11,6 @@
"app_unknown": "Aplicação desconhecida",
"app_upgrade_failed": "Não foi possível atualizar {app}: {error}",
"app_upgraded": "{app} atualizado",
- "ask_firstname": "Primeiro nome",
- "ask_lastname": "Último nome",
"ask_main_domain": "Domínio principal",
"ask_new_admin_password": "Nova senha de administração",
"ask_password": "Senha",
@@ -123,14 +119,13 @@
"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",
- "admin_password_too_long": "Escolha uma senha que contenha menos de 127 caracteres",
"aborting": "Abortando.",
"app_change_url_no_script": "A aplicação '{app_name}' ainda não permite modificar a URL. Talvez devesse atualizá-la.",
"app_argument_password_no_default": "Erro ao interpretar argumento da senha '{name}': O argumento da senha não pode ter um valor padrão por segurança",
"app_action_cannot_be_ran_because_required_services_down": "Estes serviços devem estar funcionado para executar esta ação: {services}. Tente reiniciá-los para continuar (e possivelmente investigar o porquê de não estarem funcionado).",
"app_action_broke_system": "Esta ação parece ter quebrado estes serviços importantes: {services}",
"already_up_to_date": "Nada a ser feito. Tudo já está atualizado.",
- "additional_urls_already_removed": "A URL adicional '{url}'já está removida para a permissão '{permission}'",
+ "additional_urls_already_removed": "A URL adicional '{url}' já foi removida da permissão '{permission}'",
"additional_urls_already_added": "A URL adicional '{url}' já está adicionada para a permissão '{permission}'",
"app_install_script_failed": "Ocorreu um erro dentro do script de instalação do aplicativo",
"app_install_failed": "Não foi possível instalar {app}: {error}",
@@ -151,7 +146,6 @@
"app_restore_script_failed": "Ocorreu um erro dentro do script de restauração da aplicação",
"app_restore_failed": "Não foi possível restaurar {app}: {error}",
"app_remove_after_failed_install": "Removendo a aplicação após a falha da instalação...",
- "app_requirements_unmeet": "Os requisitos para a aplicação {app} não foram satisfeitos, o pacote {pkgname} ({version}) devem ser {spec}",
"app_not_upgraded": "Não foi possível atualizar a aplicação '{failed_app}' e, como consequência, a atualização das seguintes aplicações foi cancelada: {apps}",
"app_manifest_install_ask_is_public": "Essa aplicação deve ser visível para visitantes anônimos?",
"app_manifest_install_ask_admin": "Escolha um usuário de administrador para essa aplicação",
@@ -210,7 +204,6 @@
"config_cant_set_value_on_section": "Você não pode setar um único valor na seção de configuração inteira.",
"config_validate_time": "Deve ser um horário válido como HH:MM",
"config_validate_url": "Deve ser uma URL válida",
- "config_version_not_supported": "Versões do painel de configuração '{version}' não são suportadas.",
"danger": "Perigo:",
"diagnosis_basesystem_ynh_inconsistent_versions": "Você está executando versões inconsistentes dos pacotes YunoHost... provavelmente por causa de uma atualização parcial ou que falhou.",
"diagnosis_description_basesystem": "Sistema base",
@@ -251,5 +244,6 @@
"diagnosis_basesystem_hardware_model": "O modelo do servidor é {model}",
"diagnosis_backports_in_sources_list": "Parece que o apt (o gerenciador de pacotes) está configurado para usar o repositório backport. A não ser que você saiba o que você esteá fazendo, desencorajamos fortemente a instalação de pacotes de backports porque é provável que crie instabilidades ou conflitos no seu sistema.",
"certmanager_cert_renew_success": "Certificado Let's Encrypt renovado para o domínio '{domain}'",
- "certmanager_warning_subdomain_dns_record": "O subdomínio '{subdomain}' não resolve para o mesmo IP que '{domain}'. Algumas funcionalidades não estarão disponíveis até que você conserte isto e regenere o certificado."
+ "certmanager_warning_subdomain_dns_record": "O subdomínio '{subdomain}' não resolve para o mesmo IP que '{domain}'. Algumas funcionalidades não estarão disponíveis até que você conserte isto e regenere o certificado.",
+ "admins": "Admins"
}
\ No newline at end of file
diff --git a/locales/pt_BR.json b/locales/pt_BR.json
new file mode 100644
index 000000000..9e26dfeeb
--- /dev/null
+++ b/locales/pt_BR.json
@@ -0,0 +1 @@
+{}
\ No newline at end of file
diff --git a/locales/ru.json b/locales/ru.json
index 1546c4d6e..a9c9da3f1 100644
--- a/locales/ru.json
+++ b/locales/ru.json
@@ -1,8 +1,6 @@
{
"action_invalid": "Неверное действие '{action}'",
"admin_password": "Пароль администратора",
- "admin_password_change_failed": "Невозможно изменить пароль",
- "admin_password_changed": "Пароль администратора был изменен",
"app_already_installed": "{app} уже установлено",
"app_already_installed_cant_change_url": "Это приложение уже установлено. URL не может быть изменен только с помощью этой функции. Изучите `app changeurl`, если это доступно.",
"app_argument_choice_invalid": "Выберите корректное значение аргумента '{name}'; '{value}' не входит в число возможных вариантов: '{choices}'",
@@ -29,7 +27,6 @@
"app_upgraded": "{app} обновлено",
"installation_complete": "Установка завершена",
"password_too_simple_1": "Пароль должен быть не менее 8 символов",
- "admin_password_too_long": "Пожалуйста, выберите пароль короче 127 символов",
"password_listed": "Этот пароль является одним из наиболее часто используемых паролей в мире. Пожалуйста, выберите что-то более уникальное.",
"backup_applying_method_copy": "Копирование всех файлов в резервную копию...",
"domain_dns_conf_is_just_a_recommendation": "Эта страница показывает вам *рекомендуемую* конфигурацию. Она не создаёт для вас конфигурацию DNS. Вы должны сами конфигурировать DNS у вашего регистратора в соответствии с этой рекомендацией.",
@@ -37,13 +34,11 @@
"password_too_simple_3": "Пароль должен содержать не менее 8 символов и содержать цифры, заглавные и строчные буквы, а также специальные символы",
"upnp_enabled": "UPnP включен",
"user_deleted": "Пользователь удалён",
- "ask_lastname": "Фамилия",
"app_action_broke_system": "Это действие, по-видимому, нарушило эти важные службы: {services}",
"already_up_to_date": "Ничего делать не требуется. Всё уже обновлено.",
"operation_interrupted": "Действие было прервано вручную?",
"user_created": "Пользователь создан",
"aborting": "Прерывание.",
- "ask_firstname": "Имя",
"ask_main_domain": "Основной домен",
"ask_new_admin_password": "Новый пароль администратора",
"ask_new_domain": "Новый домен",
@@ -74,7 +69,6 @@
"yunohost_already_installed": "YunoHost уже установлен",
"yunohost_configured": "Теперь YunoHost настроен",
"upgrading_packages": "Обновление пакетов...",
- "app_requirements_unmeet": "Необходимые требования для {app} не выполнены, пакет {pkgname} ({version}) должен быть {spec}",
"app_make_default_location_already_used": "Невозможно сделать '{app}' приложением по умолчанию на домене, '{domain}' уже используется '{other_app}'",
"app_config_unable_to_apply": "Не удалось применить значения панели конфигурации.",
"app_config_unable_to_read": "Не удалось прочитать значения панели конфигурации.",
@@ -112,7 +106,6 @@
"certmanager_domain_dns_ip_differs_from_public_ip": "DNS-записи для домена '{domain}' отличаются от IP этого сервера. Пожалуйста, проверьте категорию 'DNS-записи' (основные) в диагностике для получения дополнительной информации. Если вы недавно изменили свою A-запись, пожалуйста, подождите, пока она распространится (некоторые программы проверки распространения DNS доступны в интернете). (Если вы знаете, что делаете, используйте '--no-checks', чтобы отключить эти проверки.)",
"certmanager_domain_not_diagnosed_yet": "Для домена {domain} еще нет результатов диагностики. Пожалуйста, перезапустите диагностику для категорий 'DNS-записи' и 'Домены', чтобы проверить, готов ли домен к Let's Encrypt. (Или, если вы знаете, что делаете, используйте '--no-checks', чтобы отключить эти проверки.)",
"config_validate_url": "Должна быть правильная ссылка",
- "config_version_not_supported": "Версии конфигурационной панели '{version}' не поддерживаются.",
"confirm_app_install_danger": "ОПАСНО! Это приложение все еще является экспериментальным (если не сказать, что оно явно не работает)! Вам НЕ следует устанавливать его, если вы НЕ знаете, что делаете. Если это приложение не будет работать или сломает вашу систему, мы НЕ будем оказывать техническую поддержку... Если вы все равно готовы рискнуть, введите '{answers}'",
"confirm_app_install_thirdparty": "ВАЖНО! Это приложение не входит в каталог приложений YunoHost. Установка сторонних приложений может нарушить целостность и безопасность вашей системы. Вам НЕ следует устанавливать его, если вы НЕ знаете, что делаете. Если это приложение не будет работать или сломает вашу систему, мы НЕ будем оказывать техническую поддержку... Если вы все равно готовы рискнуть, введите '{answers}'",
"config_apply_failed": "Не удалось применить новую конфигурацию: {error}",
@@ -238,7 +231,6 @@
"regenconf_file_removed": "Файл конфигурации '{conf}' удален",
"permission_not_found": "Разрешение '{permission}' не найдено",
"group_cannot_edit_all_users": "Группа 'all_users' не может быть отредактирована вручную. Это специальная группа, предназначенная для всех пользователей, зарегистрированных в YunoHost",
- "global_settings_setting_smtp_allow_ipv6": "Разрешить использование IPv6 для получения и отправки почты",
"log_dyndns_subscribe": "Подписаться на субдомен YunoHost '{}'",
"pattern_firstname": "Должно быть настоящее имя",
"migrations_pending_cant_rerun": "Эти миграции еще не завершены, поэтому не могут быть запущены снова: {ids}",
@@ -264,13 +256,10 @@
"log_backup_create": "Создание резервной копии",
"group_update_failed": "Не удалось обновить группу '{group}': {error}",
"permission_already_allowed": "В группе '{group}' уже включено разрешение '{permission}'",
- "invalid_password": "Неверный пароль",
"group_already_exist": "Группа {group} уже существует",
"group_cannot_be_deleted": "Группа {group} не может быть удалена вручную.",
"log_app_config_set": "Примените конфигурацию приложения '{}'",
"log_backup_restore_app": "Восстановление '{}' из резервной копии",
- "global_settings_setting_security_webadmin_allowlist": "IP-адреса, разрешенные для доступа к веб-интерфейсу администратора. Разделенные запятыми.",
- "global_settings_setting_security_webadmin_allowlist_enabled": "Разрешите доступ к веб-интерфейсу администратора только некоторым IP-адресам.",
"log_domain_remove": "Удалить домен '{}' из конфигурации системы",
"user_import_success": "Пользователи успешно импортированы",
"group_user_already_in_group": "Пользователь {user} уже входит в группу {group}",
@@ -284,7 +273,6 @@
"diagnosis_sshd_config_inconsistent_details": "Пожалуйста, выполните /etc/resolv.conf
neukazuje na 127.0.0.1
.",
+ "diagnosis_ip_connected_ipv4": "Server nie je pripojený k internetu prostredníctvom IPv4!",
+ "diagnosis_ip_connected_ipv6": "Server nie je pripojený k internetu prostredníctvom IPv6!",
+ "diagnosis_ip_dnsresolution_working": "Preklad názvov domén nefunguje!",
+ "diagnosis_ip_global": "Globálna IP adresa: {global}
",
+ "diagnosis_ip_local": "Miestna IP adresa: {local}
",
+ "diagnosis_ip_no_ipv4": "Na serveri nefunguje spojenie cez protokol IPv4.",
+ "diagnosis_ip_no_ipv6": "Na serveri nefunguje spojenie cez protokol IPv6.",
+ "diagnosis_ip_not_connected_at_all": "Zdá sa, že tento server nie je vôbec pripojený k internetu!?",
+ "diagnosis_ip_weird_resolvconf": "Zdá sa, že preklad názvov domén funguje, ale podľa všetkého používate vlastný súbor /etc/resolv.conf
.",
+ "root_password_desynchronized": "Heslo pre správu bolo zmenené, ale YunoHost nedokázal túto zmenu premietnuť do hesla používateľa root!",
+ "main_domain_changed": "Hlavná doména bola zmenená",
+ "user_updated": "Informácie o používateľovi boli zmenené",
+ "diagnosis_ram_verylow": "Systém má iba {available} ({available_percent} %) dostupnej pamäte RAM (z celkovej pamäte {total})!",
+ "diagnosis_mail_queue_unavailable_details": "Chyba: {error}",
+ "diagnosis_ram_ok": "Systém má ešte {available} ({available_percent} %) dostupnej pamäte RAM (z celkovej pamäte {total}).",
+ "diagnosis_ram_low": "Systém má {available} ({available_percent} %) dostupnej pamäte RAM (z celkovej pamäte {total}). Buďte opatrný.",
+ "diagnosis_sshd_config_inconsistent": "Zdá sa, že port SSH bol manuálne upravený v /etc/ssh/sshd_config. Od YunoHost 4.2 je dostupné nové globálne nastavenie 'security.ssh.port', aby ste nemuseli konfiguráciu editovať ručne.",
+ "domains_available": "Dostupné domény:",
+ "dyndns_could_not_check_available": "Nepodarilo sa zistiť, či je {domain} dostupná na {provider}.",
+ "dyndns_unavailable": "Doména '{domain}' nie je dostupná.",
+ "log_available_on_yunopaste": "Tento záznam je teraz dostupný na {url}",
+ "updating_apt_cache": "Získavam dostupné aktualizácie systémových balíčkov…",
+ "admins": "Správcovia",
+ "app_action_failed": "Nepodarilo sa spustiť akciu {action} v aplikácii {app}",
+ "app_manifest_install_ask_init_admin_permission": "Kto má mať prístup k nastaveniam určených správcovi tejto aplikácie? (Nastavenie môžete neskôr zmeniť)",
+ "ask_admin_fullname": "Celé meno správcu",
+ "ask_admin_username": "Používateľské meno správcu",
+ "ask_fullname": "Celé meno",
+ "all_users": "Všetci používatelia YunoHost",
+ "app_manifest_install_ask_init_main_permission": "Kto má mať prístup k tejto aplikácii? (Nastavenie môžete neskôr zmeniť)",
+ "certmanager_cert_install_failed": "Inštalácia Let's Encrypt certifikátu pre {domains} skončila s chybou",
+ "app_arch_not_supported": "Túto aplikáciu možno nainštalovať iba na architektúrach {required}, ale Váš server beží na architektúre {current}",
+ "log_help_to_get_failed_log": "Akciu '{desc}' sa nepodarilo dokončiť. Ak potrebujete pomoc, zdieľajte, prosím, úplný záznam tejto operácie pomocou príkazu 'yunohost log share {name}'",
+ "operation_interrupted": "Bola akcia manuálne prerušená?",
+ "log_link_to_failed_log": "Akciu '{desc}' sa nepodarilo dokončiť. Ak potrebujete pomoc, poskytnite, prosím, úplný záznam tejto operácie kliknutím sem",
+ "app_change_url_failed": "Nepodarilo sa zmeniť URL adresu aplikácie {app}: {error}",
+ "app_yunohost_version_not_supported": "Táto aplikácia vyžaduje YunoHost >= {required}, ale aktuálne nainštalovaná verzia je {current}",
+ "config_action_failed": "Nepodarilo sa spustiť operáciu '{action}': {error}",
+ "app_change_url_script_failed": "Vo skripte na zmenu URL adresy sa vyskytla chyba",
+ "app_not_enough_disk": "Táto aplikácia vyžaduje {required} voľného miesta.",
+ "app_not_enough_ram": "Táto aplikácia vyžaduje {required} pamäte na inštaláciu/aktualizáciu, ale k dispozícii je momentálne iba {current}.",
+ "apps_failed_to_upgrade": "Nasledovné aplikácie nebolo možné aktualizovať: {apps}"
}
diff --git a/locales/sv.json b/locales/sv.json
index 39707d07c..6382612cd 100644
--- a/locales/sv.json
+++ b/locales/sv.json
@@ -3,9 +3,6 @@
"app_action_broke_system": "Åtgärden verkar ha fått följande viktiga tjänster att haverera: {services}",
"already_up_to_date": "Ingenting att göra. Allt är redan uppdaterat.",
"admin_password": "Administratörslösenord",
- "admin_password_too_long": "Välj gärna ett lösenord som inte innehåller fler än 127 tecken",
- "admin_password_change_failed": "Kan inte byta lösenord",
"action_invalid": "Ej tillåten åtgärd '{action}'",
- "admin_password_changed": "Administratörskontots lösenord ändrades",
"aborting": "Avbryter."
}
\ No newline at end of file
diff --git a/locales/te.json b/locales/te.json
index fa6ac91c8..7a06f88ef 100644
--- a/locales/te.json
+++ b/locales/te.json
@@ -3,16 +3,42 @@
"action_invalid": "చెల్లని చర్య '{action}'",
"additional_urls_already_removed": "'{permission}' అనుమతి కొరకు అదనపు URLలో అదనంగా URL '{url}' ఇప్పటికే జోడించబడింది",
"admin_password": "అడ్మినిస్ట్రేషన్ పాస్వర్డ్",
- "admin_password_changed": "అడ్మినిస్ట్రేషన్ పాస్వర్డ్ మార్చబడింది",
"already_up_to_date": "చేయడానికి ఏమీ లేదు. ప్రతిదీ ఎప్పటికప్పుడు తాజాగా ఉంది.",
"app_already_installed": "{app} ఇప్పటికే ఇన్స్టాల్ చేయబడింది",
"app_already_up_to_date": "{app} ఇప్పటికే అప్-టూ-డేట్గా ఉంది",
"app_argument_invalid": "ఆర్గ్యుమెంట్ '{name}' కొరకు చెల్లుబాటు అయ్యే వైల్యూ ఎంచుకోండి: {error}",
"additional_urls_already_added": "'{permission}' అనుమతి కొరకు అదనపు URLలో అదనంగా URL '{url}' ఇప్పటికే జోడించబడింది",
- "admin_password_change_failed": "అనుమతిపదాన్ని మార్చడం సాధ్యం కాదు",
- "admin_password_too_long": "దయచేసి 127 క్యారెక్టర్ల కంటే చిన్న పాస్వర్డ్ ఎంచుకోండి",
"app_action_broke_system": "ఈ చర్య ఈ ముఖ్యమైన సేవలను విచ్ఛిన్నం చేసినట్లుగా కనిపిస్తోంది: {services}",
"app_action_cannot_be_ran_because_required_services_down": "ఈ చర్యను అమలు చేయడానికి ఈ అవసరమైన సేవలు అమలు చేయబడాలి: {services}. కొనసాగడం కొరకు వాటిని పునఃప్రారంభించడానికి ప్రయత్నించండి (మరియు అవి ఎందుకు పనిచేయడం లేదో పరిశోధించవచ్చు).",
- "app_argument_choice_invalid": "ఆర్గ్యుమెంట్ '{name}' కొరకు చెల్లుబాటు అయ్యే వైల్యూ ఎంచుకోండి: '{value}' అనేది లభ్యం అవుతున్న ఎంపికల్లో ({Choices}) లేదు",
- "app_argument_password_no_default": "పాస్వర్డ్ ఆర్గ్యుమెంట్ '{name}'ని పార్సింగ్ చేసేటప్పుడు దోషం: భద్రతా కారణం కొరకు పాస్వర్డ్ ఆర్గ్యుమెంట్ డిఫాల్ట్ విలువను కలిగి ఉండరాదు"
-}
+ "app_argument_choice_invalid": "ఆర్గ్యుమెంట్ '{name}' కొరకు చెల్లుబాటు అయ్యే వైల్యూ ఎంచుకోండి: '{value}' అనేది లభ్యం అవుతున్న ఎంపికల్లో ({choices}) లేదు",
+ "app_argument_password_no_default": "పాస్వర్డ్ ఆర్గ్యుమెంట్ '{name}'ని పార్సింగ్ చేసేటప్పుడు దోషం: భద్రతా కారణం కొరకు పాస్వర్డ్ ఆర్గ్యుమెంట్ డిఫాల్ట్ విలువను కలిగి ఉండరాదు",
+ "app_extraction_failed": "ఇన్స్టాలేషన్ ఫైల్లను సంగ్రహించడం సాధ్యపడలేదు",
+ "app_id_invalid": "చెల్లని యాప్ ID",
+ "app_install_failed": "{app}ని ఇన్స్టాల్ చేయడం సాధ్యపడలేదు: {error}",
+ "app_install_script_failed": "యాప్ ఇన్స్టాలేషన్ స్క్రిప్ట్లో లోపం సంభవించింది",
+ "app_manifest_install_ask_domain": "ఈ యాప్ను ఇన్స్టాల్ చేయాల్సిన డొమైన్ను ఎంచుకోండి",
+ "app_manifest_install_ask_password": "ఈ యాప్కు అడ్మినిస్ట్రేషన్ పాస్వర్డ్ను ఎంచుకోండి",
+ "app_not_installed": "ఇన్స్టాల్ చేసిన యాప్ల జాబితాలో {app}ని కనుగొనడం సాధ్యపడలేదు: {all apps}",
+ "app_removed": "{app} అన్ఇన్స్టాల్ చేయబడింది",
+ "app_restore_failed": "{app}: {error}ని పునరుద్ధరించడం సాధ్యపడలేదు",
+ "app_start_backup": "{app} కోసం బ్యాకప్ చేయాల్సిన ఫైల్లను సేకరిస్తోంది...",
+ "app_start_install": "{app}ని ఇన్స్టాల్ చేస్తోంది...",
+ "app_start_restore": "{app}ని పునరుద్ధరిస్తోంది...",
+ "app_unknown": "తెలియని యాప్",
+ "app_upgrade_failed": "అప్గ్రేడ్ చేయడం సాధ్యపడలేదు {app}: {error}",
+ "app_manifest_install_ask_admin": "ఈ యాప్ కోసం నిర్వాహక వినియోగదారుని ఎంచుకోండి",
+ "app_argument_required": "ఆర్గ్యుమెంట్ '{name}' అవసరం",
+ "app_change_url_success": "{app} URL ఇప్పుడు {domain}{path}",
+ "app_config_unable_to_apply": "config ప్యానెల్ values దరఖాస్తు చేయడంలో విఫలమయ్యాము.",
+ "app_install_files_invalid": "ఈ ఫైల్లను ఇన్స్టాల్ చేయడం సాధ్యం కాదు",
+ "app_manifest_install_ask_is_public": "అనామక సందర్శకులకు ఈ యాప్ బహిర్గతం కావాలా?",
+ "app_not_correctly_installed": "{app} తప్పుగా ఇన్స్టాల్ చేయబడినట్లుగా ఉంది",
+ "app_not_properly_removed": "{app} సరిగ్గా తీసివేయబడలేదు",
+ "app_remove_after_failed_install": "ఇన్స్టాలేషన్ విఫలమైనందున యాప్ని తీసివేస్తోంది...",
+ "app_requirements_checking": "{app} కోసం అవసరమైన ప్యాకేజీలను తనిఖీ చేస్తోంది...",
+ "app_restore_script_failed": "యాప్ పునరుద్ధరణ స్క్రిప్ట్లో లోపం సంభవించింది",
+ "app_sources_fetch_failed": "మూలాధార ఫైల్లను పొందడం సాధ్యపడలేదు, URL సరైనదేనా?",
+ "app_start_remove": "{app}ని తీసివేస్తోంది...",
+ "app_upgrade_app_name": "ఇప్పుడు {app}ని అప్గ్రేడ్ చేస్తోంది...",
+ "app_config_unable_to_read": "కాన్ఫిగరేషన్ ప్యానెల్ విలువలను చదవడంలో విఫలమైంది."
+}
\ No newline at end of file
diff --git a/locales/tr.json b/locales/tr.json
index 6c881eec7..1af0ffd54 100644
--- a/locales/tr.json
+++ b/locales/tr.json
@@ -1,3 +1,19 @@
{
- "password_too_simple_1": "Şifre en az 8 karakter uzunluğunda olmalı"
+ "password_too_simple_1": "Şifre en az 8 karakter uzunluğunda olmalı",
+ "action_invalid": "Geçersiz işlem '{action}'",
+ "admin_password": "Yönetici şifresi",
+ "already_up_to_date": "Yapılacak yeni bir şey yok. Her şey zaten güncel.",
+ "app_action_broke_system": "Bu işlem bazı hizmetleri bozmuş olabilir: {services}",
+ "good_practices_about_user_password": "Şimdi yeni bir kullanıcı şifresi tanımlamak üzeresiniz. Parola en az 8 karakter uzunluğunda olmalıdır - ancak daha uzun bir parola (yani bir parola) ve/veya çeşitli karakterler (büyük harf, küçük harf, rakamlar ve özel karakterler) daha iyidir.",
+ "aborting": "İptal ediliyor.",
+ "app_action_failed": "{app} uygulaması için {action} eylemini çalıştırma başarısız",
+ "admins": "Yöneticiler",
+ "all_users": "Tüm YunoHost kullanıcıları",
+ "app_already_up_to_date": "{app} zaten güncel",
+ "app_already_installed": "{app} zaten kurulu",
+ "app_already_installed_cant_change_url": "Bu uygulama zaten kurulu. URL yalnızca bu işlev kullanarak değiştirilemez. Eğer varsa `app changeurl`'i kontrol edin.",
+ "additional_urls_already_added": "Ek URL '{url}' zaten '{permission}' izni için ek URL'ye eklendi",
+ "additional_urls_already_removed": "Ek URL '{url}', '{permission}' izni için ek URL'de zaten kaldırıldı",
+ "app_action_cannot_be_ran_because_required_services_down": "Bu eylemi gerçekleştirmek için şu servisler çalışıyor olmalıdır: {services}. Devam etmek için onları yeniden başlatın (ve muhtemelen neden çalışmadığını araştırın).",
+ "app_arch_not_supported": "Bu uygulama yalnızca {required} işlemci mimarisi üzerine kurulabilir ancak sunucunuzun işlemci mimarisi {current}."
}
\ No newline at end of file
diff --git a/locales/uk.json b/locales/uk.json
index 9a32a597b..07cbfe6da 100644
--- a/locales/uk.json
+++ b/locales/uk.json
@@ -22,10 +22,7 @@
"app_action_broke_system": "Ця дія, схоже, порушила роботу наступних важливих служб: {services}",
"app_action_cannot_be_ran_because_required_services_down": "Для виконання цієї дії повинні бути запущені наступні необхідні служби: {services}. Спробуйте перезапустити їх, щоб продовжити (і, можливо, з'ясувати, чому вони не працюють).",
"already_up_to_date": "Нічого не потрібно робити. Все вже актуально.",
- "admin_password_too_long": "Будь ласка, виберіть пароль коротше 127 символів",
- "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}'",
@@ -66,7 +63,6 @@
"server_reboot": "Сервер буде перезавантажено",
"server_shutdown_confirm": "Сервер буде негайно вимкнено, ви впевнені? [{answers}]",
"server_shutdown": "Сервер буде вимкнено",
- "root_password_replaced_by_admin_password": "Ваш кореневий (root) пароль було замінено на пароль адміністратора.",
"root_password_desynchronized": "Пароль адміністратора було змінено, але YunoHost не зміг поширити це на кореневий (root) пароль!",
"restore_system_part_failed": "Не вдалося відновити системний розділ '{part}'",
"restore_running_hooks": "Запуск хуків відновлення…",
@@ -126,8 +122,8 @@
"pattern_port_or_range": "Має бути припустимий номер порту (наприклад, 0-65535) або діапазон портів (наприклад, 100:200)",
"pattern_password": "Має бути довжиною не менше 3 символів",
"pattern_mailbox_quota": "Має бути розмір з суфіксом b/k/M/G/T або 0, щоб не мати квоти",
- "pattern_lastname": "Має бути припустиме прізвище",
- "pattern_firstname": "Має бути припустиме ім'я",
+ "pattern_lastname": "Має бути припустиме прізвище (принаймні 3 символи)",
+ "pattern_firstname": "Має бути припустиме ім'я (принаймні 3 символи)",
"pattern_email": "Має бути припустима адреса е-пошти, без символу '+' (наприклад, someone@example.com)",
"pattern_email_forward": "Має бути припустима адреса е-пошти, символ '+' приймається (наприклад, someone+tag@example.com)",
"pattern_domain": "Має бути припустиме доменне ім'я (наприклад, my-domain.org)",
@@ -140,7 +136,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}...",
@@ -163,7 +159,7 @@
"migration_ldap_backup_before_migration": "Створення резервної копії бази даних LDAP і налаштування застосунків перед фактичною міграцією.",
"main_domain_changed": "Основний домен було змінено",
"main_domain_change_failed": "Неможливо змінити основний домен",
- "mail_unavailable": "Ця е-пошта зарезервована і буде автоматично виділена найпершому користувачеві",
+ "mail_unavailable": "Ця адреса електронної пошти зарезервована для групи адміністраторів",
"mailbox_used_space_dovecot_down": "Поштова служба Dovecot повинна бути запущена, якщо ви хочете отримати використане місце в поштовій скриньці",
"mailbox_disabled": "Е-пошта вимкнена для користувача {user}",
"mail_forward_remove_failed": "Не вдалося видалити переадресацію електронної пошти '{mail}'",
@@ -184,7 +180,7 @@
"log_user_delete": "Видалення користувача '{}'",
"log_user_create": "Додавання користувача '{}'",
"log_regen_conf": "Перестворення системних конфігурацій '{}'",
- "log_letsencrypt_cert_renew": "Оновлення сертифікату Let's Encrypt на домені '{}'",
+ "log_letsencrypt_cert_renew": "Поновлення сертифікату Let's Encrypt на домені '{}'",
"log_selfsigned_cert_install": "Установлення самопідписаного сертифікату на домені '{}'",
"log_permission_url": "Оновлення URL, пов'язаногл з дозволом '{}'",
"log_permission_delete": "Видалення дозволу '{}'",
@@ -195,7 +191,6 @@
"log_domain_remove": "Вилучення домену '{}' з конфігурації системи",
"log_domain_add": "Додавання домену '{}' в конфігурацію системи",
"log_remove_on_failed_install": "Вилучення '{}' після невдалого встановлення",
- "log_remove_on_failed_restore": "Вилучення '{}' після невдалого відновлення з резервного архіву",
"log_backup_restore_app": "Відновлення '{}' з архіву резервних копій",
"log_backup_restore_system": "Відновлення системи з резервного архіву",
"log_backup_create": "Створення резервного архіву",
@@ -239,39 +234,16 @@
"group_already_exist_on_system": "Група {group} вже існує в групах системи",
"group_already_exist": "Група {group} вже існує",
"good_practices_about_user_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_smtp_relay_password": "Пароль хоста SMTP-ретрансляції",
- "global_settings_setting_smtp_relay_user": "Обліковий запис користувача SMTP-ретрансляції",
+ "good_practices_about_admin_password": "Зараз ви збираєтеся поставити новий пароль адміністрування. Пароль повинен складатися не менше ніж з 8 символів, але хорошою практикою є використання більш довгого пароля (тобто парольної фрази) і/або використання різних символів (великих, малих, цифр і спеціальних символів).",
+ "global_settings_setting_smtp_relay_password": "Пароль SMTP-ретрансляції",
+ "global_settings_setting_smtp_relay_user": "Користувач SMTP-ретрансляції",
"global_settings_setting_smtp_relay_port": "Порт SMTP-ретрансляції",
- "global_settings_setting_smtp_relay_host": "Хост SMTP-ретрансляції, який буде використовуватися для надсилання е-пошти замість цього зразка Yunohost. Корисно, якщо ви знаходитеся в одній із цих ситуацій: ваш 25 порт заблокований вашим провайдером або VPS провайдером, у вас є житловий IP в списку DUHL, ви не можете налаштувати зворотний DNS або цей сервер не доступний безпосередньо в Інтернеті і ви хочете використовувати інший сервер для відправки електронних листів.",
- "global_settings_setting_smtp_allow_ipv6": "Дозволити використання IPv6 для отримання і надсилання листів е-пошти",
- "global_settings_setting_ssowat_panel_overlay_enabled": "Увімкнути накладення панелі SSOwat",
- "global_settings_setting_service_ssh_allow_deprecated_dsa_hostkey": "Дозволити використання (застарілого) ключа DSA для конфігурації демона SSH",
- "global_settings_unknown_setting_from_settings_file": "Невідомий ключ в налаштуваннях: '{setting_key}', відхиліть його і збережіть у /etc/yunohost/settings-unknown.json",
- "global_settings_setting_security_ssh_port": "SSH-порт",
- "global_settings_setting_security_postfix_compatibility": "Компроміс між сумісністю і безпекою для сервера Postfix. Впливає на шифри (і інші аспекти, пов'язані з безпекою)",
- "global_settings_setting_security_ssh_compatibility": "Компроміс між сумісністю і безпекою для SSH-сервера. Впливає на шифри (і інші аспекти, пов'язані з безпекою)",
- "global_settings_setting_security_password_user_strength": "Надійність пароля користувача",
- "global_settings_setting_security_password_admin_strength": "Надійність пароля адміністратора",
- "global_settings_setting_security_nginx_compatibility": "Компроміс між сумісністю і безпекою для вебсервера NGINX. Впливає на шифри (і інші аспекти, пов'язані з безпекою)",
- "global_settings_setting_pop3_enabled": "Увімкніть протокол POP3 для поштового сервера",
- "global_settings_reset_success": "Попередні налаштування тепер збережені в {path}",
- "global_settings_key_doesnt_exists": "Ключ '{settings_key}' не існує в глобальних налаштуваннях, ви можете побачити всі доступні ключі, виконавши команду 'yunohost settings list'",
- "global_settings_cant_write_settings": "Неможливо зберегти файл налаштувань, причина: {reason}",
- "global_settings_cant_serialize_settings": "Не вдалося серіалізувати дані налаштувань, причина: {reason}",
- "global_settings_cant_open_settings": "Не вдалося відкрити файл налаштувань, причина: {reason}",
- "global_settings_bad_type_for_setting": "Поганий тип для налаштування {setting}, отримано {received_type}, а очікується {expected_type}",
- "global_settings_bad_choice_for_enum": "Поганий вибір для налаштування {setting}, отримано '{choice}', але доступні наступні варіанти: {available_choices}",
+ "global_settings_setting_ssowat_panel_overlay_enabled": "Увімкнути невеликий ярлик порталу YunoHost у застосунках",
"firewall_rules_cmd_failed": "Деякі команди правил фаєрвола не спрацювали. Подробиці в журналі.",
"firewall_reloaded": "Фаєрвол перезавантажено",
"firewall_reload_failed": "Не вдалося перезавантажити фаєрвол",
"file_does_not_exist": "Файл {path} не існує.",
"field_invalid": "Неприпустиме поле '{}'",
- "experimental_feature": "Попередження: Ця функція є експериментальною і не вважається стабільною, ви не повинні використовувати її, якщо не знаєте, що робите.",
"extracting": "Витягнення...",
"dyndns_unavailable": "Домен '{domain}' недоступний.",
"dyndns_domain_not_provided": "DynDNS провайдер {provider} не може надати домен {domain}.",
@@ -285,7 +257,7 @@
"dyndns_ip_update_failed": "Не вдалося оновити IP-адресу в DynDNS",
"dyndns_could_not_check_available": "Не вдалося перевірити, чи {domain} доступний у {provider}.",
"dpkg_lock_not_available": "Ця команда не може бути виконана прямо зараз, тому що інша програма, схоже, використовує блокування dpkg (системного менеджера пакетів)",
- "dpkg_is_broken": "Ви не можете зробити це прямо зараз, тому що dpkg/APT (системні менеджери пакетів), схоже, знаходяться в зламаному стані... Ви можете спробувати вирішити цю проблему, під'єднавшись через SSH і виконавши `sudo apt install --fix-broken` та/або `sudo dpkg --configure -a`.",
+ "dpkg_is_broken": "Ви не можете зробити це прямо зараз, тому що dpkg/APT (системні менеджери пакетів), схоже, знаходяться в зламаному стані... Ви можете спробувати вирішити цю проблему, під'єднавшись через SSH і виконавши `sudo apt install --fix-broken` та/або `sudo dpkg --configure -a`та/або `sudo dpkg --audit`.",
"downloading": "Завантаження…",
"done": "Готово",
"domains_available": "Доступні домени:",
@@ -306,11 +278,11 @@
"domain_cannot_remove_main": "Ви не можете вилучити '{domain}', бо це основний домен, спочатку вам потрібно встановити інший домен в якості основного за допомогою 'yunohost domain main-domain -n {rdns_domain}
{ehlo_domain}
",
"diagnosis_mail_fcrdns_different_from_ehlo_domain": "Зворотний DNS неправильно налаштований в IPv{ipversion}. Деякі електронні листи можуть бути не доставлені або можуть бути відзначені як спам.",
- "diagnosis_mail_fcrdns_nok_alternatives_6": "Деякі провайдери не дозволять вам налаштувати зворотний DNS (або їх функція може бути зламана...). Якщо ваш зворотний DNS правильно налаштований для IPv4, ви можете спробувати вимкнути використання IPv6 при надсиланні листів, виконавши команду {ehlo_domain}
в інтерфейсі вашого інтернет-маршрутизатора або в інтерфейсі вашого хостинг-провайдера. (Деякі хостинг-провайдери можуть вимагати, щоб ви відправили їм запит у підтримку для цього).",
"diagnosis_mail_fcrdns_dns_missing": "У IPv{ipversion} не визначений зворотний DNS. Деякі листи можуть не доставлятися або позначатися як спам.",
@@ -374,7 +346,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 {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": "Ви можете спробувати перезапустити службу, а якщо це не допоможе, подивіться журнали служби в вебадміністрації (з командного рядка це можна зробити за допомогою {file}
似乎已被手动修改。",
"diagnosis_regenconf_allgood": "所有配置文件均符合建议的配置!",
"diagnosis_mail_queue_too_big": "邮件队列中的待处理电子邮件过多({nb_pending} emails)",
@@ -447,36 +420,35 @@
"diagnosis_ip_connected_ipv4": "服务器通过IPv4连接到Internet!",
"diagnosis_no_cache": "尚无类别 '{category}'的诊断缓存",
"diagnosis_failed": "无法获取类别 '{category}'的诊断结果: {error}",
- "diagnosis_package_installed_from_sury_details": "一些软件包被无意中从一个名为Sury的第三方仓库安装。YunoHost团队改进了处理这些软件包的策略,但预计一些安装了PHP7.3应用程序的设置在仍然使用Stretch的情况下还有一些不一致的地方。为了解决这种情况,你应该尝试运行以下命令:{wrong_ehlo}
{right_ehlo}
{wrong_ehlo}
{right_ehlo}
{mountpoint}
(在设备{device}
上)仍有 {free} ({free_percent}%) 空间(在{total}中)!",
"diagnosis_diskusage_low": "存储器{mountpoint}
(在设备{device}
上)只有{free} ({free_percent}%) 的空间。({free_percent}%)的剩余空间(在{total}中)。要小心。",
"diagnosis_diskusage_verylow": "存储器{mountpoint}
(在设备{device}
上)仅剩余{free} ({free_percent}%) (剩余{total})个空间。您应该真正考虑清理一些空间!",
- "diagnosis_services_bad_status_tip": "你可以尝试重新启动服务,如果没有效果,可以看看webadmin中的服务日志(从命令行,你可以用{type}
{name}
{current}期望值: {value}
",
@@ -495,8 +467,8 @@
"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。你要么在一个容器中,要么你的内核不支持它",
- "ip6tables_unavailable": "你不能在这里使用ip6tables。你要么在一个容器中,要么你的内核不支持它",
+ "iptables_unavailable": "您不能在这里使用iptables。您要么在一个容器中,要么您的内核不支持它",
+ "ip6tables_unavailable": "您不能在这里使用ip6tables。您要么在一个容器中,要么您的内核不支持它",
"log_regen_conf": "重新生成系统配置'{}'",
"log_letsencrypt_cert_renew": "续订'{}'的“Let's Encrypt”证书",
"log_selfsigned_cert_install": "在 '{}'域上安装自签名证书",
@@ -509,10 +481,9 @@
"log_domain_remove": "从系统配置中删除 '{}' 域",
"log_domain_add": "将 '{}'域添加到系统配置中",
"log_remove_on_failed_install": "安装失败后删除 '{}'",
- "log_remove_on_failed_restore": "从备份存档还原失败后,删除 '{}'",
"log_backup_restore_app": "从备份存档还原 '{}'",
"log_backup_restore_system": "从备份档案还原系统",
- "permission_currently_allowed_for_all_users": "这个权限目前除了授予其他组以外,还授予所有用户。你可能想删除'all_users'权限或删除目前授予它的其他组。",
+ "permission_currently_allowed_for_all_users": "这个权限目前除了授予其他组以外,还授予所有用户。您可能想删除'all_users'权限或删除目前授予它的其他组。",
"permission_creation_failed": "无法创建权限'{permission}': {error}",
"permission_created": "权限'{permission}'已创建",
"permission_cannot_remove_main": "不允许删除主要权限",
@@ -557,7 +528,7 @@
"migration_ldap_rollback_success": "系统回滚。",
"migration_ldap_migration_failed_trying_to_rollback": "无法迁移...试图回滚系统。",
"migration_ldap_can_not_backup_before_migration": "迁移失败之前,无法完成系统的备份。错误: {error}",
- "migration_ldap_backup_before_migration": "在实际迁移之前,请创建LDAP数据库和应用程序设置的备份。",
+ "migration_ldap_backup_before_migration": "在实际迁移之前,请创建LDAP数据库和应用设置的备份。",
"main_domain_changed": "主域已更改",
"main_domain_change_failed": "无法更改主域",
"mail_unavailable": "该电子邮件地址是保留的,并且将自动分配给第一个用户",
@@ -569,7 +540,7 @@
"log_tools_reboot": "重新启动服务器",
"log_tools_shutdown": "关闭服务器",
"log_tools_upgrade": "升级系统软件包",
- "log_tools_postinstall": "安装好你的YunoHost服务器后",
+ "log_tools_postinstall": "安装好您的YunoHost服务器后",
"log_tools_migrations_migrate_forward": "运行迁移",
"log_domain_main_domain": "将 '{}' 设为主要域",
"log_user_permission_reset": "重置权限'{}'",
@@ -582,16 +553,16 @@
"log_user_create": "添加用户'{}'",
"domain_registrar_is_not_configured": "尚未为域 {domain} 配置注册商。",
"domain_dns_push_not_applicable": "的自动DNS配置的特征是不适用域{domain}。您应该按照 https://yunohost.org/dns_config 上的文档手动配置DNS 记录。",
- "disk_space_not_sufficient_update": "没有足够的磁盘空间来更新此应用程序",
+ "disk_space_not_sufficient_update": "没有足够的磁盘空间来更新此应用",
"diagnosis_high_number_auth_failures": "最近出现了大量可疑的失败身份验证。您的fail2ban正在运行且配置正确,或使用自定义端口的SSH作为https://yunohost.org/解释的安全性。",
- "diagnosis_apps_not_in_app_catalog": "此应用程序不在 YunoHost 的应用程序目录中。如果它过去有被删除过,您应该考虑卸载此应用程,因为它不会更新,并且可能会损害您系统的完整和安全性。",
+ "diagnosis_apps_not_in_app_catalog": "此应用不在 YunoHost 的应用目录中。如果它过去有被删除过,您应该考虑卸载此应用程,因为它不会更新,并且可能会损害您系统的完整和安全性。",
"app_config_unable_to_apply": "无法应用配置面板值。",
"app_config_unable_to_read": "无法读取配置面板值。",
"config_forbidden_keyword": "关键字“{keyword}”是保留的,您不能创建或使用带有此 ID 的问题的配置面板。",
"config_no_panel": "未找到配置面板。",
"config_unknown_filter_key": "该过滤器钥匙“{filter_key}”有误。",
- "diagnosis_apps_outdated_ynh_requirement": "此应用程序的安装 版本只需要 yunohost >= 2.x,这往往表明它与推荐的打包实践和帮助程序不是最新的。你真的应该考虑更新它。",
- "disk_space_not_sufficient_install": "没有足够的磁盘空间来安装此应用程序",
+ "diagnosis_apps_outdated_ynh_requirement": "此应用的安装 版本只需要 yunohost >= 2.x,这往往表明它与推荐的打包实践和帮助程序不是最新的。您真的应该考虑更新它。",
+ "disk_space_not_sufficient_install": "没有足够的磁盘空间来安装此应用",
"config_apply_failed": "应用新配置 失败:{error}",
"config_cant_set_value_on_section": "无法在整个配置部分设置单个值 。",
"config_validate_color": "是有效的 RGB 十六进制颜色",
@@ -599,10 +570,25 @@
"config_validate_email": "是有效的电子邮件",
"config_validate_time": "应该是像 HH:MM 这样的有效时间",
"config_validate_url": "应该是有效的URL",
- "config_version_not_supported": "不支持配置面板版本“{ version }”。",
"danger": "警告:",
- "diagnosis_apps_allgood": "所有已安装的应用程序都遵守基本的打包原则",
- "diagnosis_apps_deprecated_practices": "此应用程序的安装 版本仍然使用一些超旧的弃用打包原则。推荐您升级它。",
+ "diagnosis_apps_allgood": "所有已安装的应用都遵守基本的打包原则",
+ "diagnosis_apps_deprecated_practices": "此应用的安装 版本仍然使用一些超旧的弃用打包原则。推荐您升级它。",
"diagnosis_apps_issue": "发现应用{ app } 存在问题",
- "diagnosis_description_apps": "应用"
+ "diagnosis_description_apps": "应用",
+ "global_settings_setting_backup_compress_tar_archives_help": "创建新备份时,请压缩档案(.tar.gz) ,而不要压缩未压缩的档案(.tar)。注意:启用此选项意味着创建较小的备份存档,但是初始备份过程将明显更长且占用大量CPU。",
+ "global_settings_setting_nginx_compatibility_help": "Web服务器NGINX的兼容性与安全性的权衡,影响密码(以及其他与安全性有关的方面)",
+ "global_settings_setting_admin_strength": "管理员密码强度",
+ "global_settings_setting_user_strength": "用户密码强度",
+ "global_settings_setting_postfix_compatibility_help": "Postfix服务器的兼容性与安全性的权衡。影响密码(以及其他与安全性有关的方面)",
+ "global_settings_setting_ssh_compatibility_help": "SSH服务器的兼容性与安全性的权衡。影响密码(以及其他与安全性有关的方面)",
+ "global_settings_setting_ssh_port": "SSH端口",
+ "global_settings_setting_smtp_allow_ipv6_help": "允许使用IPv6接收和发送邮件",
+ "global_settings_setting_smtp_relay_enabled_help": "使用SMTP中继主机来代替这个YunoHost实例发送邮件。如果您有以下情况,就很有用:您的25端口被您的ISP或VPS提供商封锁,您有一个住宅IP列在DUHL上,您不能配置反向DNS,或者这个服务器没有直接暴露在互联网上,您想使用其他服务器来发送邮件。",
+ "all_users": "所有的YunoHost用户",
+ "app_manifest_install_ask_init_admin_permission": "谁应该有权访问此应用的管理功能?(此配置可以稍后更改)",
+ "app_action_failed": "对应用{app}执行动作{action}失败",
+ "app_manifest_install_ask_init_main_permission": "谁应该有权访问此应用?(此配置稍后可以更改)",
+ "ask_admin_fullname": "管理员全名",
+ "ask_admin_username": "管理员用户名",
+ "ask_fullname": "全名"
}
\ No newline at end of file
diff --git a/maintenance/agplv3.tpl b/maintenance/agplv3.tpl
new file mode 100644
index 000000000..82f3b4cc6
--- /dev/null
+++ b/maintenance/agplv3.tpl
@@ -0,0 +1,16 @@
+Copyright (c) ${years} ${owner}
+
+This file is part of ${projectname} (see ${projecturl})
+
+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 .
diff --git a/maintenance/autofix_locale_format.py b/maintenance/autofix_locale_format.py
index 1c56ea386..caa36f9f2 100644
--- a/maintenance/autofix_locale_format.py
+++ b/maintenance/autofix_locale_format.py
@@ -32,7 +32,6 @@ def autofix_i18n_placeholders():
# We iterate over all keys/string in en.json
for key, string in reference.items():
-
# Ignore check if there's no translation yet for this key
if key not in this_locale:
continue
@@ -89,7 +88,6 @@ Please fix it manually !
def autofix_orthotypography_and_standardized_words():
def reformat(lang, transformations):
-
locale = open(f"{LOCALE_FOLDER}{lang}.json").read()
for pattern, replace in transformations.items():
locale = re.compile(pattern).sub(replace, locale)
@@ -146,11 +144,9 @@ def autofix_orthotypography_and_standardized_words():
def remove_stale_translated_strings():
-
reference = json.loads(open(LOCALE_FOLDER + "en.json").read())
for locale_file in TRANSLATION_FILES:
-
print(locale_file)
this_locale = json.loads(
open(LOCALE_FOLDER + locale_file).read(), object_pairs_hook=OrderedDict
diff --git a/maintenance/make_changelog.sh b/maintenance/make_changelog.sh
index 44171c5b6..f5d1572a6 100644
--- a/maintenance/make_changelog.sh
+++ b/maintenance/make_changelog.sh
@@ -1,18 +1,18 @@
VERSION="?"
RELEASE="testing"
REPO=$(basename $(git rev-parse --show-toplevel))
-REPO_URL=$(git remote get-url origin)
+REPO_URL="https://github.com/yunohost/yunohost"
ME=$(git config --global --get user.name)
EMAIL=$(git config --global --get user.email)
-LAST_RELEASE=$(git tag --list 'debian/11.*' | tail -n 1)
+LAST_RELEASE=$(git tag --list 'debian/11.*' --sort="v:refname" | tail -n 1)
echo "$REPO ($VERSION) $RELEASE; urgency=low"
echo ""
git log $LAST_RELEASE.. -n 10000 --first-parent --pretty=tformat:' - %b%s (%h)' \
-| sed -E "s@Merge .*#([0-9]+).*\$@ \([#\1]\($REPO_URL/pull/\1\)\)@g" \
-| grep -v "Update from Weblate" \
+| sed -E "s&Merge .*#([0-9]+).*\$& \([#\1]\($REPO_URL/pull/\1\)\)&g" \
+| grep -v "Translations update from Weblate" \
| tac
TRANSLATIONS=$(git log $LAST_RELEASE... -n 10000 --pretty=format:"%s" \
@@ -23,7 +23,7 @@ TRANSLATIONS=$(git log $LAST_RELEASE... -n 10000 --pretty=format:"%s" \
echo ""
CONTRIBUTORS=$(git logc $LAST_RELEASE... -n 10000 --pretty=format:"%an" \
- | sort | uniq | grep -v "$ME" \
+ | sort | uniq | grep -v "$ME" | grep -v 'yunohost-bot' | grep -vi 'weblate' \
| tr '\n' ', ' | sed -e 's/,$//g' -e 's/,/, /g')
[[ -z "$CONTRIBUTORS" ]] || echo " Thanks to all contributors <3 ! ($CONTRIBUTORS)"
echo ""
diff --git a/maintenance/missing_i18n_keys.py b/maintenance/missing_i18n_keys.py
index 3dbca8027..2ed7fd141 100644
--- a/maintenance/missing_i18n_keys.py
+++ b/maintenance/missing_i18n_keys.py
@@ -19,7 +19,6 @@ REFERENCE_FILE = LOCALE_FOLDER + "en.json"
def find_expected_string_keys():
-
# Try to find :
# m18n.n( "foo"
# YunohostError("foo"
@@ -99,15 +98,6 @@ def find_expected_string_keys():
for m in ("log_" + match for match in p4.findall(content)):
yield m
- # Global settings descriptions
- # Will be on a line like : ("service.ssh.allow_deprecated_dsa_hostkey", {"type": "bool", ...
- p5 = re.compile(r" \(\n*\s*[\"\'](\w[\w\.]+)[\"\'],")
- content = open(ROOT + "src/settings.py").read()
- for m in (
- "global_settings_setting_" + s.replace(".", "_") for s in p5.findall(content)
- ):
- yield m
-
# Keys for the actionmap ...
for category in yaml.safe_load(open(ROOT + "share/actionsmap.yml")).values():
if "actions" not in category.keys():
@@ -143,6 +133,7 @@ def find_expected_string_keys():
for key in registrars[registrar].keys():
yield f"domain_config_{key}"
+ # Domain config panel
domain_config = toml.load(open(ROOT + "share/config_domain.toml"))
for panel in domain_config.values():
if not isinstance(panel, dict):
@@ -155,6 +146,35 @@ def find_expected_string_keys():
continue
yield f"domain_config_{key}"
+ # Global settings
+ global_config = toml.load(open(ROOT + "share/config_global.toml"))
+ # Boring hard-coding because there's no simple other way idk
+ settings_without_help_key = [
+ "passwordless_sudo",
+ "smtp_relay_host",
+ "smtp_relay_password",
+ "smtp_relay_port",
+ "smtp_relay_user",
+ "ssh_port",
+ "ssowat_panel_overlay_enabled",
+ "root_password",
+ "root_access_explain",
+ "root_password_confirm",
+ ]
+
+ for panel in global_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"global_settings_setting_{key}"
+ if key not in settings_without_help_key:
+ yield f"global_settings_setting_{key}_help"
+
###############################################################################
# Compare keys used and keys defined #
@@ -176,7 +196,6 @@ undefined_keys = sorted(undefined_keys)
mode = sys.argv[1].strip("-")
if mode == "check":
-
# Unused keys are not too problematic, will be automatically
# removed by the other autoreformat script,
# but still informative to display them
diff --git a/maintenance/update_copyright_headers.sh b/maintenance/update_copyright_headers.sh
new file mode 100644
index 000000000..bc4fe24db
--- /dev/null
+++ b/maintenance/update_copyright_headers.sh
@@ -0,0 +1,12 @@
+# To run this you'll need to:
+#
+# pip3 install licenseheaders
+
+licenseheaders \
+ -o "YunoHost Contributors" \
+ -n "YunoHost" \
+ -u "https://yunohost.org" \
+ -t ./agplv3.tpl \
+ --current-year \
+ -f ../src/*.py ../src/{utils,diagnosers,authenticators}/*.py
+
diff --git a/share/100000-most-used-passwords-length8plus.txt.gz b/share/100000-most-used-passwords-length8plus.txt.gz
new file mode 100644
index 000000000..6059a5af8
Binary files /dev/null and b/share/100000-most-used-passwords-length8plus.txt.gz differ
diff --git a/share/100000-most-used-passwords.txt.gz b/share/100000-most-used-passwords.txt.gz
deleted file mode 100644
index 43887119b..000000000
Binary files a/share/100000-most-used-passwords.txt.gz and /dev/null differ
diff --git a/share/actionsmap.yml b/share/actionsmap.yml
index 89c6e914d..0a12b94a1 100644
--- a/share/actionsmap.yml
+++ b/share/actionsmap.yml
@@ -37,14 +37,6 @@ _global:
authentication:
api: ldap_admin
cli: null
- arguments:
- -v:
- full: --version
- help: Display YunoHost packages versions
- action: callback
- callback:
- method: yunohost.utils.packages.ynh_packages_version
- return: true
#############################
# User #
@@ -73,19 +65,28 @@ user:
pattern: &pattern_username
- !!str ^[a-z0-9_]+$
- "pattern_username"
+ -F:
+ full: --fullname
+ help: The full name of the user. For example 'Camille Dupont'
+ extra:
+ ask: ask_fullname
+ required: False
+ pattern: &pattern_fullname
+ - !!str ^([^\W_]{1,30}[ ,.'-]{0,3})+$
+ - "pattern_fullname"
-f:
full: --firstname
+ help: Deprecated. Use --fullname instead.
extra:
- ask: ask_firstname
- required: True
+ required: False
pattern: &pattern_firstname
- !!str ^([^\W\d_]{1,30}[ ,.'-]{0,3})+$
- "pattern_firstname"
-l:
full: --lastname
+ help: Deprecated. Use --fullname instead.
extra:
- ask: ask_lastname
- required: True
+ required: False
pattern: &pattern_lastname
- !!str ^([^\W\d_]{1,30}[ ,.'-]{0,3})+$
- "pattern_lastname"
@@ -115,6 +116,11 @@ user:
pattern: &pattern_mailbox_quota
- !!str ^(\d+[bkMGT])|0$
- "pattern_mailbox_quota"
+ -s:
+ full: --loginShell
+ help: The login shell used
+ default: "/bin/bash"
+
### user_delete()
delete:
@@ -136,12 +142,19 @@ user:
arguments:
username:
help: Username to update
+ -F:
+ full: --fullname
+ help: The full name of the user. For example 'Camille Dupont'
+ extra:
+ pattern: *pattern_fullname
-f:
full: --firstname
+ help: Deprecated. Use --fullname instead.
extra:
pattern: *pattern_firstname
-l:
full: --lastname
+ help: Deprecated. Use --fullname instead.
extra:
pattern: *pattern_lastname
-m:
@@ -187,6 +200,10 @@ user:
metavar: "{SIZE|0}"
extra:
pattern: *pattern_mailbox_quota
+ -s:
+ full: --loginShell
+ help: The login shell used
+ default: "/bin/bash"
### user_info()
info:
@@ -305,6 +322,35 @@ user:
extra:
pattern: *pattern_username
+ add-mailalias:
+ action_help: Add mail aliases to group
+ api: PUT /users/groups//aliases/
+ arguments:
+ groupname:
+ help: Name of the group to add user(s) to
+ extra:
+ pattern: *pattern_groupname
+ aliases:
+ help: Mail aliases to add
+ nargs: "+"
+ metavar: MAIL
+ extra:
+ pattern: *pattern_email
+ remove-mailalias:
+ action_help: Remove mail aliases to group
+ api: DELETE /users/groups//aliases/
+ arguments:
+ groupname:
+ help: Name of the group to add user(s) to
+ extra:
+ pattern: *pattern_groupname
+ aliases:
+ help: Mail aliases to remove
+ nargs: "+"
+ metavar: MAIL
+
+
+
permission:
subcategory_help: Manage permissions
actions:
@@ -443,6 +489,22 @@ domain:
--exclude-subdomains:
help: Filter out domains that are obviously subdomains of other declared domains
action: store_true
+ --tree:
+ help: Display domains as a tree
+ action: store_true
+ --features:
+ help: List only domains with features enabled (xmpp, mail_in, mail_out)
+ nargs: "*"
+
+ ### domain_info()
+ info:
+ action_help: Get domain aggredated data
+ api: GET /domains/
+ arguments:
+ domain:
+ help: Domain to check
+ extra:
+ pattern: *pattern_domain
### domain_add()
add:
@@ -492,9 +554,7 @@ domain:
action_help: Check the current main domain, or change it
deprecated_alias:
- maindomain
- api:
- - GET /domains/main
- - PUT /domains//main
+ api: PUT /domains//main
arguments:
-n:
full: --new-main-domain
@@ -552,6 +612,7 @@ domain:
### domain_url_available()
url-available:
+ hide_in_help: True
action_help: Check availability of a web path
api: GET /domain//urlavailable
arguments:
@@ -562,6 +623,20 @@ domain:
path:
help: The path to check (e.g. /coffee)
+ ### domain_action_run()
+ action-run:
+ hide_in_help: True
+ action_help: Run domain action
+ api: PUT /domain//actions/
+ arguments:
+ domain:
+ help: Domain name
+ action:
+ help: action id
+ -a:
+ full: --args
+ help: Serialized arguments for action (i.e. "foo=bar&lorem=ipsum")
+
subcategories:
config:
@@ -571,7 +646,9 @@ domain:
### domain_config_get()
get:
action_help: Display a domain configuration
- api: GET /domains//config
+ api:
+ - GET /domains//config
+ - GET /domains//config/
arguments:
domain:
help: Domain name
@@ -590,7 +667,7 @@ domain:
### domain_config_set()
set:
action_help: Apply a new configuration
- api: PUT /domains//config
+ api: PUT /domains//config/
arguments:
domain:
help: Domain name
@@ -710,6 +787,10 @@ app:
full: --with-categories
help: Also return a list of app categories
action: store_true
+ -a:
+ full: --with-antifeatures
+ help: Also return a list of antifeatures categories
+ action: store_true
### app_search()
search:
@@ -725,6 +806,10 @@ app:
arguments:
app:
help: Name, local path or git URL of the app to fetch the manifest of
+ -s:
+ full: --with-screenshot
+ help: Also return a base64 screenshot if any (API only)
+ action: store_true
### app_list()
list:
@@ -826,6 +911,10 @@ app:
full: --no-safety-backup
help: Disable the safety backup during upgrade
action: store_true
+ -c:
+ full: --continue-on-failure
+ help: Continue to upgrade apps event if one or more upgrade failed
+ action: store_true
### app_change_url()
change-url:
@@ -865,9 +954,18 @@ app:
help: Delete the key
action: store_true
+ ### app_shell()
+ shell:
+ action_help: Open an interactive shell with the app environment already loaded
+ # Here we set a GET only not to lock the command line. There is no actual API endpoint for app_shell()
+ api: GET /apps//shell
+ arguments:
+ app:
+ help: App ID
### app_register_url()
register-url:
+ hide_in_help: True
action_help: Book/register a web path for a given app
arguments:
app:
@@ -880,6 +978,7 @@ app:
### app_makedefault()
makedefault:
+ hide_in_help: True
action_help: Redirect domain root to an app
api: PUT /apps//default
arguments:
@@ -893,6 +992,17 @@ app:
help: Undo redirection
action: store_true
+ ### app_dismiss_notification
+ dismiss-notification:
+ hide_in_help: True
+ action_help: Dismiss post_install or post_upgrade notification
+ api: PUT /apps//dismiss_notification/
+ arguments:
+ app:
+ help: App ID to dismiss notification for
+ name:
+ help: Notification name, either post_install or post_upgrade
+
### app_ssowatconf()
ssowatconf:
action_help: Regenerate SSOwat configuration file
@@ -941,7 +1051,9 @@ app:
### app_config_get()
get:
action_help: Display an app configuration
- api: GET /apps//config-panel
+ api:
+ - GET /apps//config
+ - GET /apps//config/
arguments:
app:
help: App name
@@ -960,7 +1072,7 @@ app:
### app_config_set()
set:
action_help: Apply a new configuration
- api: PUT /apps//config
+ api: PUT /apps//config/
arguments:
app:
help: App name
@@ -1065,6 +1177,7 @@ backup:
### backup_download()
download:
+ hide_in_help: True
action_help: (API only) Request to download the file
api: GET /backups//download
arguments:
@@ -1093,6 +1206,11 @@ settings:
list:
action_help: list all entries of the settings
api: GET /settings
+ arguments:
+ -f:
+ full: --full
+ help: Display all details (meant to be used by the API)
+ action: store_true
### settings_get()
get:
@@ -1101,22 +1219,29 @@ settings:
arguments:
key:
help: Settings key
- --full:
- help: Show more details
+ -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
### settings_set()
set:
action_help: set an entry value in the settings
- api: POST /settings/
+ api: PUT /settings/
arguments:
key:
- help: Settings key
+ help: The question or form key
+ nargs: '?'
-v:
full: --value
help: new value
- extra:
- required: True
+ -a:
+ full: --args
+ help: Serialized arguments for new configuration (i.e. "mail_in=0&mail_out=0")
### settings_reset_all()
reset-all:
@@ -1433,10 +1558,10 @@ tools:
category_help: Specific tools
actions:
- ### tools_adminpw()
- adminpw:
- action_help: Change password of admin and root users
- api: PUT /adminpw
+ ### tools_rootpw()
+ rootpw:
+ action_help: Change root password
+ api: PUT /rootpw
arguments:
-n:
full: --new-password
@@ -1471,6 +1596,20 @@ tools:
ask: ask_main_domain
pattern: *pattern_domain
required: True
+ -u:
+ full: --username
+ help: Username for the first (admin) user. For example 'camille'
+ extra:
+ ask: ask_admin_username
+ pattern: *pattern_username
+ required: True
+ -F:
+ full: --fullname
+ help: The full name for the first (admin) user. For example 'Camille Dupont'
+ extra:
+ ask: ask_admin_fullname
+ required: True
+ pattern: *pattern_fullname
-p:
full: --password
help: YunoHost admin password
@@ -1482,14 +1621,10 @@ tools:
--ignore-dyndns:
help: Do not subscribe domain to a DynDNS service
action: store_true
- --force-password:
- help: Use this if you really want to set a weak password
- action: store_true
--force-diskspace:
help: Use this if you really want to install YunoHost on a setup with less than 10 GB on the root filesystem
action: store_true
-
### tools_update()
update:
action_help: YunoHost update
@@ -1651,6 +1786,7 @@ hook:
### hook_info()
info:
+ hide_in_help: True
action_help: Get information about a given hook
arguments:
action:
@@ -1680,6 +1816,7 @@ hook:
### hook_callback()
callback:
+ hide_in_help: True
action_help: Execute all scripts binded to an action
arguments:
action:
@@ -1702,6 +1839,7 @@ hook:
### hook_exec()
exec:
+ hide_in_help: True
action_help: Execute hook from a file with arguments
arguments:
path:
diff --git a/share/config_domain.toml b/share/config_domain.toml
index 65e755365..82ef90c32 100644
--- a/share/config_domain.toml
+++ b/share/config_domain.toml
@@ -1,44 +1,25 @@
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]
+name = "Features"
+
[feature.app]
[feature.app.default_app]
type = "app"
filter = "is_webapp"
default = "_none"
-
- [feature.mail]
- #services = ['postfix', 'dovecot']
- [feature.mail.features_disclaimer]
- type = "alert"
- style = "warning"
- icon = "warning"
+ [feature.mail]
[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]
@@ -46,15 +27,56 @@ i18n = "domain_config"
default = 0
[dns]
-
+name = "DNS"
+
[dns.registrar]
- optional = true
+ # This part is automatically generated in DomainConfigPanel
- # This part is automatically generated in DomainConfigPanel
+[cert]
+name = "Certificate"
-# [dns.advanced]
-#
-# [dns.advanced.ttl]
-# type = "number"
-# min = 0
-# default = 3600
+ [cert.cert]
+
+ [cert.cert.cert_summary]
+ type = "alert"
+ # Automatically filled by DomainConfigPanel
+
+ [cert.cert.cert_validity]
+ type = "number"
+ readonly = true
+ visible = "false"
+ # Automatically filled by DomainConfigPanel
+
+ [cert.cert.cert_issuer]
+ type = "string"
+ visible = false
+ # Automatically filled by DomainConfigPanel
+
+ [cert.cert.acme_eligible]
+ type = "boolean"
+ visible = false
+ # Automatically filled by DomainConfigPanel
+
+ [cert.cert.acme_eligible_explain]
+ type = "alert"
+ style = "warning"
+ visible = "acme_eligible == false || acme_eligible == null"
+
+ [cert.cert.cert_no_checks]
+ type = "boolean"
+ default = false
+ visible = "acme_eligible == false || acme_eligible == null"
+
+ [cert.cert.cert_install]
+ type = "button"
+ icon = "star"
+ style = "success"
+ visible = "cert_issuer != 'letsencrypt'"
+ enabled = "acme_eligible || cert_no_checks"
+
+ [cert.cert.cert_renew]
+ type = "button"
+ icon = "refresh"
+ style = "warning"
+ visible = "cert_issuer == 'letsencrypt'"
+ enabled = "acme_eligible || cert_no_checks"
diff --git a/share/config_global.toml b/share/config_global.toml
new file mode 100644
index 000000000..40b71ab19
--- /dev/null
+++ b/share/config_global.toml
@@ -0,0 +1,171 @@
+version = "1.0"
+i18n = "global_settings_setting"
+
+[security]
+name = "Security"
+ [security.password]
+ name = "Passwords"
+
+ [security.password.admin_strength]
+ type = "select"
+ choices.1 = "Require at least 8 chars"
+ choices.2 = "ditto, but also require at least one digit, one lower and one upper char"
+ choices.3 = "ditto, but also require at least one special char"
+ choices.4 = "ditto, but also require at least 12 chars"
+ default = "1"
+
+ [security.password.user_strength]
+ type = "select"
+ choices.1 = "Require at least 8 chars"
+ choices.2 = "ditto, but also require at least one digit, one lower and one upper char"
+ choices.3 = "ditto, but also require at least one special char"
+ choices.4 = "ditto, but also require at least 12 chars"
+ default = "1"
+
+ [security.password.passwordless_sudo]
+ type = "boolean"
+ # The actual value is dynamically computed by checking the sudoOption of cn=admins,ou=sudo
+ default = false
+
+ [security.ssh]
+ name = "SSH"
+ [security.ssh.ssh_compatibility]
+ type = "select"
+ choices.intermediate = "Intermediate (compatible with older softwares)"
+ choices.modern = "Modern (recommended)"
+ default = "modern"
+
+ [security.ssh.ssh_port]
+ type = "number"
+ default = 22
+
+ [security.ssh.ssh_password_authentication]
+ type = "boolean"
+ default = true
+
+ [security.nginx]
+ name = "NGINX (web server)"
+ [security.nginx.nginx_redirect_to_https]
+ type = "boolean"
+ default = true
+
+ [security.nginx.nginx_compatibility]
+ type = "select"
+ choices.intermediate = "Intermediate (compatible with Firefox 27, Android 4.4.2, Chrome 31, Edge, IE 11, Opera 20, and Safari 9)"
+ choices.modern = "Modern (compatible with Firefox 63, Android 10.0, Chrome 70, Edge 75, Opera 57, and Safari 12.1)"
+ default = "intermediate"
+
+ [security.postfix]
+ name = "Postfix (SMTP email server)"
+ [security.postfix.postfix_compatibility]
+ type = "select"
+ choices.intermediate = "Intermediate (allows TLS 1.2)"
+ choices.modern = "Modern (TLS 1.3 only)"
+ default = "intermediate"
+
+ [security.webadmin]
+ name = "Webadmin"
+ [security.webadmin.webadmin_allowlist_enabled]
+ type = "boolean"
+ default = false
+
+ [security.webadmin.webadmin_allowlist]
+ type = "tags"
+ visible = "webadmin_allowlist_enabled"
+ optional = true
+ default = ""
+
+ [security.root_access]
+ name = "Change root password"
+
+ [security.root_access.root_access_explain]
+ type = "alert"
+ style = "info"
+ icon = "info"
+
+ [security.root_access.root_password]
+ type = "password"
+ optional = true
+ default = ""
+
+ [security.root_access.root_password_confirm]
+ type = "password"
+ optional = true
+ default = ""
+
+ [security.experimental]
+ name = "Experimental"
+ [security.experimental.security_experimental_enabled]
+ type = "boolean"
+ default = false
+
+
+[email]
+name = "Email"
+ [email.pop3]
+ name = "POP3"
+ [email.pop3.pop3_enabled]
+ type = "boolean"
+ default = false
+
+ [email.smtp]
+ name = "SMTP"
+ [email.smtp.smtp_allow_ipv6]
+ type = "boolean"
+ default = true
+
+ [email.smtp.smtp_relay_enabled]
+ type = "boolean"
+ default = false
+
+ [email.smtp.smtp_relay_host]
+ type = "string"
+ default = ""
+ optional = true
+ visible="smtp_relay_enabled"
+
+ [email.smtp.smtp_relay_port]
+ type = "number"
+ default = 587
+ visible="smtp_relay_enabled"
+
+ [email.smtp.smtp_relay_user]
+ type = "string"
+ default = ""
+ optional = true
+ visible="smtp_relay_enabled"
+
+ [email.smtp.smtp_relay_password]
+ type = "password"
+ default = ""
+ optional = true
+ visible="smtp_relay_enabled"
+ help = "" # This is empty string on purpose, otherwise the core automatically set the 'good_practice_admin_password' string here which is not relevant, because the admin is not actually "choosing" the password ...
+
+[misc]
+name = "Other"
+ [misc.portal]
+ name = "User portal"
+ [misc.portal.ssowat_panel_overlay_enabled]
+ type = "boolean"
+ default = true
+
+ [misc.portal.portal_theme]
+ type = "select"
+ # Choices are loaded dynamically in the python code
+ default = "default"
+
+ [misc.backup]
+ name = "Backup"
+ [misc.backup.backup_compress_tar_archives]
+ type = "boolean"
+ default = false
+
+ [misc.network]
+ name = "Network"
+ [misc.network.dns_exposure]
+ type = "select"
+ choices.both = "Both"
+ choices.ipv4 = "IPv4 Only"
+ choices.ipv6 = "IPv6 Only"
+ default = "both"
diff --git a/share/html/502.html b/share/html/502.html
new file mode 100644
index 000000000..bef0275df
--- /dev/null
+++ b/share/html/502.html
@@ -0,0 +1,20 @@
+
+
+
+502 Bad Gateway
+
+
+
+502 Bad Gateway
+If you see this page, your connection with the server is working but the internal service providing this path is not responding.
+Administrator, make sure that the service is running, and check its logs if it is not.
+The Services page is in your webadmin, under Tools > Services
.
+Thank you for using YunoHost.
+
+
diff --git a/share/registrar_list.toml b/share/registrar_list.toml
index afb213aa1..3f478a03f 100644
--- a/share/registrar_list.toml
+++ b/share/registrar_list.toml
@@ -501,6 +501,15 @@
[pointhq.auth_token]
type = "string"
redact = true
+
+[porkbun]
+ [porkbun.auth_key]
+ type = "string"
+ redact = true
+
+ [porkbun.auth_secret]
+ type = "string"
+ redact = true
[powerdns]
[powerdns.auth_token]
@@ -622,7 +631,14 @@
[vultr.auth_token]
type = "string"
redact = true
-
+
+[webgo]
+ [webgo.auth_username]
+ type = "string"
+
+ [webgo.auth_password]
+ type = "password"
+
[yandex]
[yandex.auth_token]
type = "string"
diff --git a/src/__init__.py b/src/__init__.py
index cdf845de0..146485d2d 100644
--- a/src/__init__.py
+++ b/src/__init__.py
@@ -1,5 +1,22 @@
#! /usr/bin/python
-# -*- coding: utf-8 -*-
+#
+# Copyright (c) 2023 YunoHost Contributors
+#
+# This file is part of YunoHost (see https://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 .
+#
import os
import sys
@@ -15,7 +32,6 @@ def is_installed():
def cli(debug, quiet, output_as, timeout, args, parser):
-
init_logging(interface="cli", debug=debug, quiet=quiet)
# Check that YunoHost is installed
@@ -34,7 +50,6 @@ def cli(debug, quiet, output_as, timeout, args, parser):
def api(debug, host, port):
-
init_logging(interface="api", debug=debug)
def is_installed_api():
@@ -68,7 +83,6 @@ def portalapi(debug, host, port):
def check_command_is_valid_before_postinstall(args):
-
allowed_if_not_postinstalled = [
"tools postinstall",
"tools versions",
@@ -106,7 +120,6 @@ def init_i18n():
def init_logging(interface="cli", debug=False, quiet=False, logdir="/var/log/yunohost"):
-
logfile = os.path.join(logdir, "yunohost-%s.log" % interface)
if not os.path.isdir(logdir):
diff --git a/src/app.py b/src/app.py
index 11216895e..069134798 100644
--- a/src/app.py
+++ b/src/app.py
@@ -1,28 +1,23 @@
-# -*- coding: utf-8 -*-
+#
+# Copyright (c) 2023 YunoHost Contributors
+#
+# This file is part of YunoHost (see https://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 .
+#
-""" 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_app.py
-
- Manage apps
-"""
+import glob
import os
import toml
import json
@@ -32,8 +27,10 @@ import time
import re
import subprocess
import tempfile
+import copy
from collections import OrderedDict
-from typing import List, Tuple, Dict, Any
+from typing import List, Tuple, Dict, Any, Iterator, Optional
+from packaging import version
from moulinette import Moulinette, m18n
from moulinette.utils.log import getActionLogger
@@ -51,22 +48,30 @@ from moulinette.utils.filesystem import (
chmod,
)
-from yunohost.utils import packages
-from yunohost.utils.config import (
- ConfigPanel,
- ask_questions_and_parse_answers,
- DomainQuestion,
- PathQuestion,
+from yunohost.utils.configpanel import ConfigPanel, ask_questions_and_parse_answers
+from yunohost.utils.form import (
+ DomainOption,
+ WebPathOption,
hydrate_questions_with_choices,
)
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.utils.system import (
+ free_space_in_directory,
+ dpkg_is_broken,
+ get_ynh_package_version,
+ system_arch,
+ debian_version,
+ human_to_binary,
+ binary_to_human,
+ ram_available,
+)
from yunohost.log import is_unit_operation, OperationLogger
from yunohost.app_catalog import ( # noqa
app_catalog,
app_search,
_load_apps_catalog,
+ APPS_CATALOG_LOGOS,
)
logger = getActionLogger("yunohost.app")
@@ -79,7 +84,7 @@ re_app_instance_name = re.compile(
)
APP_REPO_URL = re.compile(
- r"^https://[a-zA-Z0-9-_.]+/[a-zA-Z0-9-_./~]+/[a-zA-Z0-9-_.]+_ynh(/?(-/)?tree/[a-zA-Z0-9-_.]+)?(\.git)?/?$"
+ r"^https://[a-zA-Z0-9-_.]+/[a-zA-Z0-9-_./~]+/[a-zA-Z0-9-_.]+_ynh(/?(-/)?(tree|src/(branch|tag|commit))/[a-zA-Z0-9-_.]+)?(\.git)?/?$"
)
APP_FILES_TO_COPY = [
@@ -147,6 +152,13 @@ def app_info(app, full=False, upgradable=False):
absolute_app_name, _ = _parse_app_instance_name(app)
from_catalog = _load_apps_catalog()["apps"].get(absolute_app_name, {})
+ # Check if $app.png exists in the app logo folder, this is a trick to be able to easily customize the logo
+ # of an app just by creating $app.png (instead of the hash.png) in the corresponding folder
+ ret["logo"] = (
+ app
+ if os.path.exists(f"{APPS_CATALOG_LOGOS}/{app}.png")
+ else from_catalog.get("logo_hash")
+ )
ret["upgradable"] = _app_upgradable({**ret, "from_catalog": from_catalog})
if ret["upgradable"] == "yes":
@@ -160,18 +172,42 @@ def app_info(app, full=False, upgradable=False):
ret["current_version"] = f" ({current_revision})"
ret["new_version"] = f" ({new_revision})"
+ ret["settings"] = settings
+
if not full:
return ret
ret["setting_path"] = setting_path
ret["manifest"] = local_manifest
- ret["manifest"]["arguments"] = _set_default_ask_questions(
- ret["manifest"].get("arguments", {})
+
+ # FIXME: maybe this is not needed ? default ask questions are
+ # already set during the _get_manifest_of_app earlier ?
+ ret["manifest"]["install"] = _set_default_ask_questions(
+ ret["manifest"].get("install", {})
)
- ret["settings"] = settings
ret["from_catalog"] = from_catalog
+ # Hydrate app notifications and doc
+ for pagename, content_per_lang in ret["manifest"]["doc"].items():
+ for lang, content in content_per_lang.items():
+ ret["manifest"]["doc"][pagename][lang] = _hydrate_app_template(
+ content, settings
+ )
+
+ # Filter dismissed notification
+ ret["manifest"]["notifications"] = {
+ k: v
+ for k, v in ret["manifest"]["notifications"].items()
+ if not _notification_is_dismissed(k, settings)
+ }
+
+ # Hydrate notifications (also filter uneeded post_upgrade notification based on version)
+ for step, notifications in ret["manifest"]["notifications"].items():
+ for name, content_per_lang in notifications.items():
+ for lang, content in content_per_lang.items():
+ notifications[name][lang] = _hydrate_app_template(content, settings)
+
ret["is_webapp"] = "domain" in settings and "path" in settings
if ret["is_webapp"]:
@@ -185,8 +221,8 @@ def app_info(app, full=False, upgradable=False):
ret["supports_backup_restore"] = os.path.exists(
os.path.join(setting_path, "scripts", "backup")
) and os.path.exists(os.path.join(setting_path, "scripts", "restore"))
- ret["supports_multi_instance"] = is_true(
- local_manifest.get("multi_instance", False)
+ ret["supports_multi_instance"] = local_manifest.get("integration", {}).get(
+ "multi_instance", False
)
ret["supports_config_panel"] = os.path.exists(
os.path.join(setting_path, "config_panel.toml")
@@ -202,8 +238,6 @@ def app_info(app, full=False, upgradable=False):
def _app_upgradable(app_infos):
- from packaging import version
-
# Determine upgradability
app_in_catalog = app_infos.get("from_catalog")
@@ -339,7 +373,6 @@ def app_map(app=None, raw=False, user=None):
)
for url in perm_all_urls:
-
# Here, we decide to completely ignore regex-type urls ...
# Because :
# - displaying them in regular "yunohost app map" output creates
@@ -378,7 +411,7 @@ def app_change_url(operation_logger, app, domain, path):
path -- New path at which the application will be move
"""
- from yunohost.hook import hook_exec, hook_callback
+ from yunohost.hook import hook_exec_with_script_debug_if_failure, hook_callback
from yunohost.service import service_reload_or_restart
installed = _is_installed(app)
@@ -397,10 +430,10 @@ def app_change_url(operation_logger, app, domain, path):
# Normalize path and domain format
- domain = DomainQuestion.normalize(domain)
- old_domain = DomainQuestion.normalize(old_domain)
- path = PathQuestion.normalize(path)
- old_path = PathQuestion.normalize(old_path)
+ domain = DomainOption.normalize(domain)
+ old_domain = DomainOption.normalize(old_domain)
+ path = WebPathOption.normalize(path)
+ old_path = WebPathOption.normalize(old_path)
if (domain, path) == (old_domain, old_path):
raise YunohostValidationError(
@@ -412,51 +445,102 @@ def app_change_url(operation_logger, app, domain, path):
_validate_webpath_requirement(
{"domain": domain, "path": path}, path_requirement, ignore_app=app
)
+ if path_requirement == "full_domain" and path != "/":
+ raise YunohostValidationError("app_change_url_require_full_domain", app=app)
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, workdir=tmp_workdir_for_app)
+ env_dict = _make_environment_for_app_script(
+ app, workdir=tmp_workdir_for_app, action="change_url"
+ )
+
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["old_domain"] = old_domain
+ env_dict["old_path"] = old_path
+ env_dict["new_domain"] = domain
+ env_dict["new_path"] = path
+ env_dict["change_path"] = "1" if old_path != path else "0"
+ env_dict["change_domain"] = "1" if old_domain != domain else "0"
+
if domain != old_domain:
operation_logger.related_to.append(("domain", old_domain))
operation_logger.extra.update({"env": env_dict})
operation_logger.start()
+ old_nginx_conf_path = f"/etc/nginx/conf.d/{old_domain}.d/{app}.conf"
+ new_nginx_conf_path = f"/etc/nginx/conf.d/{domain}.d/{app}.conf"
+ old_nginx_conf_backup = None
+ if not os.path.exists(old_nginx_conf_path):
+ logger.warning(
+ f"Current nginx config file {old_nginx_conf_path} doesn't seem to exist ... wtf ?"
+ )
+ else:
+ old_nginx_conf_backup = read_file(old_nginx_conf_path)
+
change_url_script = os.path.join(tmp_workdir_for_app, "scripts/change_url")
# Execute App change_url script
- ret = hook_exec(change_url_script, env=env_dict)[0]
- if ret != 0:
- msg = f"Failed to change '{app}' url."
- logger.error(msg)
- operation_logger.error(msg)
+ change_url_failed = True
+ try:
+ (
+ change_url_failed,
+ failure_message_with_debug_instructions,
+ ) = hook_exec_with_script_debug_if_failure(
+ change_url_script,
+ env=env_dict,
+ operation_logger=operation_logger,
+ error_message_if_script_failed=m18n.n("app_change_url_script_failed"),
+ error_message_if_failed=lambda e: m18n.n(
+ "app_change_url_failed", app=app, error=e
+ ),
+ )
+ finally:
+ shutil.rmtree(tmp_workdir_for_app)
- # restore values modified by app_checkurl
- # see begining of the function
- app_setting(app, "domain", value=old_domain)
- app_setting(app, "path", value=old_path)
- return
- shutil.rmtree(tmp_workdir_for_app)
+ if change_url_failed:
+ logger.warning("Restoring initial nginx config file")
+ if old_nginx_conf_path != new_nginx_conf_path and os.path.exists(
+ new_nginx_conf_path
+ ):
+ rm(new_nginx_conf_path, force=True)
+ if old_nginx_conf_backup:
+ write_to_file(old_nginx_conf_path, old_nginx_conf_backup)
+ service_reload_or_restart("nginx")
- # this should idealy be done in the change_url script but let's avoid common mistakes
- app_setting(app, "domain", value=domain)
- app_setting(app, "path", value=path)
+ # restore values modified by app_checkurl
+ # see begining of the function
+ app_setting(app, "domain", value=old_domain)
+ app_setting(app, "path", value=old_path)
+ raise YunohostError(failure_message_with_debug_instructions, raw_msg=True)
+ else:
+ # make sure the domain/path setting are propagated
+ app_setting(app, "domain", value=domain)
+ app_setting(app, "path", value=path)
- app_ssowatconf()
+ app_ssowatconf()
- service_reload_or_restart("nginx")
+ service_reload_or_restart("nginx")
- logger.success(m18n.n("app_change_url_success", app=app, domain=domain, path=path))
+ logger.success(
+ m18n.n("app_change_url_success", app=app, domain=domain, path=path)
+ )
- hook_callback("post_app_change_url", env=env_dict)
+ hook_callback("post_app_change_url", env=env_dict)
-def app_upgrade(app=[], url=None, file=None, force=False, no_safety_backup=False):
+def app_upgrade(
+ app=[],
+ url=None,
+ file=None,
+ force=False,
+ no_safety_backup=False,
+ continue_on_failure=False,
+):
"""
Upgrade app
@@ -467,7 +551,6 @@ def app_upgrade(app=[], url=None, file=None, force=False, no_safety_backup=False
no_safety_backup -- Disable the safety backup during upgrade
"""
- from packaging import version
from yunohost.hook import (
hook_add,
hook_remove,
@@ -477,6 +560,12 @@ def app_upgrade(app=[], url=None, file=None, force=False, no_safety_backup=False
from yunohost.permission import permission_sync_to_user
from yunohost.regenconf import manually_modified_files
from yunohost.utils.legacy import _patch_legacy_php_versions, _patch_legacy_helpers
+ from yunohost.backup import (
+ backup_list,
+ backup_create,
+ backup_delete,
+ backup_restore,
+ )
apps = app
# Check if disk space available
@@ -502,6 +591,9 @@ def app_upgrade(app=[], url=None, file=None, force=False, no_safety_backup=False
if len(apps) > 1:
logger.info(m18n.n("app_upgrade_several_apps", apps=", ".join(apps)))
+ notifications = {}
+ failed_to_upgrade_apps = []
+
for number, app_instance_name in enumerate(apps):
logger.info(m18n.n("app_upgrade_app_name", app=app_instance_name))
@@ -563,34 +655,119 @@ def app_upgrade(app=[], url=None, file=None, force=False, no_safety_backup=False
upgrade_type = "UPGRADE_FULL"
# Check requirements
- _check_manifest_requirements(manifest)
+ for name, passed, values, err in _check_manifest_requirements(
+ manifest, action="upgrade"
+ ):
+ if not passed:
+ if name == "ram":
+ # i18n: confirm_app_insufficient_ram
+ _ask_confirmation(
+ "confirm_app_insufficient_ram", params=values, force=force
+ )
+ else:
+ raise YunohostValidationError(err, **values)
+
+ # Display pre-upgrade notifications and ask for simple confirm
+ if (
+ manifest["notifications"]["PRE_UPGRADE"]
+ and Moulinette.interface.type == "cli"
+ ):
+ settings = _get_app_settings(app_instance_name)
+ notifications = _filter_and_hydrate_notifications(
+ manifest["notifications"]["PRE_UPGRADE"],
+ current_version=app_current_version,
+ data=settings,
+ )
+ _display_notifications(notifications, force=force)
+
+ if manifest["packaging_format"] >= 2:
+ if no_safety_backup:
+ # FIXME: i18n
+ logger.warning(
+ "Skipping the creation of a backup prior to the upgrade."
+ )
+ else:
+ # FIXME: i18n
+ logger.info("Creating a safety backup prior to the upgrade")
+
+ # Switch between pre-upgrade1 or pre-upgrade2
+ safety_backup_name = f"{app_instance_name}-pre-upgrade1"
+ other_safety_backup_name = f"{app_instance_name}-pre-upgrade2"
+ if safety_backup_name in backup_list()["archives"]:
+ safety_backup_name = f"{app_instance_name}-pre-upgrade2"
+ other_safety_backup_name = f"{app_instance_name}-pre-upgrade1"
+
+ backup_create(
+ name=safety_backup_name, apps=[app_instance_name], system=None
+ )
+
+ if safety_backup_name in backup_list()["archives"]:
+ # if the backup suceeded, delete old safety backup to save space
+ if other_safety_backup_name in backup_list()["archives"]:
+ backup_delete(other_safety_backup_name)
+ else:
+ # Is this needed ? Shouldn't backup_create report an expcetion if backup failed ?
+ raise YunohostError(
+ "Uhoh the safety backup failed ?! Aborting the upgrade process.",
+ raw_msg=True,
+ )
+
_assert_system_is_sane_for_app(manifest, "pre")
- app_setting_path = os.path.join(APPS_SETTING_PATH, app_instance_name)
-
- # Prepare env. var. to pass to script
- env_dict = _make_environment_for_app_script(
- app_instance_name, workdir=extracted_app_folder
- )
- env_dict["YNH_APP_UPGRADE_TYPE"] = upgrade_type
- 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"
-
# We'll check that the app didn't brutally edit some system configuration
manually_modified_files_before_install = manually_modified_files()
+ app_setting_path = os.path.join(APPS_SETTING_PATH, app_instance_name)
+
# Attempt to patch legacy helpers ...
_patch_legacy_helpers(extracted_app_folder)
# Apply dirty patch to make php5 apps compatible with php7
_patch_legacy_php_versions(extracted_app_folder)
+ # Prepare env. var. to pass to script
+ env_dict = _make_environment_for_app_script(
+ app_instance_name, workdir=extracted_app_folder, action="upgrade"
+ )
+
+ env_dict_more = {
+ "YNH_APP_UPGRADE_TYPE": upgrade_type,
+ "YNH_APP_MANIFEST_VERSION": str(app_new_version),
+ "YNH_APP_CURRENT_VERSION": str(app_current_version),
+ }
+
+ if manifest["packaging_format"] < 2:
+ env_dict_more["NO_BACKUP_UPGRADE"] = "1" if no_safety_backup else "0"
+
+ env_dict.update(env_dict_more)
+
# Start register change on system
related_to = [("app", app_instance_name)]
operation_logger = OperationLogger("app_upgrade", related_to, env=env_dict)
operation_logger.start()
+ if manifest["packaging_format"] >= 2:
+ from yunohost.utils.resources import AppResourceManager
+
+ AppResourceManager(
+ app_instance_name, wanted=manifest, current=app_dict["manifest"]
+ ).apply(
+ rollback_and_raise_exception_if_failure=True,
+ operation_logger=operation_logger,
+ action="upgrade",
+ )
+
+ # Boring stuff : the resource upgrade may have added/remove/updated setting
+ # so we need to reflect this in the env_dict used to call the actual upgrade script x_x
+ # Or: the old manifest may be in v1 and the new in v2, so force to add the setting in env
+ env_dict = _make_environment_for_app_script(
+ app_instance_name,
+ workdir=extracted_app_folder,
+ action="upgrade",
+ force_include_app_settings=True,
+ )
+ env_dict.update(env_dict_more)
+
# Execute the app upgrade script
upgrade_failed = True
try:
@@ -607,6 +784,25 @@ def app_upgrade(app=[], url=None, file=None, force=False, no_safety_backup=False
),
)
finally:
+ # If upgrade failed, try to restore the safety backup
+ if (
+ upgrade_failed
+ and manifest["packaging_format"] >= 2
+ and not no_safety_backup
+ ):
+ logger.warning(
+ "Upgrade failed ... attempting to restore the satefy backup (Yunohost first need to remove the app for this) ..."
+ )
+
+ app_remove(app_instance_name, force_workdir=extracted_app_folder)
+ backup_restore(
+ name=safety_backup_name, apps=[app_instance_name], force=True
+ )
+ if not _is_installed(app_instance_name):
+ logger.error(
+ "Uhoh ... Yunohost failed to restore the app to the way it was before the failed upgrade :|"
+ )
+
# Whatever happened (install success or failure) we check if it broke the system
# and warn the user about it
try:
@@ -633,21 +829,51 @@ def app_upgrade(app=[], url=None, file=None, force=False, no_safety_backup=False
# If upgrade failed or broke the system,
# raise an error and interrupt all other pending upgrades
if upgrade_failed or broke_the_system:
+ if not continue_on_failure or broke_the_system:
+ # display this if there are remaining apps
+ if apps[number + 1 :]:
+ not_upgraded_apps = apps[number:]
+ if broke_the_system and not continue_on_failure:
+ logger.error(
+ m18n.n(
+ "app_not_upgraded_broken_system",
+ failed_app=app_instance_name,
+ apps=", ".join(not_upgraded_apps),
+ )
+ )
+ elif broke_the_system and continue_on_failure:
+ logger.error(
+ m18n.n(
+ "app_not_upgraded_broken_system_continue",
+ failed_app=app_instance_name,
+ apps=", ".join(not_upgraded_apps),
+ )
+ )
+ else:
+ logger.error(
+ m18n.n(
+ "app_not_upgraded",
+ failed_app=app_instance_name,
+ apps=", ".join(not_upgraded_apps),
+ )
+ )
- # display this if there are remaining apps
- if apps[number + 1 :]:
- not_upgraded_apps = apps[number:]
- logger.error(
- m18n.n(
- "app_not_upgraded",
- failed_app=app_instance_name,
- apps=", ".join(not_upgraded_apps),
- )
+ raise YunohostError(
+ failure_message_with_debug_instructions, raw_msg=True
)
- raise YunohostError(
- failure_message_with_debug_instructions, raw_msg=True
- )
+ else:
+ operation_logger.close()
+ logger.error(
+ m18n.n(
+ "app_failed_to_upgrade_but_continue",
+ failed_app=app_instance_name,
+ operation_logger_name=operation_logger.name,
+ )
+ )
+ failed_to_upgrade_apps.append(
+ (app_instance_name, operation_logger.name)
+ )
# Otherwise we're good and keep going !
now = int(time.time())
@@ -684,6 +910,24 @@ def app_upgrade(app=[], url=None, file=None, force=False, no_safety_backup=False
# So much win
logger.success(m18n.n("app_upgraded", app=app_instance_name))
+ # Format post-upgrade notifications
+ if manifest["notifications"]["POST_UPGRADE"]:
+ # Get updated settings to hydrate notifications
+ settings = _get_app_settings(app_instance_name)
+ notifications = _filter_and_hydrate_notifications(
+ manifest["notifications"]["POST_UPGRADE"],
+ current_version=app_current_version,
+ data=settings,
+ )
+ if Moulinette.interface.type == "cli":
+ # ask for simple confirm
+ _display_notifications(notifications, force=force)
+
+ # Reset the dismiss flag for post upgrade notification
+ app_setting(
+ app_instance_name, "_dismiss_notification_post_upgrade", delete=True
+ )
+
hook_callback("post_app_upgrade", env=env_dict)
operation_logger.success()
@@ -691,19 +935,83 @@ def app_upgrade(app=[], url=None, file=None, force=False, no_safety_backup=False
logger.success(m18n.n("upgrade_complete"))
+ if failed_to_upgrade_apps:
+ apps = ""
+ for app_id, operation_logger_name in failed_to_upgrade_apps:
+ apps += m18n.n(
+ "apps_failed_to_upgrade_line",
+ app_id=app_id,
+ operation_logger_name=operation_logger_name,
+ )
-def app_manifest(app):
+ logger.warning(m18n.n("apps_failed_to_upgrade", apps=apps))
+ if Moulinette.interface.type == "api":
+ return {"notifications": {"POST_UPGRADE": notifications}}
+
+
+def app_manifest(app, with_screenshot=False):
manifest, extracted_app_folder = _extract_app(app)
+ raw_questions = manifest.get("install", {}).values()
+ manifest["install"] = hydrate_questions_with_choices(raw_questions)
+
+ # Add a base64 image to be displayed in web-admin
+ if with_screenshot and Moulinette.interface.type == "api":
+ import base64
+
+ manifest["screenshot"] = None
+ screenshots_folder = os.path.join(extracted_app_folder, "doc", "screenshots")
+
+ if os.path.exists(screenshots_folder):
+ with os.scandir(screenshots_folder) as it:
+ for entry in it:
+ ext = os.path.splitext(entry.name)[1].replace(".", "").lower()
+ if entry.is_file() and ext in ("png", "jpg", "jpeg", "webp", "gif"):
+ with open(entry.path, "rb") as img_file:
+ data = base64.b64encode(img_file.read()).decode("utf-8")
+ manifest[
+ "screenshot"
+ ] = f"data:image/{ext};charset=utf-8;base64,{data}"
+ break
+
shutil.rmtree(extracted_app_folder)
- raw_questions = manifest.get("arguments", {}).get("install", [])
- manifest["arguments"]["install"] = hydrate_questions_with_choices(raw_questions)
+ manifest["requirements"] = {}
+ for name, passed, values, err in _check_manifest_requirements(
+ manifest, action="install"
+ ):
+ if Moulinette.interface.type == "api":
+ manifest["requirements"][name] = {
+ "pass": passed,
+ "values": values,
+ }
+ else:
+ manifest["requirements"][name] = "ok" if passed else m18n.n(err, **values)
return manifest
+def _confirm_app_install(app, force=False):
+ # Ignore if there's nothing for confirm (good quality app), if --force is used
+ # or if request on the API (confirm already implemented on the API side)
+ if force or Moulinette.interface.type == "api":
+ return
+
+ quality = _app_quality(app)
+ if quality == "success":
+ return
+
+ # i18n: confirm_app_install_warning
+ # i18n: confirm_app_install_danger
+ # i18n: confirm_app_install_thirdparty
+
+ if quality in ["danger", "thirdparty"]:
+ _ask_confirmation("confirm_app_install_" + quality, kind="hard")
+ else:
+ _ask_confirmation("confirm_app_install_" + quality, kind="soft")
+
+
@is_unit_operation()
def app_install(
operation_logger,
@@ -745,63 +1053,50 @@ def app_install(
if free_space_in_directory("/") <= 512 * 1000 * 1000:
raise YunohostValidationError("disk_space_not_sufficient_install")
- def confirm_install(app):
-
- # Ignore if there's nothing for confirm (good quality app), if --force is used
- # or if request on the API (confirm already implemented on the API side)
- if force or Moulinette.interface.type == "api":
- return
-
- quality = _app_quality(app)
- if quality == "success":
- return
-
- # i18n: confirm_app_install_warning
- # i18n: confirm_app_install_danger
- # i18n: confirm_app_install_thirdparty
-
- if quality in ["danger", "thirdparty"]:
- answer = Moulinette.prompt(
- m18n.n("confirm_app_install_" + quality, answers="Yes, I understand"),
- color="red",
- )
- if answer != "Yes, I understand":
- raise YunohostError("aborting")
-
- else:
- answer = Moulinette.prompt(
- m18n.n("confirm_app_install_" + quality, answers="Y/N"), color="yellow"
- )
- if answer.upper() != "Y":
- raise YunohostError("aborting")
-
- confirm_install(app)
+ _confirm_app_install(app, force)
manifest, extracted_app_folder = _extract_app(app)
+ # Display pre_install notices in cli mode
+ if manifest["notifications"]["PRE_INSTALL"] and Moulinette.interface.type == "cli":
+ notifications = _filter_and_hydrate_notifications(
+ manifest["notifications"]["PRE_INSTALL"]
+ )
+ _display_notifications(notifications, force=force)
+
+ packaging_format = manifest["packaging_format"]
+
# Check ID
if "id" not in manifest or "__" in manifest["id"] or "." in manifest["id"]:
raise YunohostValidationError("app_id_invalid")
app_id = manifest["id"]
- label = label if label else manifest["name"]
# Check requirements
- _check_manifest_requirements(manifest)
+ for name, passed, values, err in _check_manifest_requirements(
+ manifest, action="install"
+ ):
+ if not passed:
+ if name == "ram":
+ _ask_confirmation(
+ "confirm_app_insufficient_ram", params=values, force=force
+ )
+ else:
+ raise YunohostValidationError(err, **values)
+
_assert_system_is_sane_for_app(manifest, "pre")
# Check if app can be forked
instance_number = _next_instance_number_for_app(app_id)
if instance_number > 1:
- if "multi_instance" not in manifest or not is_true(manifest["multi_instance"]):
- raise YunohostValidationError("app_already_installed", app=app_id)
-
# Change app_id to the forked app id
app_instance_name = app_id + "__" + str(instance_number)
else:
app_instance_name = app_id
+ app_setting_path = os.path.join(APPS_SETTING_PATH, app_instance_name)
+
# Retrieve arguments list for install script
- raw_questions = manifest.get("arguments", {}).get("install", {})
+ raw_questions = manifest["install"]
questions = ask_questions_and_parse_answers(raw_questions, prefilled_answers=args)
args = {
question.name: question.value
@@ -810,11 +1105,13 @@ def app_install(
}
# Validate domain / path availability for webapps
+ # (ideally this should be handled by the resource system for manifest v >= 2
path_requirement = _guess_webapp_path_requirement(extracted_app_folder)
_validate_webpath_requirement(args, path_requirement)
- # Attempt to patch legacy helpers ...
- _patch_legacy_helpers(extracted_app_folder)
+ if packaging_format < 2:
+ # Attempt to patch legacy helpers ...
+ _patch_legacy_helpers(extracted_app_folder)
# Apply dirty patch to make php5 apps compatible with php7
_patch_legacy_php_versions(extracted_app_folder)
@@ -831,7 +1128,6 @@ def app_install(
logger.info(m18n.n("app_start_install", app=app_id))
# Create app directory
- app_setting_path = os.path.join(APPS_SETTING_PATH, app_instance_name)
if os.path.exists(app_setting_path):
shutil.rmtree(app_setting_path)
os.makedirs(app_setting_path)
@@ -842,6 +1138,17 @@ def app_install(
"install_time": int(time.time()),
"current_revision": manifest.get("remote", {}).get("revision", "?"),
}
+
+ # If packaging_format v2+, save all install questions as settings
+ if packaging_format >= 2:
+ for question in questions:
+ # Except user-provider passwords
+ # ... which we need to reinject later in the env_dict
+ if question.type == "password":
+ continue
+
+ app_settings[question.name] = question.value
+
_set_app_settings(app_instance_name, app_settings)
# Move scripts and manifest to the right place
@@ -853,28 +1160,59 @@ def app_install(
recursive=True,
)
- # Initialize the main permission for the app
- # The permission is initialized with no url associated, and with tile disabled
- # For web app, the root path of the app will be added as url and the tile
- # will be enabled during the app install. C.f. 'app_register_url()' below.
- permission_create(
- app_instance_name + ".main",
- allowed=["all_users"],
- label=label,
- show_tile=False,
- protected=False,
- )
+ # Override manifest name by given label
+ # This info is also later picked-up by the 'permission' resource initialization
+ if label:
+ manifest["name"] = label
+
+ if packaging_format >= 2:
+ from yunohost.utils.resources import AppResourceManager
+
+ try:
+ AppResourceManager(app_instance_name, wanted=manifest, current={}).apply(
+ rollback_and_raise_exception_if_failure=True,
+ operation_logger=operation_logger,
+ action="install",
+ )
+ except (KeyboardInterrupt, EOFError, Exception) as e:
+ shutil.rmtree(app_setting_path)
+ raise e
+ else:
+ # Initialize the main permission for the app
+ # The permission is initialized with no url associated, and with tile disabled
+ # For web app, the root path of the app will be added as url and the tile
+ # will be enabled during the app install. C.f. 'app_register_url()' below
+ # or the webpath resource
+ permission_create(
+ app_instance_name + ".main",
+ allowed=["all_users"],
+ label=manifest["name"],
+ show_tile=False,
+ protected=False,
+ )
# Prepare env. var. to pass to script
env_dict = _make_environment_for_app_script(
- app_instance_name, args=args, workdir=extracted_app_folder
+ app_instance_name, args=args, workdir=extracted_app_folder, action="install"
)
+ # If packaging_format v2+, save all install questions as settings
+ if packaging_format >= 2:
+ for question in questions:
+ # Reinject user-provider passwords which are not in the app settings
+ # (cf a few line before)
+ if question.type == "password":
+ env_dict[question.name] = question.value
+
+ # We want to hav the env_dict in the log ... but not password values
env_dict_for_logging = env_dict.copy()
for question in questions:
# Or should it be more generally question.redact ?
if question.type == "password":
- del env_dict_for_logging[f"YNH_APP_ARG_{question.name.upper()}"]
+ if f"YNH_APP_ARG_{question.name.upper()}" in env_dict_for_logging:
+ del env_dict_for_logging[f"YNH_APP_ARG_{question.name.upper()}"]
+ if question.name in env_dict_for_logging:
+ del env_dict_for_logging[question.name]
operation_logger.extra.update({"env": env_dict_for_logging})
@@ -914,10 +1252,12 @@ def app_install(
"Packagers /!\\ This app manually modified some system configuration files! This should not happen! If you need to do so, you should implement a proper conf_regen hook. Those configuration were affected:\n - "
+ "\n -".join(manually_modified_files_by_app)
)
+ # Actually forbid this for app packaging >= 2
+ if packaging_format >= 2:
+ broke_the_system = True
# If the install failed or broke the system, we remove it
if install_failed or broke_the_system:
-
# This option is meant for packagers to debug their apps more easily
if no_remove_on_failure:
raise YunohostError(
@@ -929,7 +1269,7 @@ def app_install(
# Setup environment for remove script
env_dict_remove = _make_environment_for_app_script(
- app_instance_name, workdir=extracted_app_folder
+ app_instance_name, workdir=extracted_app_folder, action="remove"
)
# Execute remove script
@@ -960,10 +1300,17 @@ def app_install(
m18n.n("unexpected_error", error="\n" + traceback.format_exc())
)
- # Remove all permission in LDAP
- for permission_name in user_permission_list()["permissions"].keys():
- if permission_name.startswith(app_instance_name + "."):
- permission_delete(permission_name, force=True, sync_perm=False)
+ if packaging_format >= 2:
+ from yunohost.utils.resources import AppResourceManager
+
+ AppResourceManager(
+ app_instance_name, wanted={}, current=manifest
+ ).apply(rollback_and_raise_exception_if_failure=False, action="remove")
+ else:
+ # Remove all permission in LDAP
+ for permission_name in user_permission_list()["permissions"].keys():
+ if permission_name.startswith(app_instance_name + "."):
+ permission_delete(permission_name, force=True, sync_perm=False)
if remove_retcode != 0:
msg = m18n.n("app_not_properly_removed", app=app_instance_name)
@@ -999,18 +1346,33 @@ def app_install(
logger.success(m18n.n("installation_complete"))
+ # Get the generated settings to hydrate notifications
+ settings = _get_app_settings(app_instance_name)
+ notifications = _filter_and_hydrate_notifications(
+ manifest["notifications"]["POST_INSTALL"], data=settings
+ )
+
+ # Display post_install notices in cli mode
+ if notifications and Moulinette.interface.type == "cli":
+ _display_notifications(notifications, force=force)
+
+ # Call postinstall hook
hook_callback("post_app_install", env=env_dict)
+ # Return hydrated post install notif for API
+ if Moulinette.interface.type == "api":
+ return {"notifications": notifications}
+
@is_unit_operation()
-def app_remove(operation_logger, app, purge=False):
+def app_remove(operation_logger, app, purge=False, force_workdir=None):
"""
Remove app
Keyword arguments:
app -- App(s) to delete
purge -- Remove with all app data
-
+ force_workdir -- Special var to force the working directoy to use, in context such as remove-after-failed-upgrade or remove-after-failed-restore
"""
from yunohost.utils.legacy import _patch_legacy_php_versions, _patch_legacy_helpers
from yunohost.hook import hook_exec, hook_remove, hook_callback
@@ -1029,7 +1391,6 @@ def app_remove(operation_logger, app, purge=False):
operation_logger.start()
logger.info(m18n.n("app_start_remove", app=app))
-
app_setting_path = os.path.join(APPS_SETTING_PATH, app)
# Attempt to patch legacy helpers ...
@@ -1039,12 +1400,26 @@ def app_remove(operation_logger, app, purge=False):
# script might date back from jessie install)
_patch_legacy_php_versions(app_setting_path)
- manifest = _get_manifest_of_app(app_setting_path)
- tmp_workdir_for_app = _make_tmp_workdir_for_app(app=app)
+ if force_workdir:
+ # This is when e.g. calling app_remove() from the upgrade-failed case
+ # where we want to remove using the *new* remove script and not the old one
+ # and also get the new manifest
+ # It's especially important during v1->v2 app format transition where the
+ # setting names change (e.g. install_dir instead of final_path) and
+ # running the old remove script doesnt make sense anymore ...
+ tmp_workdir_for_app = tempfile.mkdtemp(prefix="app_", dir=APP_TMP_WORKDIRS)
+ os.system(f"cp -a {force_workdir}/* {tmp_workdir_for_app}/")
+ else:
+ tmp_workdir_for_app = _make_tmp_workdir_for_app(app=app)
+
+ manifest = _get_manifest_of_app(tmp_workdir_for_app)
+
remove_script = f"{tmp_workdir_for_app}/scripts/remove"
env_dict = {}
- env_dict = _make_environment_for_app_script(app, workdir=tmp_workdir_for_app)
+ env_dict = _make_environment_for_app_script(
+ app, workdir=tmp_workdir_for_app, action="remove"
+ )
env_dict["YNH_APP_PURGE"] = str(1 if purge else 0)
operation_logger.extra.update({"env": env_dict})
@@ -1064,15 +1439,19 @@ def app_remove(operation_logger, app, purge=False):
finally:
shutil.rmtree(tmp_workdir_for_app)
- if ret == 0:
- logger.success(m18n.n("app_removed", app=app))
- hook_callback("post_app_remove", env=env_dict)
- else:
- logger.warning(m18n.n("app_not_properly_removed", app=app))
+ packaging_format = manifest["packaging_format"]
+ if packaging_format >= 2:
+ from yunohost.utils.resources import AppResourceManager
- # Remove all permission in LDAP
- for permission_name in user_permission_list(apps=[app])["permissions"].keys():
- permission_delete(permission_name, force=True, sync_perm=False)
+ AppResourceManager(app, wanted={}, current=manifest).apply(
+ rollback_and_raise_exception_if_failure=False,
+ purge_data_dir=purge,
+ action="remove",
+ )
+ else:
+ # Remove all permission in LDAP
+ for permission_name in user_permission_list(apps=[app])["permissions"].keys():
+ permission_delete(permission_name, force=True, sync_perm=False)
if os.path.exists(app_setting_path):
shutil.rmtree(app_setting_path)
@@ -1083,6 +1462,12 @@ def app_remove(operation_logger, app, purge=False):
if domain_config_get(domain, "feature.app.default_app") == app:
domain_config_set(domain, "feature.app.default_app", "_none")
+ if ret == 0:
+ logger.success(m18n.n("app_removed", app=app))
+ hook_callback("post_app_remove", env=env_dict)
+ else:
+ logger.warning(m18n.n("app_not_properly_removed", app=app))
+
permission_sync_to_user()
_assert_system_is_sane_for_app(manifest, "post")
@@ -1140,7 +1525,6 @@ def app_setting(app, key, value=None, delete=False):
)
if is_legacy_permission_setting:
-
from yunohost.permission import (
user_permission_list,
user_permission_update,
@@ -1183,7 +1567,6 @@ def app_setting(app, key, value=None, delete=False):
# SET
else:
-
urls = value
# If the request is about the root of the app (/), ( = the vast majority of cases)
# we interpret this as a change for the main permission
@@ -1195,7 +1578,6 @@ def app_setting(app, key, value=None, delete=False):
else:
user_permission_update(app + ".main", remove="visitors")
else:
-
urls = urls.split(",")
if key.endswith("_regex"):
urls = ["re:" + url for url in urls]
@@ -1264,6 +1646,23 @@ def app_setting(app, key, value=None, delete=False):
_set_app_settings(app, app_settings)
+def app_shell(app):
+ """
+ Open an interactive shell with the app environment already loaded
+
+ Keyword argument:
+ app -- App ID
+
+ """
+ subprocess.run(
+ [
+ "/bin/bash",
+ "-c",
+ "source /usr/share/yunohost/helpers && ynh_spawn_app_shell " + app,
+ ]
+ )
+
+
def app_register_url(app, domain, path):
"""
Book/register a web path for a given app
@@ -1279,8 +1678,8 @@ def app_register_url(app, domain, path):
permission_sync_to_user,
)
- domain = DomainQuestion.normalize(domain)
- path = PathQuestion.normalize(path)
+ domain = DomainOption.normalize(domain)
+ path = WebPathOption.normalize(path)
# We cannot change the url of an app already installed simply by changing
# the settings...
@@ -1291,7 +1690,7 @@ def app_register_url(app, domain, path):
raise YunohostValidationError("app_already_installed_cant_change_url")
# Check the url is available
- _assert_no_conflicting_apps(domain, path)
+ _assert_no_conflicting_apps(domain, path, ignore_app=app)
app_setting(app, "domain", value=domain)
app_setting(app, "path", value=path)
@@ -1315,6 +1714,7 @@ def app_ssowatconf():
"""
from yunohost.domain import domain_list, _get_maindomain, domain_config_get
from yunohost.permission import user_permission_list
+ from yunohost.settings import settings_get
main_domain = _get_maindomain()
domains = domain_list()["domains"]
@@ -1333,6 +1733,7 @@ def app_ssowatconf():
+ [domain + "/yunohost/api" for domain in domains]
+ [domain + "/yunohost/portalapi" for domain in domains]
+ [
+ "re:^[^/]/502%.html$",
"re:^[^/]*/%.well%-known/ynh%-diagnosis/.*$",
"re:^[^/]*/%.well%-known/acme%-challenge/.*$",
"re:^[^/]*/%.well%-known/autoconfig/mail/config%-v1%.1%.xml.*$",
@@ -1344,9 +1745,16 @@ def app_ssowatconf():
}
redirected_urls = {}
- for app in _installed_apps():
+ apps_using_remote_user_var_in_nginx = (
+ check_output(
+ "grep -nri '$remote_user' /etc/yunohost/apps/*/conf/*nginx*conf | awk -F/ '{print $5}' || true"
+ )
+ .strip()
+ .split("\n")
+ )
- app_settings = read_yaml(APPS_SETTING_PATH + app + "/settings.yml")
+ for app in _installed_apps():
+ app_settings = read_yaml(APPS_SETTING_PATH + app + "/settings.yml") or {}
# Redirected
redirected_urls.update(app_settings.get("redirected_urls", {}))
@@ -1371,7 +1779,6 @@ def app_ssowatconf():
# New permission system
for perm_name, perm_info in all_permissions.items():
-
uris = (
[]
+ ([perm_info["url"]] if perm_info["url"] else [])
@@ -1382,7 +1789,11 @@ def app_ssowatconf():
if not uris:
continue
+ app_id = perm_name.split(".")[0]
+
permissions[perm_name] = {
+ "use_remote_user_var_in_nginx_conf": app_id
+ in apps_using_remote_user_var_in_nginx,
"users": perm_info["corresponding_users"],
"label": perm_info["label"],
"show_tile": perm_info["show_tile"]
@@ -1396,6 +1807,7 @@ def app_ssowatconf():
conf_dict = {
"cookie_secret_file": "/etc/yunohost/.ssowat_cookie_secret",
"cookie_name": "yunohost.portal",
+ "theme": settings_get("misc.portal.portal_theme"),
"portal_domain": main_domain,
"portal_path": "/yunohost/sso/",
"additional_headers": {
@@ -1432,90 +1844,14 @@ def app_change_label(app, new_label):
def app_action_list(app):
- logger.warning(m18n.n("experimental_feature"))
-
- # this will take care of checking if the app is installed
- app_info_dict = app_info(app)
-
- return {
- "app": app,
- "app_name": app_info_dict["name"],
- "actions": _get_app_actions(app),
- }
+ return AppConfigPanel(app).list_actions()
@is_unit_operation()
-def app_action_run(operation_logger, app, action, args=None):
- logger.warning(m18n.n("experimental_feature"))
-
- from yunohost.hook import hook_exec
-
- # will raise if action doesn't exist
- actions = app_action_list(app)["actions"]
- actions = {x["id"]: x for x in actions}
-
- if action not in actions:
- available_actions = (", ".join(actions.keys()),)
- raise YunohostValidationError(
- f"action '{action}' not available for app '{app}', available actions are: {available_actions}",
- raw_msg=True,
- )
-
- operation_logger.start()
-
- action_declaration = actions[action]
-
- # Retrieve arguments list for install script
- raw_questions = actions[action].get("arguments", {})
- questions = ask_questions_and_parse_answers(raw_questions, prefilled_answers=args)
- args = {
- question.name: question.value
- for question in questions
- if question.value is not None
- }
-
- tmp_workdir_for_app = _make_tmp_workdir_for_app(app=app)
-
- env_dict = _make_environment_for_app_script(
- app, args=args, args_prefix="ACTION_", workdir=tmp_workdir_for_app
+def app_action_run(operation_logger, app, action, args=None, args_file=None):
+ return AppConfigPanel(app).run_action(
+ action, args=args, args_file=args_file, operation_logger=operation_logger
)
- env_dict["YNH_ACTION"] = action
-
- _, action_script = tempfile.mkstemp(dir=tmp_workdir_for_app)
-
- with open(action_script, "w") as script:
- script.write(action_declaration["command"])
-
- if action_declaration.get("cwd"):
- cwd = action_declaration["cwd"].replace("$app", app)
- else:
- cwd = tmp_workdir_for_app
-
- 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 = f"Error while executing action '{action}' of app '{app}': return code {retcode}"
- operation_logger.error(msg)
- raise YunohostError(msg, raw_msg=True)
-
- operation_logger.success()
- return logger.success("Action successed!")
def app_config_get(app, key="", full=False, export=False):
@@ -1534,8 +1870,15 @@ def app_config_get(app, key="", full=False, export=False):
else:
mode = "classic"
- config_ = AppConfigPanel(app)
- return config_.get(key, mode)
+ try:
+ config_ = AppConfigPanel(app)
+ return config_.get(key, mode)
+ except YunohostValidationError as e:
+ if Moulinette.interface.type == "api" and e.key == "config_no_panel":
+ # Be more permissive when no config panel found
+ return {}
+ else:
+ raise
@is_unit_operation()
@@ -1556,7 +1899,11 @@ class AppConfigPanel(ConfigPanel):
save_path_tpl = os.path.join(APPS_SETTING_PATH, "{entity}/settings.yml")
config_path_tpl = os.path.join(APPS_SETTING_PATH, "{entity}/config_panel.toml")
- def _load_current_values(self):
+ def _run_action(self, action):
+ env = {key: str(value) for key, value in self.new_values.items()}
+ self._call_config_script(action, env=env)
+
+ def _get_raw_settings(self):
self.values = self._call_config_script("show")
def _apply(self):
@@ -1604,6 +1951,7 @@ ynh_app_config_run $1
"app": app,
"app_instance_nb": str(app_instance_nb),
"final_path": settings.get("final_path", ""),
+ "install_dir": settings.get("install_dir", ""),
"YNH_APP_BASEDIR": os.path.join(APPS_SETTING_PATH, app),
}
)
@@ -1612,8 +1960,10 @@ ynh_app_config_run $1
if ret != 0:
if action == "show":
raise YunohostError("app_config_unable_to_read")
- else:
+ elif action == "apply":
raise YunohostError("app_config_unable_to_apply")
+ else:
+ raise YunohostError("app_action_failed", action=action, app=app)
return values
@@ -1622,58 +1972,6 @@ def _get_app_actions(app_id):
actions_toml_path = os.path.join(APPS_SETTING_PATH, app_id, "actions.toml")
actions_json_path = os.path.join(APPS_SETTING_PATH, app_id, "actions.json")
- # sample data to get an idea of what is going on
- # this toml extract:
- #
-
- # [restart_service]
- # name = "Restart service"
- # command = "echo pouet $YNH_ACTION_SERVICE"
- # user = "root" # optional
- # cwd = "/" # optional
- # accepted_return_codes = [0, 1, 2, 3] # optional
- # description.en = "a dummy stupid exemple or restarting a service"
- #
- # [restart_service.arguments.service]
- # type = "string",
- # ask.en = "service to restart"
- # example = "nginx"
- #
- # will be parsed into this:
- #
- # OrderedDict([(u'restart_service',
- # OrderedDict([(u'name', u'Restart service'),
- # (u'command', u'echo pouet $YNH_ACTION_SERVICE'),
- # (u'user', u'root'),
- # (u'cwd', u'/'),
- # (u'accepted_return_codes', [0, 1, 2, 3]),
- # (u'description',
- # OrderedDict([(u'en',
- # u'a dummy stupid exemple or restarting a service')])),
- # (u'arguments',
- # OrderedDict([(u'service',
- # OrderedDict([(u'type', u'string'),
- # (u'ask',
- # OrderedDict([(u'en',
- # u'service to restart')])),
- # (u'example',
- # u'nginx')]))]))])),
- #
- #
- # and needs to be converted into this:
- #
- # [{u'accepted_return_codes': [0, 1, 2, 3],
- # u'arguments': [{u'ask': {u'en': u'service to restart'},
- # u'example': u'nginx',
- # u'name': u'service',
- # u'type': u'string'}],
- # u'command': u'echo pouet $YNH_ACTION_SERVICE',
- # u'cwd': u'/',
- # u'description': {u'en': u'a dummy stupid exemple or restarting a service'},
- # u'id': u'restart_service',
- # u'name': u'Restart service',
- # u'user': u'root'}]
-
if os.path.exists(actions_toml_path):
toml_actions = toml.load(open(actions_toml_path, "r"), _dict=OrderedDict)
@@ -1683,15 +1981,7 @@ def _get_app_actions(app_id):
for key, value in toml_actions.items():
action = dict(**value)
action["id"] = key
-
- arguments = []
- for argument_name, argument in value.get("arguments", {}).items():
- argument = dict(**argument)
- argument["name"] = argument_name
-
- arguments.append(argument)
-
- action["arguments"] = arguments
+ action["arguments"] = value.get("arguments", {})
actions.append(action)
return actions
@@ -1716,11 +2006,20 @@ def _get_app_settings(app):
)
try:
with open(os.path.join(APPS_SETTING_PATH, app, "settings.yml")) as f:
- settings = yaml.safe_load(f)
+ settings = yaml.safe_load(f) or {}
# If label contains unicode char, this may later trigger issues when building strings...
# FIXME: this should be propagated to read_yaml so that this fix applies everywhere I think...
settings = {k: v for k, v in settings.items()}
+ # App settings should never be empty, there should always be at least some standard, internal keys like id, install_time etc.
+ # Otherwise, this probably means that the app settings disappeared somehow...
+ if not settings:
+ logger.error(
+ f"It looks like settings.yml for {app} is empty ... This should not happen ..."
+ )
+ logger.error(m18n.n("app_not_correctly_installed", app=app))
+ return {}
+
# Stupid fix for legacy bullshit
# In the past, some setups did not have proper normalization for app domain/path
# Meaning some setups (as of January 2021) still have path=/foobar/ (with a trailing slash)
@@ -1735,6 +2034,9 @@ def _get_app_settings(app):
settings["path"] = "/" + settings["path"].strip("/")
_set_app_settings(app, settings)
+ # Make the app id available as $app too
+ settings["app"] = app
+
if app == settings["id"]:
return settings
except (IOError, TypeError, KeyError):
@@ -1862,21 +2164,7 @@ def _get_manifest_of_app(path):
# ¦ ¦ },
if os.path.exists(os.path.join(path, "manifest.toml")):
- manifest_toml = read_toml(os.path.join(path, "manifest.toml"))
-
- manifest = manifest_toml.copy()
-
- install_arguments = []
- for name, values in (
- manifest_toml.get("arguments", {}).get("install", {}).items()
- ):
- args = values.copy()
- args["name"] = name
-
- install_arguments.append(args)
-
- manifest["arguments"]["install"] = install_arguments
-
+ manifest = read_toml(os.path.join(path, "manifest.toml"))
elif os.path.exists(os.path.join(path, "manifest.json")):
manifest = read_json(os.path.join(path, "manifest.json"))
else:
@@ -1885,25 +2173,190 @@ def _get_manifest_of_app(path):
raw_msg=True,
)
- manifest["arguments"] = _set_default_ask_questions(manifest.get("arguments", {}))
+ manifest["packaging_format"] = float(
+ str(manifest.get("packaging_format", "")).strip() or "0"
+ )
+
+ if manifest["packaging_format"] < 2:
+ manifest = _convert_v1_manifest_to_v2(manifest)
+
+ manifest["install"] = _set_default_ask_questions(manifest.get("install", {}))
+ manifest["doc"], manifest["notifications"] = _parse_app_doc_and_notifications(path)
+
return manifest
-def _set_default_ask_questions(arguments):
+def _parse_app_doc_and_notifications(path):
+ doc = {}
+ notification_names = ["PRE_INSTALL", "POST_INSTALL", "PRE_UPGRADE", "POST_UPGRADE"]
+ for filepath in glob.glob(os.path.join(path, "doc") + "/*.md"):
+ # to be improved : [a-z]{2,3} is a clumsy way of parsing the
+ # lang code ... some lang code are more complex that this é_è
+ m = re.match("([A-Z]*)(_[a-z]{2,3})?.md", filepath.split("/")[-1])
+
+ if not m:
+ # FIXME: shall we display a warning ? idk
+ continue
+
+ pagename, lang = m.groups()
+
+ if pagename in notification_names:
+ continue
+
+ lang = lang.strip("_") if lang else "en"
+
+ if pagename not in doc:
+ doc[pagename] = {}
+
+ try:
+ doc[pagename][lang] = read_file(filepath).strip()
+ except Exception as e:
+ logger.error(e)
+ continue
+
+ notifications = {}
+
+ for step in notification_names:
+ notifications[step] = {}
+ for filepath in glob.glob(os.path.join(path, "doc", f"{step}*.md")):
+ m = re.match(step + "(_[a-z]{2,3})?.md", filepath.split("/")[-1])
+ if not m:
+ continue
+ pagename = "main"
+ lang = m.groups()[0].strip("_") if m.groups()[0] else "en"
+ if pagename not in notifications[step]:
+ notifications[step][pagename] = {}
+ try:
+ notifications[step][pagename][lang] = read_file(filepath).strip()
+ except Exception as e:
+ logger.error(e)
+ continue
+
+ for filepath in glob.glob(os.path.join(path, "doc", f"{step}.d") + "/*.md"):
+ m = re.match(
+ r"([A-Za-z0-9\.\~]*)(_[a-z]{2,3})?.md", filepath.split("/")[-1]
+ )
+ if not m:
+ continue
+ pagename, lang = m.groups()
+ lang = lang.strip("_") if lang else "en"
+ if pagename not in notifications[step]:
+ notifications[step][pagename] = {}
+
+ try:
+ notifications[step][pagename][lang] = read_file(filepath).strip()
+ except Exception as e:
+ logger.error(e)
+ continue
+
+ return doc, notifications
+
+
+def _hydrate_app_template(template, data):
+ stuff_to_replace = set(re.findall(r"__[A-Z0-9]+?[A-Z0-9_]*?[A-Z0-9]*?__", template))
+
+ for stuff in stuff_to_replace:
+ varname = stuff.strip("_").lower()
+
+ if varname in data:
+ template = template.replace(stuff, str(data[varname]))
+
+ return template
+
+
+def _convert_v1_manifest_to_v2(manifest):
+ manifest = copy.deepcopy(manifest)
+
+ if "upstream" not in manifest:
+ manifest["upstream"] = {}
+
+ if "license" in manifest and "license" not in manifest["upstream"]:
+ manifest["upstream"]["license"] = manifest["license"]
+
+ if "url" in manifest and "website" not in manifest["upstream"]:
+ manifest["upstream"]["website"] = manifest["url"]
+
+ manifest["integration"] = {
+ "yunohost": manifest.get("requirements", {})
+ .get("yunohost", "")
+ .replace(">", "")
+ .replace("=", "")
+ .replace(" ", ""),
+ "architectures": "?",
+ "multi_instance": manifest.get("multi_instance", False),
+ "ldap": "?",
+ "sso": "?",
+ "disk": "?",
+ "ram": {"build": "?", "runtime": "?"},
+ }
+
+ maintainers = manifest.get("maintainer", {})
+ if isinstance(maintainers, list):
+ maintainers = [m["name"] for m in maintainers]
+ else:
+ maintainers = [maintainers["name"]] if maintainers.get("name") else []
+
+ manifest["maintainers"] = maintainers
+
+ install_questions = manifest["arguments"]["install"]
+
+ manifest["install"] = {}
+ for question in install_questions:
+ name = question.pop("name")
+ if "ask" in question and name in [
+ "domain",
+ "path",
+ "admin",
+ "is_public",
+ "password",
+ ]:
+ question.pop("ask")
+ if question.get("example") and question.get("type") in [
+ "domain",
+ "path",
+ "user",
+ "boolean",
+ "password",
+ ]:
+ question.pop("example")
+
+ manifest["install"][name] = question
+
+ manifest["resources"] = {"system_user": {}, "install_dir": {"alias": "final_path"}}
+
+ keys_to_keep = [
+ "packaging_format",
+ "id",
+ "name",
+ "description",
+ "version",
+ "maintainers",
+ "upstream",
+ "integration",
+ "install",
+ "resources",
+ ]
+
+ keys_to_del = [key for key in manifest.keys() if key not in keys_to_keep]
+ for key in keys_to_del:
+ del manifest[key]
+
+ return manifest
+
+
+def _set_default_ask_questions(questions, script_name="install"):
# arguments is something like
- # { "install": [
- # { "name": "domain",
+ # { "domain":
+ # {
# "type": "domain",
# ....
# },
- # { "name": "path",
- # "type": "path"
+ # "path": {
+ # "type": "path",
# ...
# },
# ...
- # ],
- # "upgrade": [ ... ]
# }
# We set a default for any question with these matching (type, name)
@@ -1915,43 +2368,41 @@ def _set_default_ask_questions(arguments):
("path", "path"), # i18n: app_manifest_install_ask_path
("password", "password"), # i18n: app_manifest_install_ask_password
("user", "admin"), # i18n: app_manifest_install_ask_admin
- ("boolean", "is_public"),
- ] # i18n: app_manifest_install_ask_is_public
+ ("boolean", "is_public"), # i18n: app_manifest_install_ask_is_public
+ (
+ "group",
+ "init_main_permission",
+ ), # i18n: app_manifest_install_ask_init_main_permission
+ (
+ "group",
+ "init_admin_permission",
+ ), # i18n: app_manifest_install_ask_init_admin_permission
+ ]
- for script_name, arg_list in arguments.items():
+ for question_name, question in questions.items():
+ question["name"] = question_name
- # We only support questions for install so far, and for other
- if script_name != "install":
- continue
-
- for arg in arg_list:
-
- # Do not override 'ask' field if provided by app ?... Or shall we ?
- # if "ask" in arg:
- # continue
-
- # If this arg corresponds to a question with default ask message...
- if any(
- (arg.get("type"), arg["name"]) == question
- for question in questions_with_default
- ):
- # The key is for example "app_manifest_install_ask_domain"
- arg_name = arg["name"]
- key = f"app_manifest_{script_name}_ask_{arg_name}"
- arg["ask"] = m18n.n(key)
+ # If this question corresponds to a question with default ask message...
+ if any(
+ (question.get("type"), question["name"]) == question_with_default
+ for question_with_default in questions_with_default
+ ):
+ # The key is for example "app_manifest_install_ask_domain"
+ question["ask"] = m18n.n(
+ f"app_manifest_{script_name}_ask_{question['name']}"
+ )
# 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["default"]
+ if question.get("type") in ["domain", "user", "password"]:
+ if "example" in question:
+ del question["example"]
+ if "default" in question:
+ del question["default"]
- return arguments
+ return questions
def _is_app_repo_url(string: str) -> bool:
-
string = string.strip()
# Dummy test for ssh-based stuff ... should probably be improved somehow
@@ -1968,7 +2419,6 @@ def _app_quality(src: str) -> str:
raw_app_catalog = _load_apps_catalog()["apps"]
if src in raw_app_catalog or _is_app_repo_url(src):
-
# If we got an app name directly (e.g. just "wordpress"), we gonna test this name
if src in raw_app_catalog:
app_name_to_test = src
@@ -1981,7 +2431,6 @@ def _app_quality(src: str) -> str:
return "thirdparty"
if app_name_to_test in raw_app_catalog:
-
state = raw_app_catalog[app_name_to_test].get("state", "notworking")
level = raw_app_catalog[app_name_to_test].get("level", None)
if state in ["working", "validated"]:
@@ -2019,19 +2468,21 @@ def _extract_app(src: str) -> Tuple[Dict, str]:
url = app_info["git"]["url"]
branch = app_info["git"]["branch"]
revision = str(app_info["git"]["revision"])
- return _extract_app_from_gitrepo(url, branch, revision, app_info)
+ return _extract_app_from_gitrepo(
+ url, branch=branch, revision=revision, app_info=app_info
+ )
# App is a git repo url
elif _is_app_repo_url(src):
url = src.strip().strip("/")
- branch = "master"
- revision = "HEAD"
# gitlab urls may look like 'https://domain/org/group/repo/-/tree/testing'
# compated to github urls looking like 'https://domain/org/repo/tree/testing'
if "/-/" in url:
url = url.replace("/-/", "/")
if "/tree/" in url:
url, branch = url.split("/tree/", 1)
- return _extract_app_from_gitrepo(url, branch, revision, {})
+ else:
+ branch = None
+ return _extract_app_from_gitrepo(url, branch=branch)
# App is a local folder
elif os.path.exists(src):
return _extract_app_from_folder(src)
@@ -2061,6 +2512,10 @@ def _extract_app_from_folder(path: str) -> Tuple[Dict, str]:
if path[-1] != "/":
path = path + "/"
cp(path, extracted_app_folder, recursive=True)
+ # Change the last edit time which is used in _make_tmp_workdir_for_app
+ # to cleanup old dir ... otherwise it may end up being incorrectly removed
+ # at the end of the safety-backup-before-upgrade :/
+ os.system(f"touch {extracted_app_folder}")
else:
try:
shutil.unpack_archive(path, extracted_app_folder)
@@ -2080,12 +2535,51 @@ def _extract_app_from_folder(path: str) -> Tuple[Dict, str]:
logger.debug(m18n.n("done"))
manifest["remote"] = {"type": "file", "path": path}
+ manifest["quality"] = {"level": -1, "state": "thirdparty"}
+ manifest["antifeatures"] = []
+ manifest["potential_alternative_to"] = []
+
return manifest, extracted_app_folder
def _extract_app_from_gitrepo(
- url: str, branch: str, revision: str, app_info: Dict = {}
+ url: str, branch: Optional[str] = None, revision: str = "HEAD", app_info: Dict = {}
) -> Tuple[Dict, str]:
+ logger.debug("Checking default branch")
+
+ try:
+ git_ls_remote = check_output(
+ ["git", "ls-remote", "--symref", url, "HEAD"],
+ env={"GIT_TERMINAL_PROMPT": "0", "LC_ALL": "C"},
+ shell=False,
+ )
+ except Exception as e:
+ logger.error(str(e))
+ raise YunohostError("app_sources_fetch_failed")
+
+ if not branch:
+ default_branch = None
+ try:
+ for line in git_ls_remote.split("\n"):
+ # Look for the line formated like :
+ # ref: refs/heads/master HEAD
+ if "ref: refs/heads/" in line:
+ line = line.replace("/", " ").replace("\t", " ")
+ default_branch = line.split()[3]
+ except Exception:
+ pass
+
+ if not default_branch:
+ logger.warning("Failed to parse default branch, trying 'main'")
+ branch = "main"
+ else:
+ if default_branch in ["testing", "dev"]:
+ logger.warning(
+ f"Trying 'master' branch instead of default '{default_branch}'"
+ )
+ branch = "master"
+ else:
+ branch = default_branch
logger.debug(m18n.n("downloading"))
@@ -2126,9 +2620,36 @@ def _extract_app_from_gitrepo(
manifest["remote"]["revision"] = revision
manifest["lastUpdate"] = app_info.get("lastUpdate")
+ manifest["quality"] = {
+ "level": app_info.get("level", -1),
+ "state": app_info.get("state", "thirdparty"),
+ }
+ manifest["antifeatures"] = app_info.get("antifeatures", [])
+ manifest["potential_alternative_to"] = app_info.get("potential_alternative_to", [])
+
return manifest, extracted_app_folder
+def _list_upgradable_apps():
+ upgradable_apps = list(app_list(upgradable=True)["apps"])
+
+ # Retrieve next manifest pre_upgrade notifications
+ for app in upgradable_apps:
+ absolute_app_name, _ = _parse_app_instance_name(app["id"])
+ manifest, extracted_app_folder = _extract_app(absolute_app_name)
+ app["notifications"] = {}
+ if manifest["notifications"]["PRE_UPGRADE"]:
+ app["notifications"]["PRE_UPGRADE"] = _filter_and_hydrate_notifications(
+ manifest["notifications"]["PRE_UPGRADE"],
+ app["current_version"],
+ app["settings"],
+ )
+ del app["settings"]
+ shutil.rmtree(extracted_app_folder)
+
+ return upgradable_apps
+
+
#
# ############################### #
# Small utilities #
@@ -2177,48 +2698,128 @@ def _get_all_installed_apps_id():
return all_apps_ids_formatted
-def _check_manifest_requirements(manifest: Dict):
+def _check_manifest_requirements(
+ manifest: Dict, action: str = ""
+) -> Iterator[Tuple[str, bool, object, str]]:
"""Check if required packages are met from the manifest"""
- packaging_format = int(manifest.get("packaging_format", 0))
- if packaging_format not in [0, 1]:
+ app_id = manifest["id"]
+ logger.debug(m18n.n("app_requirements_checking", app=app_id))
+
+ # Packaging format
+ if manifest["packaging_format"] not in [1, 2]:
raise YunohostValidationError("app_packaging_format_not_supported")
- requirements = manifest.get("requirements", dict())
+ # Yunohost version
+ required_yunohost_version = (
+ manifest["integration"].get("yunohost", "4.3").strip(">= ")
+ )
+ current_yunohost_version = get_ynh_package_version("yunohost")["version"]
- if not requirements:
- return
+ yield (
+ "required_yunohost_version",
+ version.parse(required_yunohost_version)
+ <= version.parse(current_yunohost_version),
+ {"current": current_yunohost_version, "required": required_yunohost_version},
+ "app_yunohost_version_not_supported", # i18n: app_yunohost_version_not_supported
+ )
- app = manifest.get("id", "?")
+ # Architectures
+ arch_requirement = manifest["integration"]["architectures"]
+ arch = system_arch()
- logger.debug(m18n.n("app_requirements_checking", app=app))
+ yield (
+ "arch",
+ arch_requirement in ["all", "?"] or arch in arch_requirement,
+ {"current": arch, "required": ", ".join(arch_requirement)},
+ "app_arch_not_supported", # i18n: app_arch_not_supported
+ )
- # Iterate over requirements
- for pkgname, spec in requirements.items():
- if not packages.meets_version_specifier(pkgname, spec):
- version = packages.ynh_packages_version()[pkgname]["version"]
- raise YunohostValidationError(
- "app_requirements_unmeet",
- pkgname=pkgname,
- version=version,
- spec=spec,
- app=app,
+ # Multi-instance
+ if action == "install":
+ multi_instance = manifest["integration"]["multi_instance"] is True
+ if not multi_instance:
+ apps = _installed_apps()
+ sibling_apps = [
+ a for a in apps if a == app_id or a.startswith(f"{app_id}__")
+ ]
+ multi_instance = len(sibling_apps) == 0
+
+ yield (
+ "install",
+ multi_instance,
+ {"app": app_id},
+ "app_already_installed", # i18n: app_already_installed
+ )
+
+ # Disk
+ if action == "install":
+ root_free_space = free_space_in_directory("/")
+ var_free_space = free_space_in_directory("/var")
+ if manifest["integration"]["disk"] == "?":
+ has_enough_disk = True
+ else:
+ disk_req_bin = human_to_binary(manifest["integration"]["disk"])
+ has_enough_disk = (
+ root_free_space > disk_req_bin and var_free_space > disk_req_bin
)
+ free_space = binary_to_human(min(root_free_space, var_free_space))
+
+ yield (
+ "disk",
+ has_enough_disk,
+ {"current": free_space, "required": manifest["integration"]["disk"]},
+ "app_not_enough_disk", # i18n: app_not_enough_disk
+ )
+
+ # Ram
+ ram_requirement = manifest["integration"]["ram"]
+ ram, swap = ram_available()
+ # Is "include_swap" really useful ? We should probably decide wether to always include it or not instead
+ if ram_requirement.get("include_swap", False):
+ ram += swap
+ can_build = ram_requirement["build"] == "?" or ram > human_to_binary(
+ ram_requirement["build"]
+ )
+ can_run = ram_requirement["runtime"] == "?" or ram > human_to_binary(
+ ram_requirement["runtime"]
+ )
+
+ # Some apps have a higher runtime value than build ...
+ if ram_requirement["build"] != "?" and ram_requirement["runtime"] != "?":
+ max_build_runtime = (
+ ram_requirement["build"]
+ if human_to_binary(ram_requirement["build"])
+ > human_to_binary(ram_requirement["runtime"])
+ else ram_requirement["runtime"]
+ )
+ else:
+ max_build_runtime = ram_requirement["build"]
+
+ yield (
+ "ram",
+ can_build and can_run,
+ {"current": binary_to_human(ram), "required": max_build_runtime},
+ "app_not_enough_ram", # i18n: app_not_enough_ram
+ )
def _guess_webapp_path_requirement(app_folder: str) -> str:
-
# If there's only one "domain" and "path", validate that domain/path
# is an available url and normalize the path.
manifest = _get_manifest_of_app(app_folder)
- raw_questions = manifest.get("arguments", {}).get("install", {})
+ raw_questions = manifest["install"]
domain_questions = [
- question for question in raw_questions if question.get("type") == "domain"
+ question
+ for question in raw_questions.values()
+ if question.get("type") == "domain"
]
path_questions = [
- question for question in raw_questions if question.get("type") == "path"
+ question
+ for question in raw_questions.values()
+ if question.get("type") == "path"
]
if len(domain_questions) == 0 and len(path_questions) == 0:
@@ -2226,22 +2827,33 @@ def _guess_webapp_path_requirement(app_folder: str) -> str:
if len(domain_questions) == 1 and len(path_questions) == 1:
return "domain_and_path"
if len(domain_questions) == 1 and len(path_questions) == 0:
- # This is likely to be a full-domain app...
+ if manifest.get("packaging_format", 0) < 2:
+ # This is likely to be a full-domain app...
- # Confirm that this is a full-domain app This should cover most cases
- # ... though anyway the proper solution is to implement some mechanism
- # in the manifest for app to declare that they require a full domain
- # (among other thing) so that we can dynamically check/display this
- # requirement on the webadmin form and not miserably fail at submit time
+ # Confirm that this is a full-domain app This should cover most cases
+ # ... though anyway the proper solution is to implement some mechanism
+ # in the manifest for app to declare that they require a full domain
+ # (among other thing) so that we can dynamically check/display this
+ # requirement on the webadmin form and not miserably fail at submit time
- # Full-domain apps typically declare something like path_url="/" or path=/
- # and use ynh_webpath_register or yunohost_app_checkurl inside the install script
- install_script_content = read_file(os.path.join(app_folder, "scripts/install"))
+ # Full-domain apps typically declare something like path_url="/" or path=/
+ # and use ynh_webpath_register or yunohost_app_checkurl inside the install script
+ install_script_content = read_file(
+ os.path.join(app_folder, "scripts/install")
+ )
- if re.search(
- r"\npath(_url)?=[\"']?/[\"']?", install_script_content
- ) and re.search(r"ynh_webpath_register", install_script_content):
- return "full_domain"
+ if re.search(
+ r"\npath(_url)?=[\"']?/[\"']?", install_script_content
+ ) and re.search(r"ynh_webpath_register", install_script_content):
+ return "full_domain"
+
+ else:
+ # For packaging v2 apps, check if there's a permission with url being a string
+ perm_resource = manifest.get("resources", {}).get("permissions")
+ if perm_resource is not None and isinstance(
+ perm_resource.get("main", {}).get("url"), str
+ ):
+ return "full_domain"
return "?"
@@ -2249,7 +2861,6 @@ def _guess_webapp_path_requirement(app_folder: str) -> str:
def _validate_webpath_requirement(
args: Dict[str, Any], path_requirement: str, ignore_app=None
) -> None:
-
domain = args.get("domain")
path = args.get("path")
@@ -2274,8 +2885,8 @@ def _get_conflicting_apps(domain, path, ignore_app=None):
from yunohost.domain import _assert_domain_exists
- domain = DomainQuestion.normalize(domain)
- path = PathQuestion.normalize(path)
+ domain = DomainOption.normalize(domain)
+ path = WebPathOption.normalize(path)
# Abort if domain is unknown
_assert_domain_exists(domain)
@@ -2290,18 +2901,13 @@ def _get_conflicting_apps(domain, path, ignore_app=None):
for p, a in apps_map[domain].items():
if a["id"] == ignore_app:
continue
- if path == p:
- conflicts.append((p, a["id"], a["label"]))
- # We also don't want conflicts with other apps starting with
- # same name
- elif path.startswith(p) or p.startswith(path):
+ if path == p or path == "/" or p == "/":
conflicts.append((p, a["id"], a["label"]))
return conflicts
def _assert_no_conflicting_apps(domain, path, ignore_app=None, full_domain=False):
-
conflicts = _get_conflicting_apps(domain, path, ignore_app)
if conflicts:
@@ -2318,12 +2924,17 @@ def _assert_no_conflicting_apps(domain, path, ignore_app=None, full_domain=False
def _make_environment_for_app_script(
- app, args={}, args_prefix="APP_ARG_", workdir=None
+ app,
+ args={},
+ args_prefix="APP_ARG_",
+ workdir=None,
+ action=None,
+ force_include_app_settings=False,
):
-
app_setting_path = os.path.join(APPS_SETTING_PATH, app)
- manifest = _get_manifest_of_app(app_setting_path)
+ manifest = _get_manifest_of_app(workdir if workdir else app_setting_path)
+
app_id, app_instance_nb = _parse_app_instance_name(app)
env_dict = {
@@ -2331,16 +2942,37 @@ def _make_environment_for_app_script(
"YNH_APP_INSTANCE_NAME": app,
"YNH_APP_INSTANCE_NUMBER": str(app_instance_nb),
"YNH_APP_MANIFEST_VERSION": manifest.get("version", "?"),
- "YNH_ARCH": check_output("dpkg --print-architecture"),
+ "YNH_APP_PACKAGING_FORMAT": str(manifest["packaging_format"]),
+ "YNH_ARCH": system_arch(),
+ "YNH_DEBIAN_VERSION": debian_version(),
}
if workdir:
env_dict["YNH_APP_BASEDIR"] = workdir
+ if action:
+ env_dict["YNH_APP_ACTION"] = action
+
for arg_name, arg_value in args.items():
arg_name_upper = arg_name.upper()
env_dict[f"YNH_{args_prefix}{arg_name_upper}"] = str(arg_value)
+ # If packaging format v2, load all settings
+ if manifest["packaging_format"] >= 2 or force_include_app_settings:
+ env_dict["app"] = app
+ for setting_name, setting_value in _get_app_settings(app).items():
+ # Ignore special internal settings like checksum__
+ # (not a huge deal to load them but idk...)
+ if setting_name.startswith("checksum__"):
+ continue
+
+ env_dict[setting_name] = str(setting_value)
+
+ # Special weird case for backward compatibility...
+ # 'path' was loaded into 'path_url' .....
+ if "path" in env_dict:
+ env_dict["path_url"] = env_dict["path"]
+
return env_dict
@@ -2373,7 +3005,6 @@ def _parse_app_instance_name(app_instance_name: str) -> Tuple[str, int]:
def _next_instance_number_for_app(app):
-
# Get list of sibling apps, such as {app}, {app}__2, {app}__4
apps = _installed_apps()
sibling_app_ids = [a for a in apps if a == app or a.startswith(f"{app}__")]
@@ -2391,7 +3022,6 @@ def _next_instance_number_for_app(app):
def _make_tmp_workdir_for_app(app=None):
-
# Create parent dir if it doesn't exists yet
if not os.path.exists(APP_TMP_WORKDIRS):
os.makedirs(APP_TMP_WORKDIRS)
@@ -2420,33 +3050,11 @@ def _make_tmp_workdir_for_app(app=None):
return tmpdir
-def is_true(arg):
- """
- Convert a string into a boolean
-
- Keyword arguments:
- arg -- The string to convert
-
- Returns:
- Boolean
-
- """
- if isinstance(arg, bool):
- return arg
- elif isinstance(arg, str):
- return arg.lower() in ["yes", "true", "on"]
- else:
- logger.debug(f"arg should be a boolean or a string, got {arg}")
- return True if arg else False
-
-
def unstable_apps():
-
output = []
- deprecated_apps = ["mailman"]
+ deprecated_apps = ["mailman", "ffsync"]
for infos in app_list(full=True)["apps"]:
-
if (
not infos.get("from_catalog")
or infos.get("from_catalog").get("state")
@@ -2462,7 +3070,6 @@ 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...")
@@ -2517,8 +3124,116 @@ def _assert_system_is_sane_for_app(manifest, when):
"app_action_broke_system", services=", ".join(faulty_services)
)
- if packages.dpkg_is_broken():
+ if dpkg_is_broken():
if when == "pre":
raise YunohostValidationError("dpkg_is_broken")
elif when == "post":
raise YunohostError("this_action_broke_dpkg")
+
+
+def app_dismiss_notification(app, name):
+ assert isinstance(name, str)
+ name = name.lower()
+ assert name in ["post_install", "post_upgrade"]
+ _assert_is_installed(app)
+
+ app_setting(app, f"_dismiss_notification_{name}", value="1")
+
+
+def _notification_is_dismissed(name, settings):
+ # Check for _dismiss_notiication_$name setting and also auto-dismiss
+ # notifications after one week (otherwise people using mostly CLI would
+ # never really dismiss the notification and it would be displayed forever)
+
+ if name == "POST_INSTALL":
+ return (
+ settings.get("_dismiss_notification_post_install")
+ or (int(time.time()) - settings.get("install_time", 0)) / (24 * 3600) > 7
+ )
+ elif name == "POST_UPGRADE":
+ # Check on update_time also implicitly prevent the post_upgrade notification
+ # from being displayed after install, because update_time is only set during upgrade
+ return (
+ settings.get("_dismiss_notification_post_upgrade")
+ or (int(time.time()) - settings.get("update_time", 0)) / (24 * 3600) > 7
+ )
+ else:
+ return False
+
+
+def _filter_and_hydrate_notifications(notifications, current_version=None, data={}):
+ def is_version_more_recent_than_current_version(name, current_version):
+ current_version = str(current_version)
+ # Boring code to handle the fact that "0.1 < 9999~ynh1" is False
+
+ if "~" in name:
+ return version.parse(name) > version.parse(current_version)
+ else:
+ return version.parse(name) > version.parse(current_version.split("~")[0])
+
+ return {
+ # Should we render the markdown maybe? idk
+ name: _hydrate_app_template(_value_for_locale(content_per_lang), data)
+ for name, content_per_lang in notifications.items()
+ if current_version is None
+ or name == "main"
+ or is_version_more_recent_than_current_version(name, current_version)
+ }
+
+
+def _display_notifications(notifications, force=False):
+ if not notifications:
+ return
+
+ for name, content in notifications.items():
+ print("==========")
+ print(content)
+ print("==========")
+
+ # i18n: confirm_notifications_read
+ _ask_confirmation("confirm_notifications_read", kind="simple", force=force)
+
+
+# FIXME: move this to Moulinette
+def _ask_confirmation(
+ question: str,
+ params: dict = {},
+ kind: str = "hard",
+ force: bool = False,
+):
+ """
+ Ask confirmation
+
+ Keyword argument:
+ question -- m18n key or string
+ params -- dict of values passed to the string formating
+ kind -- "hard": ask with "Yes, I understand", "soft": "Y/N", "simple": "press enter"
+ force -- Will not ask for confirmation
+
+ """
+ if force or Moulinette.interface.type == "api":
+ return
+
+ # If ran from the CLI in a non-interactive context,
+ # skip confirmation (except in hard mode)
+ if not os.isatty(1) and kind in ["simple", "soft"]:
+ return
+ if kind == "simple":
+ answer = Moulinette.prompt(
+ m18n.n(question, answers="Press enter to continue", **params),
+ color="yellow",
+ )
+ answer = True
+ elif kind == "soft":
+ answer = Moulinette.prompt(
+ m18n.n(question, answers="Y/N", **params), color="yellow"
+ )
+ answer = answer.upper() == "Y"
+ else:
+ answer = Moulinette.prompt(
+ m18n.n(question, answers="Yes, I understand", **params), color="red"
+ )
+ answer = answer == "Yes, I understand"
+
+ if not answer:
+ raise YunohostError("aborting")
diff --git a/src/app_catalog.py b/src/app_catalog.py
index 5ae8ef30b..9fb662845 100644
--- a/src/app_catalog.py
+++ b/src/app_catalog.py
@@ -1,5 +1,24 @@
+#
+# Copyright (c) 2023 YunoHost Contributors
+#
+# This file is part of YunoHost (see https://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 .
+#
import os
import re
+import hashlib
from moulinette import m18n
from moulinette.utils.log import getActionLogger
@@ -18,17 +37,18 @@ from yunohost.utils.error import YunohostError
logger = getActionLogger("yunohost.app_catalog")
APPS_CATALOG_CACHE = "/var/cache/yunohost/repo"
+APPS_CATALOG_LOGOS = "/usr/share/yunohost/applogos"
APPS_CATALOG_CONF = "/etc/yunohost/apps_catalog.yml"
-APPS_CATALOG_API_VERSION = 2
+APPS_CATALOG_API_VERSION = 3
APPS_CATALOG_DEFAULT_URL = "https://app.yunohost.org/default"
-def app_catalog(full=False, with_categories=False):
+def app_catalog(full=False, with_categories=False, with_antifeatures=False):
"""
Return a dict of apps available to installation from Yunohost's app catalog
"""
- from yunohost.app import _installed_apps, _set_default_ask_questions
+ from yunohost.app import _installed_apps
# Get app list from catalog cache
catalog = _load_apps_catalog()
@@ -47,28 +67,38 @@ def app_catalog(full=False, with_categories=False):
"description": infos["manifest"]["description"],
"level": infos["level"],
}
- else:
- infos["manifest"]["arguments"] = _set_default_ask_questions(
- infos["manifest"].get("arguments", {})
- )
- # Trim info for categories if not using --full
- for category in catalog["categories"]:
- category["title"] = _value_for_locale(category["title"])
- category["description"] = _value_for_locale(category["description"])
- for subtags in category.get("subtags", []):
- subtags["title"] = _value_for_locale(subtags["title"])
+ _catalog = {"apps": catalog["apps"]}
- if not full:
- catalog["categories"] = [
- {"id": c["id"], "description": c["description"]}
- for c in catalog["categories"]
- ]
+ if with_categories:
+ for category in catalog["categories"]:
+ category["title"] = _value_for_locale(category["title"])
+ category["description"] = _value_for_locale(category["description"])
+ for subtags in category.get("subtags", []):
+ subtags["title"] = _value_for_locale(subtags["title"])
- if not with_categories:
- return {"apps": catalog["apps"]}
- else:
- return {"apps": catalog["apps"], "categories": catalog["categories"]}
+ if not full:
+ catalog["categories"] = [
+ {"id": c["id"], "description": c["description"]}
+ for c in catalog["categories"]
+ ]
+
+ _catalog["categories"] = catalog["categories"]
+
+ if with_antifeatures:
+ for antifeature in catalog["antifeatures"]:
+ antifeature["title"] = _value_for_locale(antifeature["title"])
+ antifeature["description"] = _value_for_locale(antifeature["description"])
+
+ if not full:
+ catalog["antifeatures"] = [
+ {"id": a["id"], "description": a["description"]}
+ for a in catalog["antifeatures"]
+ ]
+
+ _catalog["antifeatures"] = catalog["antifeatures"]
+
+ return _catalog
def app_search(string):
@@ -127,7 +157,6 @@ def _read_apps_catalog_list():
def _actual_apps_catalog_api_url(base_url):
-
return f"{base_url}/v{APPS_CATALOG_API_VERSION}/apps.json"
@@ -154,7 +183,13 @@ def _update_apps_catalog():
logger.debug("Initialize folder for apps catalog cache")
mkdir(APPS_CATALOG_CACHE, mode=0o750, parents=True, uid="root")
+ if not os.path.exists(APPS_CATALOG_LOGOS):
+ mkdir(APPS_CATALOG_LOGOS, mode=0o755, parents=True, uid="root")
+
for apps_catalog in apps_catalog_list:
+ if apps_catalog["url"] is None:
+ continue
+
apps_catalog_id = apps_catalog["id"]
actual_api_url = _actual_apps_catalog_api_url(apps_catalog["url"])
@@ -181,6 +216,46 @@ def _update_apps_catalog():
raw_msg=True,
)
+ # Download missing app logos
+ logos_to_download = []
+ for app, infos in apps_catalog_content["apps"].items():
+ logo_hash = infos.get("logo_hash")
+ if not logo_hash or os.path.exists(f"{APPS_CATALOG_LOGOS}/{logo_hash}.png"):
+ continue
+ logos_to_download.append(logo_hash)
+
+ if len(logos_to_download) > 20:
+ logger.info(
+ f"(Will fetch {len(logos_to_download)} logos, this may take a couple minutes)"
+ )
+
+ import requests
+ from multiprocessing.pool import ThreadPool
+
+ def fetch_logo(logo_hash):
+ try:
+ r = requests.get(
+ f"{apps_catalog['url']}/v{APPS_CATALOG_API_VERSION}/logos/{logo_hash}.png",
+ timeout=10,
+ )
+ assert (
+ r.status_code == 200
+ ), f"Got status code {r.status_code}, expected 200"
+ if hashlib.sha256(r.content).hexdigest() != logo_hash:
+ raise Exception(
+ f"Found inconsistent hash while downloading logo {logo_hash}"
+ )
+ open(f"{APPS_CATALOG_LOGOS}/{logo_hash}.png", "wb").write(r.content)
+ return True
+ except Exception as e:
+ logger.debug(f"Failed to download logo {logo_hash} : {e}")
+ return False
+
+ results = ThreadPool(8).imap_unordered(fetch_logo, logos_to_download)
+ for result in results:
+ # Is this even needed to iterate on the results ?
+ pass
+
logger.success(m18n.n("apps_catalog_update_success"))
@@ -190,10 +265,9 @@ def _load_apps_catalog():
corresponding to all known apps and categories
"""
- merged_catalog = {"apps": {}, "categories": []}
+ merged_catalog = {"apps": {}, "categories": [], "antifeatures": []}
for apps_catalog_id in [L["id"] for L in _read_apps_catalog_list()]:
-
# Let's load the json from cache for this catalog
cache_file = f"{APPS_CATALOG_CACHE}/{apps_catalog_id}.json"
@@ -222,7 +296,6 @@ def _load_apps_catalog():
# Add apps from this catalog to the output
for app, info in apps_catalog_content["apps"].items():
-
# (N.B. : there's a small edge case where multiple apps catalog could be listing the same apps ...
# in which case we keep only the first one found)
if app in merged_catalog["apps"]:
@@ -232,10 +305,17 @@ def _load_apps_catalog():
)
continue
+ if info.get("level") == "?":
+ info["level"] = -1
+
+ # FIXME: we may want to autoconvert all v0/v1 manifest to v2 here
+ # so that everything is consistent in terms of APIs, datastructure format etc
info["repository"] = apps_catalog_id
merged_catalog["apps"][app] = info
- # Annnnd categories
- merged_catalog["categories"] += apps_catalog_content["categories"]
+ # Annnnd categories + antifeatures
+ # (we use .get here, only because the dev catalog doesnt include the categories/antifeatures keys)
+ merged_catalog["categories"] += apps_catalog_content.get("categories", [])
+ merged_catalog["antifeatures"] += apps_catalog_content.get("antifeatures", [])
return merged_catalog
diff --git a/src/authenticators/ldap_admin.py b/src/authenticators/ldap_admin.py
index 872dd3c8d..b1b550bc0 100644
--- a/src/authenticators/ldap_admin.py
+++ b/src/authenticators/ldap_admin.py
@@ -1,5 +1,21 @@
-# -*- coding: utf-8 -*-
-
+#
+# Copyright (c) 2023 YunoHost Contributors
+#
+# This file is part of YunoHost (see https://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 .
+#
import os
import logging
import ldap
@@ -11,37 +27,72 @@ from moulinette.authentication import BaseAuthenticator
from moulinette.utils.text import random_ascii
from yunohost.utils.error import YunohostError, YunohostAuthenticationError
-
-logger = logging.getLogger("yunohost.authenticators.ldap_admin")
+from yunohost.utils.ldap import _get_ldap_interface
session_secret = random_ascii()
+logger = logging.getLogger("yunohost.authenticators.ldap_admin")
+
+LDAP_URI = "ldap://localhost:389"
+ADMIN_GROUP = "cn=admins,ou=groups"
+AUTH_DN = "uid={uid},ou=users,dc=yunohost,dc=org"
class Authenticator(BaseAuthenticator):
-
name = "ldap_admin"
def __init__(self, *args, **kwargs):
- self.uri = "ldap://localhost:389"
- self.basedn = "dc=yunohost,dc=org"
- self.admindn = "cn=admin,dc=yunohost,dc=org"
+ pass
def _authenticate_credentials(self, credentials=None):
+ try:
+ admins = (
+ _get_ldap_interface()
+ .search(ADMIN_GROUP, attrs=["memberUid"])[0]
+ .get("memberUid", [])
+ )
+ except ldap.SERVER_DOWN:
+ # ldap is down, attempt to restart it before really failing
+ logger.warning(m18n.n("ldap_server_is_down_restart_it"))
+ os.system("systemctl restart slapd")
+ time.sleep(10) # waits 10 secondes so we are sure that slapd has restarted
- # TODO : change authentication format
- # to support another dn to support multi-admins
+ # Force-reset existing LDAP interface
+ from yunohost.utils import ldap as ldaputils
+
+ ldaputils._ldap_interface = None
+
+ try:
+ admins = (
+ _get_ldap_interface()
+ .search(ADMIN_GROUP, attrs=["memberUid"])[0]
+ .get("memberUid", [])
+ )
+ except ldap.SERVER_DOWN:
+ raise YunohostError("ldap_server_down")
+
+ try:
+ uid, password = credentials.split(":", 1)
+ except ValueError:
+ raise YunohostError("invalid_credentials")
+
+ # Here we're explicitly using set() which are handled as hash tables
+ # and should prevent timing attacks to find out the admin usernames?
+ if uid not in set(admins):
+ raise YunohostError("invalid_credentials")
+
+ dn = AUTH_DN.format(uid=uid)
def _reconnect():
con = ldap.ldapobject.ReconnectLDAPObject(
- self.uri, retry_max=10, retry_delay=0.5
+ LDAP_URI, retry_max=10, retry_delay=0.5
)
- con.simple_bind_s(self.admindn, credentials)
+ con.simple_bind_s(dn, password)
return con
try:
con = _reconnect()
except ldap.INVALID_CREDENTIALS:
- raise YunohostError("invalid_password")
+ raise YunohostError("invalid_credentials")
except ldap.SERVER_DOWN:
# ldap is down, attempt to restart it before really failing
logger.warning(m18n.n("ldap_server_is_down_restart_it"))
@@ -61,9 +112,9 @@ class Authenticator(BaseAuthenticator):
logger.warning("Error during ldap authentication process: %s", e)
raise
else:
- if who != self.admindn:
+ if who != dn:
raise YunohostError(
- f"Not logged with the appropriate identity ? Found {who}, expected {self.admindn} !?",
+ f"Not logged with the appropriate identity ? Found {who}, expected {dn} !?",
raw_msg=True,
)
finally:
@@ -72,7 +123,6 @@ class Authenticator(BaseAuthenticator):
con.unbind_s()
def set_session_cookie(self, infos):
-
from bottle import response
assert isinstance(infos, dict)
@@ -92,7 +142,6 @@ class Authenticator(BaseAuthenticator):
)
def get_session_cookie(self, raise_if_no_session_exists=True):
-
from bottle import request
try:
@@ -121,7 +170,6 @@ class Authenticator(BaseAuthenticator):
return infos
def delete_session_cookie(self):
-
from bottle import response
response.set_cookie("yunohost.admin", "", max_age=-1)
diff --git a/src/backup.py b/src/backup.py
index bba60b895..ce1e8ba2c 100644
--- a/src/backup.py
+++ b/src/backup.py
@@ -1,28 +1,21 @@
-# -*- 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_backup.py
-
- Manage backups
-"""
+#
+# Copyright (c) 2023 YunoHost Contributors
+#
+# This file is part of YunoHost (see https://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 .
+#
import os
import re
import json
@@ -39,9 +32,17 @@ from functools import reduce
from packaging import version
from moulinette import Moulinette, m18n
-from moulinette.utils import filesystem
+from moulinette.utils.text import random_ascii
from moulinette.utils.log import getActionLogger
-from moulinette.utils.filesystem import read_file, mkdir, write_to_yaml, read_yaml
+from moulinette.utils.filesystem import (
+ read_file,
+ mkdir,
+ write_to_yaml,
+ read_yaml,
+ rm,
+ chown,
+ chmod,
+)
from moulinette.utils.process import check_output
import yunohost.domain
@@ -50,6 +51,8 @@ from yunohost.app import (
_is_installed,
_make_environment_for_app_script,
_make_tmp_workdir_for_app,
+ _get_manifest_of_app,
+ app_remove,
)
from yunohost.hook import (
hook_list,
@@ -67,8 +70,12 @@ from yunohost.tools import (
from yunohost.regenconf import regen_conf
from yunohost.log import OperationLogger, is_unit_operation
from yunohost.utils.error import YunohostError, YunohostValidationError
-from yunohost.utils.packages import ynh_packages_version
-from yunohost.utils.filesystem import free_space_in_directory
+from yunohost.utils.system import (
+ free_space_in_directory,
+ get_ynh_package_version,
+ binary_to_human,
+ space_used_by_directory,
+)
from yunohost.settings import settings_get
BACKUP_PATH = "/home/yunohost.backup"
@@ -88,7 +95,6 @@ class BackupRestoreTargetsManager:
"""
def __init__(self):
-
self.targets = {}
self.results = {"system": {}, "apps": {}}
@@ -312,7 +318,7 @@ class BackupManager:
"size_details": self.size_details,
"apps": self.apps_return,
"system": self.system_return,
- "from_yunohost_version": ynh_packages_version()["yunohost"]["version"],
+ "from_yunohost_version": get_ynh_package_version("yunohost")["version"],
}
@property
@@ -342,9 +348,8 @@ class BackupManager:
# FIXME replace isdir by exists ? manage better the case where the path
# exists
if not os.path.isdir(self.work_dir):
- filesystem.mkdir(self.work_dir, 0o750, parents=True, uid="admin")
+ mkdir(self.work_dir, 0o750, parents=True)
elif self.is_tmp_work_dir:
-
logger.debug(
"temporary directory for backup '%s' already exists... attempting to clean it",
self.work_dir,
@@ -357,8 +362,8 @@ class BackupManager:
# If umount succeeded, remove the directory (we checked that
# we're in /home/yunohost.backup/tmp so that should be okay...
# c.f. method clean() which also does this)
- filesystem.rm(self.work_dir, recursive=True, force=True)
- filesystem.mkdir(self.work_dir, 0o750, parents=True, uid="admin")
+ rm(self.work_dir, recursive=True, force=True)
+ mkdir(self.work_dir, 0o750, parents=True)
#
# Backup target management #
@@ -535,7 +540,7 @@ class BackupManager:
successfull_system = self.targets.list("system", include=["Success", "Warning"])
if not successfull_apps and not successfull_system:
- filesystem.rm(self.work_dir, True, True)
+ rm(self.work_dir, True, True)
raise YunohostError("backup_nothings_done")
# Add unlisted files from backup tmp dir
@@ -577,7 +582,7 @@ class BackupManager:
env_var["YNH_BACKUP_CSV"] = tmp_csv
if app is not None:
- env_var.update(_make_environment_for_app_script(app))
+ env_var.update(_make_environment_for_app_script(app, action="backup"))
env_var["YNH_APP_BACKUP_DIR"] = os.path.join(
self.work_dir, "apps", app, "backup"
)
@@ -647,7 +652,7 @@ class BackupManager:
restore_hooks_dir = os.path.join(self.work_dir, "hooks", "restore")
if not os.path.exists(restore_hooks_dir):
- filesystem.mkdir(restore_hooks_dir, mode=0o700, parents=True, uid="root")
+ mkdir(restore_hooks_dir, mode=0o700, parents=True, uid="root")
restore_hooks = hook_list("restore")["hooks"]
@@ -714,7 +719,7 @@ class BackupManager:
tmp_workdir_for_app = _make_tmp_workdir_for_app(app=app)
try:
# Prepare backup directory for the app
- filesystem.mkdir(tmp_app_bkp_dir, 0o700, True, uid="root")
+ mkdir(tmp_app_bkp_dir, 0o700, True, uid="root")
# Copy the app settings to be able to call _common.sh
shutil.copytree(app_setting_path, settings_dir)
@@ -753,7 +758,7 @@ class BackupManager:
# Remove tmp files in all situations
finally:
shutil.rmtree(tmp_workdir_for_app)
- filesystem.rm(env_dict["YNH_BACKUP_CSV"], force=True)
+ rm(env_dict["YNH_BACKUP_CSV"], force=True)
#
# Actual backup archive creation / method management #
@@ -796,7 +801,7 @@ class BackupManager:
if row["dest"] == "info.json":
continue
- size = disk_usage(row["source"])
+ size = space_used_by_directory(row["source"], follow_symlinks=False)
# Add size to apps details
splitted_dest = row["dest"].split("/")
@@ -882,7 +887,6 @@ class RestoreManager:
@property
def success(self):
-
successful_apps = self.targets.list("apps", include=["Success", "Warning"])
successful_system = self.targets.list("system", include=["Success", "Warning"])
@@ -934,7 +938,17 @@ class RestoreManager:
)
logger.debug("executing the post-install...")
- tools_postinstall(domain, "Yunohost", True)
+
+ # Use a dummy password which is not gonna be saved anywhere
+ # because the next thing to happen should be that a full restore of the LDAP db will happen
+ tools_postinstall(
+ domain,
+ "tmpadmin",
+ "Tmp Admin",
+ password=random_ascii(70),
+ ignore_dyndns=True,
+ overwrite_root_password=False,
+ )
def clean(self):
"""
@@ -949,7 +963,7 @@ class RestoreManager:
ret = subprocess.call(["umount", self.work_dir])
if ret != 0:
logger.warning(m18n.n("restore_cleaning_failed"))
- filesystem.rm(self.work_dir, recursive=True, force=True)
+ rm(self.work_dir, recursive=True, force=True)
#
# Restore target manangement #
@@ -979,7 +993,7 @@ class RestoreManager:
available_restore_system_hooks = hook_list("restore")["hooks"]
custom_restore_hook_folder = os.path.join(CUSTOM_HOOK_FOLDER, "restore")
- filesystem.mkdir(custom_restore_hook_folder, 755, parents=True, force=True)
+ mkdir(custom_restore_hook_folder, 755, parents=True, force=True)
for system_part in target_list:
# By default, we'll use the restore hooks on the current install
@@ -1084,7 +1098,7 @@ class RestoreManager:
else:
raise YunohostError("restore_removing_tmp_dir_failed")
- filesystem.mkdir(self.work_dir, parents=True)
+ mkdir(self.work_dir, parents=True)
self.method.mount()
@@ -1182,7 +1196,8 @@ class RestoreManager:
self._restore_apps()
except Exception as e:
raise YunohostError(
- f"The following critical error happened during restoration: {e}"
+ f"The following critical error happened during restoration: {e}",
+ raw_msg=True,
)
finally:
self.clean()
@@ -1361,8 +1376,6 @@ class RestoreManager:
from yunohost.user import user_group_list
from yunohost.permission import (
permission_create,
- permission_delete,
- user_permission_list,
permission_sync_to_user,
)
@@ -1402,7 +1415,7 @@ class RestoreManager:
# Delete _common.sh file in backup
common_file = os.path.join(app_backup_in_archive, "_common.sh")
- filesystem.rm(common_file, force=True)
+ rm(common_file, force=True)
# Check if the app has a restore script
app_restore_script_in_archive = os.path.join(app_scripts_in_archive, "restore")
@@ -1418,14 +1431,14 @@ class RestoreManager:
)
app_scripts_new_path = os.path.join(app_settings_new_path, "scripts")
shutil.copytree(app_settings_in_archive, app_settings_new_path)
- filesystem.chmod(app_settings_new_path, 0o400, 0o400, True)
- filesystem.chown(app_scripts_new_path, "root", None, True)
+ chmod(app_settings_new_path, 0o400, 0o400, True)
+ chown(app_scripts_new_path, "root", None, True)
# Copy the app scripts to a writable temporary folder
tmp_workdir_for_app = _make_tmp_workdir_for_app()
copytree(app_scripts_in_archive, tmp_workdir_for_app)
- filesystem.chmod(tmp_workdir_for_app, 0o700, 0o700, True)
- filesystem.chown(tmp_workdir_for_app, "root", None, True)
+ chmod(tmp_workdir_for_app, 0o700, 0o700, True)
+ chown(tmp_workdir_for_app, "root", None, True)
restore_script = os.path.join(tmp_workdir_for_app, "restore")
# Restore permissions
@@ -1438,7 +1451,6 @@ class RestoreManager:
existing_groups = user_group_list()["groups"]
for permission_name, permission_infos in permissions.items():
-
if "allowed" not in permission_infos:
logger.warning(
f"'allowed' key corresponding to allowed groups for permission {permission_name} not found when restoring app {app_instance_name} … You might have to reconfigure permissions yourself."
@@ -1494,7 +1506,7 @@ class RestoreManager:
# FIXME : workdir should be a tmp workdir
app_workdir = os.path.join(self.work_dir, "apps", app_instance_name, "settings")
env_dict = _make_environment_for_app_script(
- app_instance_name, workdir=app_workdir
+ app_instance_name, workdir=app_workdir, action="restore"
)
env_dict.update(
{
@@ -1509,6 +1521,16 @@ class RestoreManager:
operation_logger.extra["env"] = env_dict
operation_logger.flush()
+ manifest = _get_manifest_of_app(app_settings_in_archive)
+ if manifest["packaging_format"] >= 2:
+ from yunohost.utils.resources import AppResourceManager
+
+ AppResourceManager(app_instance_name, wanted=manifest, current={}).apply(
+ rollback_and_raise_exception_if_failure=True,
+ operation_logger=operation_logger,
+ action="restore",
+ )
+
# Execute the app install script
restore_failed = True
try:
@@ -1533,39 +1555,9 @@ class RestoreManager:
self.targets.set_result("apps", app_instance_name, "Success")
operation_logger.success()
else:
-
self.targets.set_result("apps", app_instance_name, "Error")
- remove_script = os.path.join(app_scripts_in_archive, "remove")
-
- # Setup environment for remove script
- env_dict_remove = _make_environment_for_app_script(
- app_instance_name, workdir=app_workdir
- )
- remove_operation_logger = OperationLogger(
- "remove_on_failed_restore",
- [("app", app_instance_name)],
- env=env_dict_remove,
- )
- remove_operation_logger.start()
-
- # Execute remove script
- if hook_exec(remove_script, env=env_dict_remove)[0] != 0:
- msg = m18n.n("app_not_properly_removed", app=app_instance_name)
- logger.warning(msg)
- remove_operation_logger.error(msg)
- else:
- remove_operation_logger.success()
-
- # Cleaning app directory
- shutil.rmtree(app_settings_new_path, ignore_errors=True)
-
- # Remove all permission in LDAP for this app
- for permission_name in user_permission_list()["permissions"].keys():
- if permission_name.startswith(app_instance_name + "."):
- permission_delete(permission_name, force=True)
-
- # TODO Cleaning app hooks
+ app_remove(app_instance_name, force_workdir=app_workdir)
logger.error(failure_message_with_debug_instructions)
@@ -1727,7 +1719,7 @@ class BackupMethod:
raise YunohostError("backup_cleaning_failed")
if self.manager.is_tmp_work_dir:
- filesystem.rm(self.work_dir, True, True)
+ rm(self.work_dir, True, True)
def _check_is_enough_free_space(self):
"""
@@ -1775,11 +1767,11 @@ class BackupMethod:
# Be sure the parent dir of destination exists
if not os.path.isdir(dest_dir):
- filesystem.mkdir(dest_dir, parents=True)
+ mkdir(dest_dir, parents=True)
# For directory, attempt to mount bind
if os.path.isdir(src):
- filesystem.mkdir(dest, parents=True, force=True)
+ mkdir(dest, parents=True, force=True)
try:
subprocess.check_call(["mount", "--rbind", src, dest])
@@ -1832,7 +1824,10 @@ class BackupMethod:
# to mounting error
# Compute size to copy
- size = sum(disk_usage(path["source"]) for path in paths_needed_to_be_copied)
+ size = sum(
+ space_used_by_directory(path["source"], follow_symlinks=False)
+ for path in paths_needed_to_be_copied
+ )
size /= 1024 * 1024 # Convert bytes to megabytes
# Ask confirmation for copying
@@ -1884,7 +1879,7 @@ class CopyBackupMethod(BackupMethod):
dest_parent = os.path.dirname(dest)
if not os.path.exists(dest_parent):
- filesystem.mkdir(dest_parent, 0o700, True, uid="admin")
+ mkdir(dest_parent, 0o700, True)
if os.path.isdir(source):
shutil.copytree(source, dest)
@@ -1902,7 +1897,7 @@ class CopyBackupMethod(BackupMethod):
if not os.path.isdir(self.repo):
raise YunohostError("backup_no_uncompress_archive_dir")
- filesystem.mkdir(self.work_dir, parent=True)
+ mkdir(self.work_dir, parent=True)
ret = subprocess.call(["mount", "-r", "--rbind", self.repo, self.work_dir])
if ret == 0:
return
@@ -1921,14 +1916,12 @@ class CopyBackupMethod(BackupMethod):
class TarBackupMethod(BackupMethod):
-
method_name = "tar"
@property
def _archive_file(self):
-
if isinstance(self.manager, BackupManager) and settings_get(
- "backup.compress_tar_archives"
+ "misc.backup.backup_compress_tar_archives"
):
return os.path.join(self.repo, self.name + ".tar.gz")
@@ -1946,7 +1939,7 @@ class TarBackupMethod(BackupMethod):
"""
if not os.path.exists(self.repo):
- filesystem.mkdir(self.repo, 0o750, parents=True, uid="admin")
+ mkdir(self.repo, 0o750, parents=True)
# Check free space in output
self._check_is_enough_free_space()
@@ -2285,7 +2278,7 @@ def backup_create(
)
backup_manager.backup()
- logger.success(m18n.n("backup_created"))
+ logger.success(m18n.n("backup_created", name=backup_manager.name))
operation_logger.success()
return {
@@ -2383,6 +2376,7 @@ def backup_list(with_info=False, human_readable=False):
# (we do a realpath() to resolve symlinks)
archives = glob(f"{ARCHIVES_PATH}/*.tar.gz") + glob(f"{ARCHIVES_PATH}/*.tar")
archives = {os.path.realpath(archive) for archive in archives}
+ archives = {archive for archive in archives if os.path.exists(archive)}
archives = sorted(archives, key=lambda x: os.path.getctime(x))
# Extract only filename without the extension
@@ -2413,7 +2407,6 @@ def backup_list(with_info=False, human_readable=False):
def backup_download(name):
-
if Moulinette.interface.type != "api":
logger.error(
"This option is only meant for the API/webadmin and doesn't make sense for the command line."
@@ -2554,7 +2547,6 @@ def backup_info(name, with_details=False, human_readable=False):
if "size_details" in info.keys():
for category in ["apps", "system"]:
for name, key_info in info[category].items():
-
if category == "system":
# Stupid legacy fix for weird format between 3.5 and 3.6
if isinstance(key_info, dict):
@@ -2614,7 +2606,7 @@ def backup_delete(name):
hook_callback("post_backup_delete", args=[name])
- logger.success(m18n.n("backup_deleted"))
+ logger.success(m18n.n("backup_deleted", name=name))
#
@@ -2628,9 +2620,9 @@ def _create_archive_dir():
if os.path.lexists(ARCHIVES_PATH):
raise YunohostError("backup_output_symlink_dir_broken", path=ARCHIVES_PATH)
- # Create the archive folder, with 'admin' as owner, such that
+ # Create the archive folder, with 'admins' as groupowner, such that
# people can scp archives out of the server
- mkdir(ARCHIVES_PATH, mode=0o750, parents=True, uid="admin", gid="root")
+ mkdir(ARCHIVES_PATH, mode=0o770, parents=True, gid="admins")
def _call_for_each_path(self, callback, csv_path=None):
@@ -2667,31 +2659,3 @@ def _recursive_umount(directory):
continue
return everything_went_fine
-
-
-def disk_usage(path):
- # We don't do this in python with os.stat because we don't want
- # to follow symlinks
-
- du_output = check_output(["du", "-sb", path], shell=False)
- return int(du_output.split()[0])
-
-
-def binary_to_human(n, customary=False):
- """
- Convert bytes or bits into human readable format with binary prefix
- Keyword argument:
- n -- Number to convert
- customary -- Use customary symbol instead of IEC standard
- """
- symbols = ("Ki", "Mi", "Gi", "Ti", "Pi", "Ei", "Zi", "Yi")
- if customary:
- symbols = ("K", "M", "G", "T", "P", "E", "Z", "Y")
- prefix = {}
- for i, s in enumerate(symbols):
- prefix[s] = 1 << (i + 1) * 10
- for s in reversed(symbols):
- if n >= prefix[s]:
- value = float(n) / prefix[s]
- return "{:.1f}{}".format(value, s)
- return "%s" % n
diff --git a/src/certificate.py b/src/certificate.py
index 2a9fb4ce9..76d3f32b7 100644
--- a/src/certificate.py
+++ b/src/certificate.py
@@ -1,40 +1,33 @@
-# -*- coding: utf-8 -*-
-
-""" License
-
- Copyright (C) 2016 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
-
- yunohost_certificate.py
-
- Manage certificates, in particular Let's encrypt
-"""
-
+#
+# Copyright (c) 2023 YunoHost Contributors
+#
+# This file is part of YunoHost (see https://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 .
+#
import os
import sys
import shutil
-import pwd
-import grp
import subprocess
-import glob
+from glob import glob
from datetime import datetime
from moulinette import m18n
from moulinette.utils.log import getActionLogger
-from moulinette.utils.filesystem import read_file
+from moulinette.utils.filesystem import read_file, chown, chmod
+from moulinette.utils.process import check_output
from yunohost.vendor.acme_tiny.acme_tiny import get_crt as sign_certificate
from yunohost.utils.error import YunohostError, YunohostValidationError
@@ -48,8 +41,8 @@ from yunohost.log import OperationLogger
logger = getActionLogger("yunohost.certmanager")
CERT_FOLDER = "/etc/yunohost/certs/"
-TMP_FOLDER = "/tmp/acme-challenge-private/"
-WEBROOT_FOLDER = "/tmp/acme-challenge-public/"
+TMP_FOLDER = "/var/www/.well-known/acme-challenge-private/"
+WEBROOT_FOLDER = "/var/www/.well-known/acme-challenge-public/"
SELF_CA_FILE = "/etc/ssl/certs/ca-yunohost_crt.pem"
ACCOUNT_KEY_FILE = "/etc/yunohost/letsencrypt_account.pem"
@@ -95,15 +88,16 @@ def certificate_status(domains, full=False):
if not full:
del status["subject"]
del status["CA_name"]
- status["CA_type"] = status["CA_type"]["verbose"]
- status["summary"] = status["summary"]["verbose"]
if full:
try:
_check_domain_is_ready_for_ACME(domain)
status["ACME_eligible"] = True
- except Exception:
- status["ACME_eligible"] = False
+ except Exception as e:
+ if e.key == "certmanager_domain_not_diagnosed_yet":
+ status["ACME_eligible"] = None # = unknown status
+ else:
+ status["ACME_eligible"] = False
del status["domain"]
certificates[domain] = status
@@ -130,9 +124,8 @@ def certificate_install(domain_list, force=False, no_checks=False, self_signed=F
def _certificate_install_selfsigned(domain_list, force=False):
-
+ failed_cert_install = []
for domain in domain_list:
-
operation_logger = OperationLogger(
"selfsigned_cert_install", [("domain", domain)], args={"force": force}
)
@@ -154,7 +147,7 @@ def _certificate_install_selfsigned(domain_list, force=False):
if not force and os.path.isfile(current_cert_file):
status = _get_status(domain)
- if status["summary"]["code"] in ("good", "great"):
+ if status["style"] == "success":
raise YunohostValidationError(
"certmanager_attempt_to_replace_valid_cert", domain=domain
)
@@ -214,20 +207,24 @@ def _certificate_install_selfsigned(domain_list, force=False):
# Check new status indicate a recently created self-signed certificate
status = _get_status(domain)
- if (
- status
- and status["CA_type"]["code"] == "self-signed"
- and status["validity"] > 3648
- ):
+ if status and status["CA_type"] == "selfsigned" and status["validity"] > 3648:
logger.success(
m18n.n("certmanager_cert_install_success_selfsigned", domain=domain)
)
operation_logger.success()
else:
msg = f"Installation of self-signed certificate installation for {domain} failed !"
+ failed_cert_install.append(domain)
logger.error(msg)
+ logger.error(status)
operation_logger.error(msg)
+ if failed_cert_install:
+ raise YunohostError(
+ "certmanager_cert_install_failed_selfsigned",
+ domains=",".join(failed_cert_install),
+ )
+
def _certificate_install_letsencrypt(domains, force=False, no_checks=False):
from yunohost.domain import domain_list, _assert_domain_exists
@@ -239,9 +236,8 @@ def _certificate_install_letsencrypt(domains, force=False, no_checks=False):
# certificates
if domains == []:
for domain in domain_list()["domains"]:
-
status = _get_status(domain)
- if status["CA_type"]["code"] != "self-signed":
+ if status["CA_type"] != "selfsigned":
continue
domains.append(domain)
@@ -253,14 +249,14 @@ def _certificate_install_letsencrypt(domains, force=False, no_checks=False):
# Is it self-signed?
status = _get_status(domain)
- if not force and status["CA_type"]["code"] != "self-signed":
+ if not force and status["CA_type"] != "selfsigned":
raise YunohostValidationError(
"certmanager_domain_cert_not_selfsigned", domain=domain
)
# Actual install steps
+ failed_cert_install = []
for domain in domains:
-
if not no_checks:
try:
_check_domain_is_ready_for_ACME(domain)
@@ -287,11 +283,17 @@ def _certificate_install_letsencrypt(domains, force=False, no_checks=False):
logger.error(
f"Please consider checking the 'DNS records' (basic) and 'Web' categories of the diagnosis to check for possible issues that may prevent installing a Let's Encrypt certificate on domain {domain}."
)
+ failed_cert_install.append(domain)
else:
logger.success(m18n.n("certmanager_cert_install_success", domain=domain))
operation_logger.success()
+ if failed_cert_install:
+ raise YunohostError(
+ "certmanager_cert_install_failed", domains=",".join(failed_cert_install)
+ )
+
def certificate_renew(domains, force=False, no_checks=False, email=False):
"""
@@ -311,10 +313,9 @@ def certificate_renew(domains, force=False, no_checks=False, email=False):
# certificates
if domains == []:
for domain in domain_list()["domains"]:
-
# Does it have a Let's Encrypt cert?
status = _get_status(domain)
- if status["CA_type"]["code"] != "lets-encrypt":
+ if status["CA_type"] != "letsencrypt":
continue
# Does it expire soon?
@@ -336,7 +337,6 @@ def certificate_renew(domains, force=False, no_checks=False, email=False):
# Else, validate the domain list given
else:
for domain in domains:
-
# Is it in Yunohost domain list?
_assert_domain_exists(domain)
@@ -349,7 +349,7 @@ def certificate_renew(domains, force=False, no_checks=False, email=False):
)
# Does it have a Let's Encrypt cert?
- if status["CA_type"]["code"] != "lets-encrypt":
+ if status["CA_type"] != "letsencrypt":
raise YunohostValidationError(
"certmanager_attempt_to_renew_nonLE_cert", domain=domain
)
@@ -361,8 +361,8 @@ def certificate_renew(domains, force=False, no_checks=False, email=False):
)
# Actual renew steps
+ failed_cert_install = []
for domain in domains:
-
if not no_checks:
try:
_check_domain_is_ready_for_ACME(domain)
@@ -402,6 +402,8 @@ def certificate_renew(domains, force=False, no_checks=False, email=False):
logger.error(stack.getvalue())
logger.error(str(e))
+ failed_cert_install.append(domain)
+
if email:
logger.error("Sending email with details to root ...")
_email_renewing_failed(domain, msg + "\n" + str(e), stack.getvalue())
@@ -409,6 +411,11 @@ def certificate_renew(domains, force=False, no_checks=False, email=False):
logger.success(m18n.n("certmanager_cert_renew_success", domain=domain))
operation_logger.success()
+ if failed_cert_install:
+ raise YunohostError(
+ "certmanager_cert_renew_failed", domains=",".join(failed_cert_install)
+ )
+
#
# Back-end stuff #
@@ -441,21 +448,24 @@ investigate :
-- Certificate Manager
"""
- import smtplib
+ try:
+ import smtplib
- smtp = smtplib.SMTP("localhost")
- smtp.sendmail(from_, [to_], message.encode("utf-8"))
- smtp.quit()
+ smtp = smtplib.SMTP("localhost")
+ smtp.sendmail(from_, [to_], message.encode("utf-8"))
+ smtp.quit()
+ except Exception as e:
+ # Dont miserably crash the whole auto renew cert when one renewal fails ...
+ # cf boring cases like https://github.com/YunoHost/issues/issues/2102
+ logger.exception(f"Failed to send mail about cert renewal failure ... : {e}")
def _check_acme_challenge_configuration(domain):
-
domain_conf = f"/etc/nginx/conf.d/{domain}.conf"
return "include /etc/nginx/conf.d/acme-challenge.conf.inc" in read_file(domain_conf)
def _fetch_and_enable_new_certificate(domain, no_checks=False):
-
if not os.path.exists(ACCOUNT_KEY_FILE):
_generate_account_key()
@@ -537,9 +547,9 @@ def _fetch_and_enable_new_certificate(domain, no_checks=False):
_enable_certificate(domain, new_cert_folder)
# Check the status of the certificate is now good
- status_summary = _get_status(domain)["summary"]
+ status_style = _get_status(domain)["style"]
- if status_summary["code"] != "great":
+ if status_style != "success":
raise YunohostError(
"certmanager_certificate_fetching_or_enabling_failed", domain=domain
)
@@ -554,10 +564,11 @@ def _prepare_certificate_signing_request(domain, key_file, output_folder):
# Set the domain
csr.get_subject().CN = domain
- from yunohost.domain import domain_list
+ from yunohost.domain import domain_config_get
- # For "parent" domains, include xmpp-upload subdomain in subject alternate names
- if domain in domain_list(exclude_subdomains=True)["domains"]:
+ # If XMPP is enabled for this domain, add xmpp-upload and muc subdomains
+ # in subject alternate names
+ if domain_config_get(domain, key="feature.xmpp.xmpp") == 1:
subdomain = "xmpp-upload." + domain
xmpp_records = (
Diagnoser.get_cached_report(
@@ -565,24 +576,30 @@ def _prepare_certificate_signing_request(domain, key_file, output_folder):
).get("data")
or {}
)
- if xmpp_records.get("CNAME:xmpp-upload") == "OK":
+ sanlist = []
+ for sub in ("xmpp-upload", "muc"):
+ subdomain = sub + "." + domain
+ if xmpp_records.get("CNAME:" + sub) == "OK":
+ sanlist.append(("DNS:" + subdomain))
+ else:
+ logger.warning(
+ m18n.n(
+ "certmanager_warning_subdomain_dns_record",
+ subdomain=subdomain,
+ domain=domain,
+ )
+ )
+
+ if sanlist:
csr.add_extensions(
[
crypto.X509Extension(
b"subjectAltName",
False,
- ("DNS:" + subdomain).encode("utf8"),
+ (", ".join(sanlist)).encode("utf-8"),
)
]
)
- else:
- logger.warning(
- m18n.n(
- "certmanager_warning_subdomain_dns_record",
- subdomain=subdomain,
- domain=domain,
- )
- )
# Set the key
with open(key_file, "rt") as f:
@@ -602,9 +619,6 @@ def _prepare_certificate_signing_request(domain, key_file, output_folder):
def _get_status(domain):
-
- import yunohost.domain
-
cert_file = os.path.join(CERT_FOLDER, domain, "crt.pem")
if not os.path.isfile(cert_file):
@@ -633,59 +647,34 @@ def _get_status(domain):
)
days_remaining = (valid_up_to - datetime.utcnow()).days
- if cert_issuer in ["yunohost.org"] + yunohost.domain.domain_list()["domains"]:
- CA_type = {
- "code": "self-signed",
- "verbose": "Self-signed",
- }
-
+ # Identify that a domain's cert is self-signed if the cert dir
+ # is actually a symlink to a dir ending with -selfsigned
+ if os.path.realpath(os.path.join(CERT_FOLDER, domain)).endswith("-selfsigned"):
+ CA_type = "selfsigned"
elif organization_name == "Let's Encrypt":
- CA_type = {
- "code": "lets-encrypt",
- "verbose": "Let's Encrypt",
- }
-
+ CA_type = "letsencrypt"
else:
- CA_type = {
- "code": "other-unknown",
- "verbose": "Other / Unknown",
- }
+ CA_type = "other"
if days_remaining <= 0:
- status_summary = {
- "code": "critical",
- "verbose": "CRITICAL",
- }
-
- elif CA_type["code"] in ("self-signed", "fake-lets-encrypt"):
- status_summary = {
- "code": "warning",
- "verbose": "WARNING",
- }
-
+ style = "danger"
+ summary = "expired"
+ elif CA_type == "selfsigned":
+ style = "warning"
+ summary = "selfsigned"
elif days_remaining < VALIDITY_LIMIT:
- status_summary = {
- "code": "attention",
- "verbose": "About to expire",
- }
-
- elif CA_type["code"] == "other-unknown":
- status_summary = {
- "code": "good",
- "verbose": "Good",
- }
-
- elif CA_type["code"] == "lets-encrypt":
- status_summary = {
- "code": "great",
- "verbose": "Great!",
- }
-
+ style = "warning"
+ summary = "abouttoexpire"
+ elif CA_type == "other":
+ style = "success"
+ summary = "ok"
+ elif CA_type == "letsencrypt":
+ style = "success"
+ summary = "letsencrypt"
else:
- status_summary = {
- "code": "unknown",
- "verbose": "Unknown?",
- }
+ # shouldnt happen, because CA_type can be only selfsigned, letsencrypt, or other
+ style = ""
+ summary = "wat"
return {
"domain": domain,
@@ -693,7 +682,8 @@ def _get_status(domain):
"CA_name": cert_issuer,
"CA_type": CA_type,
"validity": days_remaining,
- "summary": status_summary,
+ "style": style,
+ "summary": summary,
}
@@ -719,11 +709,8 @@ def _generate_key(destination_path):
def _set_permissions(path, user, group, permissions):
- uid = pwd.getpwnam(user).pw_uid
- gid = grp.getgrnam(group).gr_gid
-
- os.chown(path, uid, gid)
- os.chmod(path, permissions)
+ chown(path, user, group)
+ chmod(path, permissions)
def _enable_certificate(domain, new_cert_folder):
@@ -746,11 +733,11 @@ def _enable_certificate(domain, new_cert_folder):
logger.debug("Restarting services...")
- for service in ("postfix", "dovecot", "metronome"):
- # Ugly trick to not restart metronome if it's not installed
- if (
- service == "metronome"
- and os.system("dpkg --list | grep -q 'ii *metronome'") != 0
+ for service in ("dovecot", "metronome"):
+ # Ugly trick to not restart metronome if it's not installed or no domain configured for XMPP
+ if service == "metronome" and (
+ os.system("dpkg --list | grep -q 'ii *metronome'") != 0
+ or not glob("/etc/metronome/conf.d/*.cfg.lua")
):
continue
_run_service_command("restart", service)
@@ -758,7 +745,8 @@ def _enable_certificate(domain, new_cert_folder):
if os.path.isfile("/etc/yunohost/installed"):
# regen nginx conf to be sure it integrates OCSP Stapling
# (We don't do this yet if postinstall is not finished yet)
- regen_conf(names=["nginx"])
+ # We also regenconf for postfix to propagate the SNI hash map thingy
+ regen_conf(names=["nginx", "postfix"])
_run_service_command("reload", "nginx")
@@ -779,7 +767,6 @@ def _backup_current_cert(domain):
def _check_domain_is_ready_for_ACME(domain):
-
from yunohost.domain import _get_parent_domain_of
from yunohost.dns import _get_dns_zone_for_domain
from yunohost.utils.dns import is_yunohost_dyndns_domain
@@ -791,7 +778,7 @@ def _check_domain_is_ready_for_ACME(domain):
or {}
)
- parent_domain = _get_parent_domain_of(domain)
+ parent_domain = _get_parent_domain_of(domain, return_self=True)
dnsrecords = (
Diagnoser.get_cached_report(
@@ -866,9 +853,8 @@ def _regen_dnsmasq_if_needed():
do_regen = False
# For all domain files in DNSmasq conf...
- domainsconf = glob.glob("/etc/dnsmasq.d/*.*")
+ domainsconf = glob("/etc/dnsmasq.d/*.*")
for domainconf in domainsconf:
-
# Look for the IP, it's in the lines with this format :
# host-record=the.domain.tld,11.22.33.44
for line in open(domainconf).readlines():
@@ -908,6 +894,4 @@ def _name_self_CA():
def _tail(n, file_path):
- from moulinette.utils.process import check_output
-
return check_output(f"tail -n {n} '{file_path}'")
diff --git a/src/diagnosers/00-basesystem.py b/src/diagnosers/00-basesystem.py
index a36394ce8..336271bd1 100644
--- a/src/diagnosers/00-basesystem.py
+++ b/src/diagnosers/00-basesystem.py
@@ -1,5 +1,21 @@
-#!/usr/bin/env python
-
+#
+# Copyright (c) 2023 YunoHost Contributors
+#
+# This file is part of YunoHost (see https://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 .
+#
import os
import json
import subprocess
@@ -9,28 +25,27 @@ from moulinette.utils import log
from moulinette.utils.process import check_output
from moulinette.utils.filesystem import read_file, read_json, write_to_json
from yunohost.diagnosis import Diagnoser
-from yunohost.utils.packages import ynh_packages_version
+from yunohost.utils.system import (
+ ynh_packages_version,
+ system_virt,
+ system_arch,
+)
logger = log.getActionLogger("yunohost.diagnosis")
class MyDiagnoser(Diagnoser):
-
id_ = os.path.splitext(os.path.basename(__file__))[0].split("-")[1]
cache_duration = 600
dependencies: List[str] = []
def run(self):
-
- # Detect virt technology (if not bare metal) and arch
- # Gotta have this "|| true" because it systemd-detect-virt return 'none'
- # with an error code on bare metal ~.~
- virt = check_output("systemd-detect-virt || true", shell=True)
+ virt = system_virt()
if virt.lower() == "none":
virt = "bare-metal"
# Detect arch
- arch = check_output("dpkg --print-architecture")
+ arch = system_arch()
hardware = dict(
meta={"test": "hardware"},
status="INFO",
@@ -137,6 +152,37 @@ class MyDiagnoser(Diagnoser):
summary="diagnosis_backports_in_sources_list",
)
+ # Using yunohost testing channel
+ if (
+ os.system(
+ "grep -q '^\\s*deb\\s*.*yunohost.org.*\\stesting' /etc/apt/sources.list /etc/apt/sources.list.d/*"
+ )
+ == 0
+ ):
+ yield dict(
+ meta={"test": "apt_yunohost_channel"},
+ status="WARNING",
+ summary="diagnosis_using_yunohost_testing",
+ details=["diagnosis_using_yunohost_testing_details"],
+ )
+
+ # Apt being mapped to 'stable' (instead of 'buster/bullseye/bookworm/trixie/...')
+ # will cause the machine to spontaenously upgrade everything as soon as next debian is released ...
+ # Note that we grep this from the policy for libc6, because it's hard to know exactly which apt repo
+ # is configured (it may not be simply debian.org)
+ if (
+ os.system(
+ "apt policy libc6 2>/dev/null | grep '^\\s*500' | awk '{print $3}' | tr '/' ' ' | awk '{print $1}' | grep -q 'stable'"
+ )
+ == 0
+ ):
+ yield dict(
+ meta={"test": "apt_debian_codename"},
+ status="WARNING",
+ summary="diagnosis_using_stable_codename",
+ details=["diagnosis_using_stable_codename_details"],
+ )
+
if self.number_of_recent_auth_failure() > 750:
yield dict(
meta={"test": "high_number_auth_failure"},
@@ -145,7 +191,6 @@ class MyDiagnoser(Diagnoser):
)
def bad_sury_packages(self):
-
packages_to_check = ["openssl", "libssl1.1", "libssl-dev"]
for package in packages_to_check:
cmd = "dpkg --list | grep '^ii' | grep gbp | grep -q -w %s" % package
@@ -161,12 +206,10 @@ class MyDiagnoser(Diagnoser):
yield (package, version_to_downgrade_to)
def backports_in_sources_list(self):
-
cmd = "grep -q -nr '^ *deb .*-backports' /etc/apt/sources.list*"
return os.system(cmd) == 0
def number_of_recent_auth_failure(self):
-
# Those syslog facilities correspond to auth and authpriv
# c.f. https://unix.stackexchange.com/a/401398
# and https://wiki.archlinux.org/title/Systemd/Journal#Facility
diff --git a/src/diagnosers/10-ip.py b/src/diagnosers/10-ip.py
index 247c486fc..4f9cd9708 100644
--- a/src/diagnosers/10-ip.py
+++ b/src/diagnosers/10-ip.py
@@ -1,5 +1,21 @@
-#!/usr/bin/env python
-
+#
+# Copyright (c) 2023 YunoHost Contributors
+#
+# This file is part of YunoHost (see https://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 .
+#
import re
import os
import random
@@ -12,18 +28,17 @@ from moulinette.utils.filesystem import read_file
from yunohost.diagnosis import Diagnoser
from yunohost.utils.network import get_network_interfaces
+from yunohost.settings import settings_get
logger = log.getActionLogger("yunohost.diagnosis")
class MyDiagnoser(Diagnoser):
-
id_ = os.path.splitext(os.path.basename(__file__))[0].split("-")[1]
cache_duration = 600
dependencies: List[str] = []
def run(self):
-
# ############################################################ #
# PING : Check that we can ping outside at least in ipv4 or v6 #
# ############################################################ #
@@ -102,10 +117,17 @@ class MyDiagnoser(Diagnoser):
else:
return local_ip
+ def is_ipvx_important(x):
+ return settings_get("misc.network.dns_exposure") in ["both", "ipv" + str(x)]
+
yield dict(
meta={"test": "ipv4"},
data={"global": ipv4, "local": get_local_ip("ipv4")},
- status="SUCCESS" if ipv4 else "ERROR",
+ status="SUCCESS"
+ if ipv4
+ else "ERROR"
+ if is_ipvx_important(4)
+ else "WARNING",
summary="diagnosis_ip_connected_ipv4" if ipv4 else "diagnosis_ip_no_ipv4",
details=["diagnosis_ip_global", "diagnosis_ip_local"] if ipv4 else None,
)
@@ -113,17 +135,24 @@ class MyDiagnoser(Diagnoser):
yield dict(
meta={"test": "ipv6"},
data={"global": ipv6, "local": get_local_ip("ipv6")},
- status="SUCCESS" if ipv6 else "WARNING",
+ status="SUCCESS"
+ if ipv6
+ else "ERROR"
+ if settings_get("misc.network.dns_exposure") == "ipv6"
+ else "WARNING",
summary="diagnosis_ip_connected_ipv6" if ipv6 else "diagnosis_ip_no_ipv6",
details=["diagnosis_ip_global", "diagnosis_ip_local"]
if ipv6
- else ["diagnosis_ip_no_ipv6_tip"],
+ else [
+ "diagnosis_ip_no_ipv6_tip_important"
+ if is_ipvx_important(6)
+ else "diagnosis_ip_no_ipv6_tip"
+ ],
)
# TODO / FIXME : add some attempt to detect ISP (using whois ?) ?
def can_ping_outside(self, protocol=4):
-
assert protocol in [
4,
6,
@@ -202,7 +231,6 @@ class MyDiagnoser(Diagnoser):
return len(content) == 1 and content[0].split() == ["nameserver", "127.0.0.1"]
def get_public_ip(self, protocol=4):
-
# FIXME - TODO : here we assume that DNS resolution for ip.yunohost.org is working
# but if we want to be able to diagnose DNS resolution issues independently from
# internet connectivity, we gotta rely on fixed IPs first....
@@ -221,5 +249,5 @@ class MyDiagnoser(Diagnoser):
except Exception as e:
protocol = str(protocol)
e = str(e)
- self.logger_debug(f"Could not get public IPv{protocol} : {e}")
+ logger.debug(f"Could not get public IPv{protocol} : {e}")
return None
diff --git a/src/diagnosers/12-dnsrecords.py b/src/diagnosers/12-dnsrecords.py
index 4d30bb1a7..196a2e1f9 100644
--- a/src/diagnosers/12-dnsrecords.py
+++ b/src/diagnosers/12-dnsrecords.py
@@ -1,5 +1,21 @@
-#!/usr/bin/env python
-
+#
+# Copyright (c) 2023 YunoHost Contributors
+#
+# This file is part of YunoHost (see https://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 .
+#
import os
import re
from typing import List
@@ -27,13 +43,11 @@ logger = log.getActionLogger("yunohost.diagnosis")
class MyDiagnoser(Diagnoser):
-
id_ = os.path.splitext(os.path.basename(__file__))[0].split("-")[1]
cache_duration = 600
dependencies: List[str] = ["ip"]
def run(self):
-
main_domain = _get_maindomain()
major_domains = domain_list(exclude_subdomains=True)["domains"]
@@ -61,7 +75,6 @@ class MyDiagnoser(Diagnoser):
yield report
def check_domain(self, domain, is_main_domain):
-
if is_special_use_tld(domain):
yield dict(
meta={"domain": domain},
@@ -81,13 +94,11 @@ class MyDiagnoser(Diagnoser):
categories = ["basic", "mail", "xmpp", "extra"]
for category in categories:
-
records = expected_configuration[category]
discrepancies = []
results = {}
for r in records:
-
id_ = r["type"] + ":" + r["name"]
fqdn = r["name"] + "." + base_dns_zone if r["name"] != "@" else domain
@@ -105,7 +116,7 @@ class MyDiagnoser(Diagnoser):
if r["value"] == "@":
r["value"] = domain + "."
elif r["type"] == "CNAME":
- r["value"] = r["value"] + f".{base_dns_zone}."
+ r["value"] = r["value"] # + f".{base_dns_zone}."
if self.current_record_match_expected(r):
results[id_] = "OK"
@@ -166,12 +177,15 @@ class MyDiagnoser(Diagnoser):
yield output
def get_current_record(self, fqdn, type_):
-
success, answers = dig(fqdn, type_, resolvers="force_external")
if success != "ok":
return None
else:
+ if type_ == "TXT" and isinstance(answers, list):
+ for part in answers:
+ if part.startswith('"v=spf1'):
+ return part
return answers[0] if len(answers) == 1 else answers
def current_record_match_expected(self, r):
@@ -207,6 +221,11 @@ class MyDiagnoser(Diagnoser):
expected = r["value"].split()[-1]
current = r["current"].split()[-1]
return expected == current
+ elif r["type"] == "CAA":
+ # For CAA, check only the last item, ignore the 0 / 128 nightmare
+ expected = r["value"].split()[-1]
+ current = r["current"].split()[-1]
+ return expected == current
else:
return r["current"] == r["value"]
diff --git a/src/diagnosers/14-ports.py b/src/diagnosers/14-ports.py
index be172e524..34c512f14 100644
--- a/src/diagnosers/14-ports.py
+++ b/src/diagnosers/14-ports.py
@@ -1,20 +1,35 @@
-#!/usr/bin/env python
-
+#
+# Copyright (c) 2023 YunoHost Contributors
+#
+# This file is part of YunoHost (see https://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 .
+#
import os
from typing import List
from yunohost.diagnosis import Diagnoser
from yunohost.service import _get_services
+from yunohost.settings import settings_get
class MyDiagnoser(Diagnoser):
-
id_ = os.path.splitext(os.path.basename(__file__))[0].split("-")[1]
cache_duration = 600
dependencies: List[str] = ["ip"]
def run(self):
-
# TODO: report a warning if port 53 or 5353 is exposed to the outside world...
# This dict is something like :
@@ -30,7 +45,10 @@ class MyDiagnoser(Diagnoser):
ipversions = []
ipv4 = Diagnoser.get_cached_report("ip", item={"test": "ipv4"}) or {}
- if ipv4.get("status") == "SUCCESS":
+ if (
+ ipv4.get("status") == "SUCCESS"
+ or settings_get("misc.network.dns_exposure") != "ipv6"
+ ):
ipversions.append(4)
# To be discussed: we could also make this check dependent on the
@@ -104,7 +122,10 @@ class MyDiagnoser(Diagnoser):
for record in dnsrecords.get("items", [])
)
- if failed == 4 or ipv6_is_important():
+ if (
+ failed == 4
+ and settings_get("misc.network.dns_exposure") in ["both", "ipv4"]
+ ) or (failed == 6 and ipv6_is_important()):
yield dict(
meta={"port": port},
data={
diff --git a/src/diagnosers/21-web.py b/src/diagnosers/21-web.py
index 584505ad1..cc6edd7dc 100644
--- a/src/diagnosers/21-web.py
+++ b/src/diagnosers/21-web.py
@@ -1,31 +1,45 @@
-#!/usr/bin/env python
-
+#
+# Copyright (c) 2023 YunoHost Contributors
+#
+# This file is part of YunoHost (see https://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 .
+#
import os
import random
import requests
from typing import List
-from moulinette.utils.filesystem import read_file
+from moulinette.utils.filesystem import read_file, mkdir, rm
from yunohost.diagnosis import Diagnoser
from yunohost.domain import domain_list
from yunohost.utils.dns import is_special_use_tld
+from yunohost.settings import settings_get
DIAGNOSIS_SERVER = "diagnosis.yunohost.org"
class MyDiagnoser(Diagnoser):
-
id_ = os.path.splitext(os.path.basename(__file__))[0].split("-")[1]
cache_duration = 600
dependencies: List[str] = ["ip"]
def run(self):
-
all_domains = domain_list()["domains"]
domains_to_check = []
for domain in all_domains:
-
# If the diagnosis location ain't defined, can't do diagnosis,
# probably because nginx conf manually modified...
nginx_conf = "/etc/nginx/conf.d/%s.conf" % domain
@@ -46,9 +60,9 @@ class MyDiagnoser(Diagnoser):
domains_to_check.append(domain)
self.nonce = "".join(random.choice("0123456789abcedf") for i in range(16))
- os.system("rm -rf /tmp/.well-known/ynh-diagnosis/")
- os.system("mkdir -p /tmp/.well-known/ynh-diagnosis/")
- os.system("touch /tmp/.well-known/ynh-diagnosis/%s" % self.nonce)
+ rm("/var/www/.well-known/ynh-diagnosis/", recursive=True, force=True)
+ mkdir("/var/www/.well-known/ynh-diagnosis/", parents=True, mode=0o0775)
+ os.system("touch /var/www/.well-known/ynh-diagnosis/%s" % self.nonce)
if not domains_to_check:
return
@@ -60,7 +74,9 @@ class MyDiagnoser(Diagnoser):
ipversions = []
ipv4 = Diagnoser.get_cached_report("ip", item={"test": "ipv4"}) or {}
- if ipv4.get("status") == "SUCCESS":
+ if ipv4.get("status") == "SUCCESS" and settings_get(
+ "misc.network.dns_exposure"
+ ) in ["both", "ipv4"]:
ipversions.append(4)
# To be discussed: we could also make this check dependent on the
@@ -80,7 +96,10 @@ class MyDiagnoser(Diagnoser):
# "curl --head the.global.ip" will simply timeout...
if self.do_hairpinning_test:
global_ipv4 = ipv4.get("data", {}).get("global", None)
- if global_ipv4:
+ if global_ipv4 and settings_get("misc.network.dns_exposure") in [
+ "both",
+ "ipv4",
+ ]:
try:
requests.head("http://" + global_ipv4, timeout=5)
except requests.exceptions.Timeout:
@@ -97,7 +116,6 @@ class MyDiagnoser(Diagnoser):
pass
def test_http(self, domains, ipversions):
-
results = {}
for ipversion in ipversions:
try:
@@ -122,7 +140,6 @@ class MyDiagnoser(Diagnoser):
return
for domain in domains:
-
# i18n: diagnosis_http_bad_status_code
# i18n: diagnosis_http_connection_error
# i18n: diagnosis_http_timeout
@@ -131,7 +148,10 @@ class MyDiagnoser(Diagnoser):
if all(
results[ipversion][domain]["status"] == "ok" for ipversion in ipversions
):
- if 4 in ipversions:
+ if 4 in ipversions and settings_get("misc.network.dns_exposure") in [
+ "both",
+ "ipv4",
+ ]:
self.do_hairpinning_test = True
yield dict(
meta={"domain": domain},
@@ -169,7 +189,9 @@ class MyDiagnoser(Diagnoser):
)
AAAA_status = dnsrecords.get("data", {}).get("AAAA:@")
- return AAAA_status in ["OK", "WRONG"]
+ return AAAA_status in ["OK", "WRONG"] or settings_get(
+ "misc.network.dns_exposure"
+ ) in ["both", "ipv6"]
if failed == 4 or ipv6_is_important_for_this_domain():
yield dict(
diff --git a/src/diagnosers/24-mail.py b/src/diagnosers/24-mail.py
index 7fe7a08db..df14222a5 100644
--- a/src/diagnosers/24-mail.py
+++ b/src/diagnosers/24-mail.py
@@ -1,5 +1,21 @@
-#!/usr/bin/env python
-
+#
+# Copyright (c) 2023 YunoHost Contributors
+#
+# This file is part of YunoHost (see https://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 .
+#
import os
import dns.resolver
import re
@@ -22,13 +38,11 @@ logger = log.getActionLogger("yunohost.diagnosis")
class MyDiagnoser(Diagnoser):
-
id_ = os.path.splitext(os.path.basename(__file__))[0].split("-")[1]
cache_duration = 600
dependencies: List[str] = ["ip"]
def run(self):
-
self.ehlo_domain = _get_maindomain()
self.mail_domains = domain_list()["domains"]
self.ipversions, self.ips = self.get_ips_checked()
@@ -263,7 +277,7 @@ class MyDiagnoser(Diagnoser):
data={"error": str(e)},
status="ERROR",
summary="diagnosis_mail_queue_unavailable",
- details="diagnosis_mail_queue_unavailable_details",
+ details=["diagnosis_mail_queue_unavailable_details"],
)
else:
if pending_emails > 100:
@@ -285,13 +299,17 @@ class MyDiagnoser(Diagnoser):
outgoing_ipversions = []
outgoing_ips = []
ipv4 = Diagnoser.get_cached_report("ip", {"test": "ipv4"}) or {}
- if ipv4.get("status") == "SUCCESS":
+ if ipv4.get("status") == "SUCCESS" and settings_get(
+ "misc.network.dns_exposure"
+ ) in ["both", "ipv4"]:
outgoing_ipversions.append(4)
global_ipv4 = ipv4.get("data", {}).get("global", {})
if global_ipv4:
outgoing_ips.append(global_ipv4)
- if settings_get("smtp.allow_ipv6"):
+ if settings_get("email.smtp.smtp_allow_ipv6") or settings_get(
+ "misc.network.dns_exposure"
+ ) in ["both", "ipv6"]:
ipv6 = Diagnoser.get_cached_report("ip", {"test": "ipv6"}) or {}
if ipv6.get("status") == "SUCCESS":
outgoing_ipversions.append(6)
diff --git a/src/diagnosers/30-services.py b/src/diagnosers/30-services.py
index f09688911..42ea9d18f 100644
--- a/src/diagnosers/30-services.py
+++ b/src/diagnosers/30-services.py
@@ -1,5 +1,21 @@
-#!/usr/bin/env python
-
+#
+# Copyright (c) 2023 YunoHost Contributors
+#
+# This file is part of YunoHost (see https://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 .
+#
import os
from typing import List
@@ -8,17 +24,14 @@ from yunohost.service import service_status
class MyDiagnoser(Diagnoser):
-
id_ = os.path.splitext(os.path.basename(__file__))[0].split("-")[1]
cache_duration = 300
dependencies: List[str] = []
def run(self):
-
all_result = service_status()
for service, result in sorted(all_result.items()):
-
item = dict(
meta={"service": service},
data={
diff --git a/src/diagnosers/50-systemresources.py b/src/diagnosers/50-systemresources.py
index 6ac7f0ec4..096c3483f 100644
--- a/src/diagnosers/50-systemresources.py
+++ b/src/diagnosers/50-systemresources.py
@@ -1,4 +1,21 @@
-#!/usr/bin/env python
+#
+# Copyright (c) 2023 YunoHost Contributors
+#
+# This file is part of YunoHost (see https://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 .
+#
import os
import psutil
import datetime
@@ -11,13 +28,11 @@ from yunohost.diagnosis import Diagnoser
class MyDiagnoser(Diagnoser):
-
id_ = os.path.splitext(os.path.basename(__file__))[0].split("-")[1]
cache_duration = 300
dependencies: List[str] = []
def run(self):
-
MB = 1024**2
GB = MB * 1024
@@ -172,7 +187,6 @@ class MyDiagnoser(Diagnoser):
return []
def analyzed_kern_log():
-
cmd = 'tail -n 10000 /var/log/kern.log | grep "oom_reaper: reaped process" || true'
out = check_output(cmd)
lines = out.split("\n") if out else []
diff --git a/src/diagnosers/70-regenconf.py b/src/diagnosers/70-regenconf.py
index 591f883a4..65195aac5 100644
--- a/src/diagnosers/70-regenconf.py
+++ b/src/diagnosers/70-regenconf.py
@@ -1,5 +1,21 @@
-#!/usr/bin/env python
-
+#
+# Copyright (c) 2023 YunoHost Contributors
+#
+# This file is part of YunoHost (see https://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 .
+#
import os
import re
from typing import List
@@ -11,13 +27,11 @@ from moulinette.utils.filesystem import read_file
class MyDiagnoser(Diagnoser):
-
id_ = os.path.splitext(os.path.basename(__file__))[0].split("-")[1]
cache_duration = 300
dependencies: List[str] = []
def run(self):
-
regenconf_modified_files = list(self.manually_modified_files())
if not regenconf_modified_files:
@@ -53,7 +67,7 @@ class MyDiagnoser(Diagnoser):
)
# Check consistency between actual ssh port in sshd_config vs. setting
- ssh_port_setting = settings_get("security.ssh.port")
+ ssh_port_setting = settings_get("security.ssh.ssh_port")
ssh_port_line = re.findall(
r"\bPort *([0-9]{2,5})\b", read_file("/etc/ssh/sshd_config")
)
@@ -66,7 +80,6 @@ class MyDiagnoser(Diagnoser):
)
def manually_modified_files(self):
-
for category, infos in _get_regenconf_infos().items():
for path, hash_ in infos["conffiles"].items():
if hash_ != _calculate_hash(path):
diff --git a/src/diagnosers/80-apps.py b/src/diagnosers/80-apps.py
index 56e45f831..93cefeaaf 100644
--- a/src/diagnosers/80-apps.py
+++ b/src/diagnosers/80-apps.py
@@ -1,5 +1,21 @@
-#!/usr/bin/env python
-
+#
+# Copyright (c) 2023 YunoHost Contributors
+#
+# This file is part of YunoHost (see https://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 .
+#
import os
from typing import List
@@ -9,13 +25,11 @@ from yunohost.diagnosis import Diagnoser
class MyDiagnoser(Diagnoser):
-
id_ = os.path.splitext(os.path.basename(__file__))[0].split("-")[1]
cache_duration = 300
dependencies: List[str] = []
def run(self):
-
apps = app_list(full=True)["apps"]
for app in apps:
app["issues"] = list(self.issues(app))
@@ -28,7 +42,6 @@ class MyDiagnoser(Diagnoser):
)
else:
for app in apps:
-
if not app["issues"]:
continue
@@ -46,16 +59,15 @@ class MyDiagnoser(Diagnoser):
)
def issues(self, app):
-
# Check quality level in catalog
if not app.get("from_catalog") or app["from_catalog"].get("state") != "working":
- yield ("error", "diagnosis_apps_not_in_app_catalog")
+ yield ("warning", "diagnosis_apps_not_in_app_catalog")
elif (
not isinstance(app["from_catalog"].get("level"), int)
or app["from_catalog"]["level"] == 0
):
- yield ("error", "diagnosis_apps_broken")
+ yield ("warning", "diagnosis_apps_broken")
elif app["from_catalog"]["level"] <= 4:
yield ("warning", "diagnosis_apps_bad_quality")
@@ -64,7 +76,9 @@ class MyDiagnoser(Diagnoser):
yunohost_version_req = (
app["manifest"].get("requirements", {}).get("yunohost", "").strip(">= ")
)
- if yunohost_version_req.startswith("2."):
+ if yunohost_version_req.startswith("2.") or yunohost_version_req.startswith(
+ "3."
+ ):
yield ("error", "diagnosis_apps_outdated_ynh_requirement")
deprecated_helpers = [
diff --git a/src/diagnosers/__init__.py b/src/diagnosers/__init__.py
index e69de29bb..7c1e7b0cd 100644
--- a/src/diagnosers/__init__.py
+++ b/src/diagnosers/__init__.py
@@ -0,0 +1,18 @@
+#
+# Copyright (c) 2023 YunoHost Contributors
+#
+# This file is part of YunoHost (see https://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 .
+#
diff --git a/src/diagnosis.py b/src/diagnosis.py
index 007719dfc..02047c001 100644
--- a/src/diagnosis.py
+++ b/src/diagnosis.py
@@ -1,29 +1,21 @@
-# -*- coding: utf-8 -*-
-
-""" License
-
- Copyright (C) 2018 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
-
-"""
-
-""" diagnosis.py
-
- Look for possible issues on the server
-"""
-
+#
+# Copyright (c) 2023 YunoHost Contributors
+#
+# This file is part of YunoHost (see https://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 .
+#
import re
import os
import time
@@ -53,7 +45,6 @@ def diagnosis_list():
def diagnosis_get(category, item):
-
# Get all the categories
all_categories_names = _list_diagnosis_categories()
@@ -77,7 +68,6 @@ def diagnosis_get(category, item):
def diagnosis_show(
categories=[], issues=False, full=False, share=False, human_readable=False
):
-
if not os.path.exists(DIAGNOSIS_CACHE):
logger.warning(m18n.n("diagnosis_never_ran_yet"))
return
@@ -98,7 +88,6 @@ def diagnosis_show(
# Fetch all reports
all_reports = []
for category in categories:
-
try:
report = Diagnoser.get_cached_report(category)
except Exception as e:
@@ -147,7 +136,6 @@ def diagnosis_show(
def _dump_human_readable_reports(reports):
-
output = ""
for report in reports:
@@ -167,7 +155,6 @@ def _dump_human_readable_reports(reports):
def diagnosis_run(
categories=[], force=False, except_if_never_ran_yet=False, email=False
):
-
if (email or except_if_never_ran_yet) and not os.path.exists(DIAGNOSIS_CACHE):
return
@@ -271,7 +258,6 @@ def _diagnosis_ignore(add_filter=None, remove_filter=None, list=False):
return {"ignore_filters": configuration.get("ignore_filters", {})}
def validate_filter_criterias(filter_):
-
# Get all the categories
all_categories_names = _list_diagnosis_categories()
@@ -294,7 +280,6 @@ def _diagnosis_ignore(add_filter=None, remove_filter=None, list=False):
return category, criterias
if add_filter:
-
category, criterias = validate_filter_criterias(add_filter)
# Fetch current issues for the requested category
@@ -328,7 +313,6 @@ def _diagnosis_ignore(add_filter=None, remove_filter=None, list=False):
return
if remove_filter:
-
category, criterias = validate_filter_criterias(remove_filter)
# Make sure the subdicts/lists exists
@@ -402,12 +386,10 @@ def add_ignore_flag_to_issues(report):
class Diagnoser:
def __init__(self):
-
self.cache_file = Diagnoser.cache_file(self.id_)
self.description = Diagnoser.get_description(self.id_)
def cached_time_ago(self):
-
if not os.path.exists(self.cache_file):
return 99999999
return time.time() - os.path.getmtime(self.cache_file)
@@ -418,7 +400,6 @@ class Diagnoser:
return write_to_json(self.cache_file, report)
def diagnose(self, force=False):
-
if not force and self.cached_time_ago() < self.cache_duration:
logger.debug(f"Cache still valid : {self.cache_file}")
logger.info(
@@ -556,7 +537,6 @@ class Diagnoser:
@staticmethod
def i18n(report, force_remove_html_tags=False):
-
# "Render" the strings with m18n.n
# N.B. : we do those m18n.n right now instead of saving the already-translated report
# because we can't be sure we'll redisplay the infos with the same locale as it
@@ -566,7 +546,6 @@ class Diagnoser:
report["description"] = Diagnoser.get_description(report["id"])
for item in report["items"]:
-
# For the summary and each details, we want to call
# m18n() on the string, with the appropriate data for string
# formatting which can come from :
@@ -605,7 +584,6 @@ class Diagnoser:
@staticmethod
def remote_diagnosis(uri, data, ipversion, timeout=30):
-
# Lazy loading for performance
import requests
import socket
@@ -654,7 +632,6 @@ class Diagnoser:
def _list_diagnosis_categories():
-
paths = glob.glob(os.path.dirname(__file__) + "/diagnosers/??-*.py")
names = [
name.split("-")[-1]
@@ -665,7 +642,6 @@ def _list_diagnosis_categories():
def _load_diagnoser(diagnoser_name):
-
logger.debug(f"Loading diagnoser {diagnoser_name}")
paths = glob.glob(os.path.dirname(__file__) + f"/diagnosers/??-{diagnoser_name}.py")
diff --git a/src/dns.py b/src/dns.py
index c8bebed41..e3a26044c 100644
--- a/src/dns.py
+++ b/src/dns.py
@@ -1,28 +1,21 @@
-# -*- 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
-"""
+#
+# Copyright (c) 2023 YunoHost Contributors
+#
+# This file is part of YunoHost (see https://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 .
+#
import os
import re
import time
@@ -35,16 +28,17 @@ from moulinette.utils.log import getActionLogger
from moulinette.utils.filesystem import read_file, write_to_file, read_toml, mkdir
from yunohost.domain import (
- domain_list,
_assert_domain_exists,
domain_config_get,
_get_domain_settings,
_set_domain_settings,
_list_subdomains_of,
+ _get_parent_domain_of,
)
from yunohost.utils.dns import dig, is_yunohost_dyndns_domain, is_special_use_tld
from yunohost.utils.error import YunohostValidationError, YunohostError
from yunohost.utils.network import get_public_ip
+from yunohost.settings import settings_get
from yunohost.log import is_unit_operation
from yunohost.hook import hook_callback
@@ -144,7 +138,7 @@ def _build_dns_conf(base_domain, include_empty_AAAA_if_no_ipv6=False):
{"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},
+ {"type": "CAA", "name": "@", "value": "0 issue \"letsencrypt.org\"", "ttl": 3600},
],
"example_of_a_custom_rule": [
{"type": "SRV", "name": "_matrix", "value": "domain.tld.", "ttl": 3600}
@@ -175,7 +169,6 @@ def _build_dns_conf(base_domain, include_empty_AAAA_if_no_ipv6=False):
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 # @ # #
@@ -192,7 +185,7 @@ def _build_dns_conf(base_domain, include_empty_AAAA_if_no_ipv6=False):
###########################
# Basic ipv4/ipv6 records #
###########################
- if ipv4:
+ if ipv4 and settings_get("misc.network.dns_exposure") in ["both", "ipv4"]:
basic.append([basename, ttl, "A", ipv4])
if ipv6:
@@ -235,10 +228,10 @@ def _build_dns_conf(base_domain, include_empty_AAAA_if_no_ipv6=False):
"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],
+ [f"muc{suffix}", ttl, "CNAME", f"{domain}."],
+ [f"pubsub{suffix}", ttl, "CNAME", f"{domain}."],
+ [f"vjud{suffix}", ttl, "CNAME", f"{domain}."],
+ [f"xmpp-upload{suffix}", ttl, "CNAME", f"{domain}."],
]
#########
@@ -247,7 +240,7 @@ def _build_dns_conf(base_domain, include_empty_AAAA_if_no_ipv6=False):
# Only recommend wildcard and CAA for the top level
if domain == base_domain:
- if ipv4:
+ if ipv4 and settings_get("misc.network.dns_exposure") in ["both", "ipv4"]:
extra.append([f"*{suffix}", ttl, "A", ipv4])
if ipv6:
@@ -255,7 +248,7 @@ def _build_dns_conf(base_domain, include_empty_AAAA_if_no_ipv6=False):
elif include_empty_AAAA_if_no_ipv6:
extra.append([f"*{suffix}", ttl, "AAAA", None])
- extra.append([basename, ttl, "CAA", '128 issue "letsencrypt.org"'])
+ extra.append([basename, ttl, "CAA", '0 issue "letsencrypt.org"'])
####################
# Standard records #
@@ -446,8 +439,8 @@ def _get_dns_zone_for_domain(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_domain = _get_parent_domain_of(domain)
+ if parent_domain:
parent_cache_file = f"{cache_folder}/{parent_domain}"
if (
os.path.exists(parent_cache_file)
@@ -468,7 +461,6 @@ def _get_dns_zone_for_domain(domain):
# We don't wan't to do A NS request on the tld
for parent in parent_list[0:-1]:
-
# Check if there's a NS record for that domain
answer = dig(parent, rdtype="NS", full_answers=True, resolvers="force_external")
@@ -509,20 +501,22 @@ def _get_relative_name_for_dns_zone(domain, base_dns_zone):
def _get_registrar_config_section(domain):
-
from lexicon.providers.auto import _relevant_provider_for_domain
- registrar_infos = {}
+ registrar_infos = {
+ "name": m18n.n(
+ "registrar_infos"
+ ), # This is meant to name the config panel section, for proper display in the webadmin
+ }
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"]:
-
+ parent_domain = _get_parent_domain_of(domain, topest=True)
+ if parent_domain:
# Dirty hack to have a link on the webadmin
if Moulinette.interface.type == "api":
- parent_domain_link = f"[{parent_domain}](#/domains/{parent_domain}/config)"
+ parent_domain_link = f"[{parent_domain}](#/domains/{parent_domain}/dns)"
else:
parent_domain_link = parent_domain
@@ -532,7 +526,7 @@ def _get_registrar_config_section(domain):
"style": "info",
"ask": m18n.n(
"domain_dns_registrar_managed_in_parent_domain",
- parent_domain=domain,
+ parent_domain=parent_domain,
parent_domain_link=parent_domain_link,
),
"value": "parent_domain",
@@ -574,7 +568,6 @@ def _get_registrar_config_section(domain):
}
)
else:
-
registrar_infos["registrar"] = OrderedDict(
{
"type": "alert",
@@ -598,7 +591,12 @@ def _get_registrar_config_section(domain):
# 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]
+ registrar_credentials = registrar_list.get(registrar)
+ if registrar_credentials is None:
+ logger.warning(
+ f"Registrar {registrar} unknown / Should be added to YunoHost's registrar_list.toml by the development team!"
+ )
+ registrar_credentials = {}
for credential, infos in registrar_credentials.items():
infos["default"] = infos.get("default", "")
infos["optional"] = infos.get("optional", "False")
@@ -608,7 +606,6 @@ def _get_registrar_config_section(domain):
def _get_registar_settings(domain):
-
_assert_domain_exists(domain)
settings = domain_config_get(domain, key="dns.registrar", export=True)
@@ -647,7 +644,7 @@ def domain_dns_push(operation_logger, domain, dry_run=False, force=False, purge=
return {}
if registrar == "parent_domain":
- parent_domain = domain.split(".", 1)[1]
+ parent_domain = _get_parent_domain_of(domain, topest=True)
registar, registrar_credentials = _get_registar_settings(parent_domain)
if any(registrar_credentials.values()):
raise YunohostValidationError(
@@ -672,7 +669,6 @@ def domain_dns_push(operation_logger, domain, dry_run=False, force=False, purge=
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}"
@@ -747,7 +743,6 @@ def domain_dns_push(operation_logger, domain, dry_run=False, force=False, purge=
]
for record in current_records:
-
# Try to get rid of weird stuff like ".domain.tld" or "@.domain.tld"
record["name"] = record["name"].strip("@").strip(".")
@@ -797,7 +792,6 @@ def domain_dns_push(operation_logger, domain, dry_run=False, force=False, purge=
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
#
@@ -941,9 +935,7 @@ def domain_dns_push(operation_logger, domain, dry_run=False, force=False, purge=
results = {"warnings": [], "errors": []}
for action in ["delete", "create", "update"]:
-
for record in changes[action]:
-
relative_name = _get_relative_name_for_dns_zone(
record["name"], base_dns_zone
)
@@ -968,6 +960,9 @@ def domain_dns_push(operation_logger, domain, dry_run=False, force=False, purge=
f"Pushing {record['type']} records is not properly supported by Lexicon/Godaddy."
)
continue
+ elif registrar == "gandi":
+ if record["name"] == base_dns_zone:
+ record["name"] = "@." + record["name"]
record["action"] = action
query = (
@@ -1028,7 +1023,6 @@ def _set_managed_dns_records_hashes(domain: str, hashes: list) -> None:
def _hash_dns_record(record: dict) -> int:
-
fields = ["name", "type", "content"]
record_ = {f: record.get(f) for f in fields}
diff --git a/src/domain.py b/src/domain.py
index e40b4f03c..4f96d08c4 100644
--- a/src/domain.py
+++ b/src/domain.py
@@ -1,30 +1,25 @@
-# -*- 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
-"""
+#
+# Copyright (c) 2023 YunoHost Contributors
+#
+# This file is part of YunoHost (see https://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 .
+#
import os
-from typing import Dict, Any
+import time
+from typing import List, Optional
+from collections import OrderedDict
from moulinette import m18n, Moulinette
from moulinette.core import MoulinetteError
@@ -38,96 +33,181 @@ from yunohost.app import (
_get_conflicting_apps,
)
from yunohost.regenconf import regen_conf, _force_clear_hashes, _process_regen_conf
-from yunohost.utils.config import ConfigPanel, Question
+from yunohost.utils.configpanel import ConfigPanel
+from yunohost.utils.form import BaseOption
from yunohost.utils.error import YunohostError, YunohostValidationError
from yunohost.log import is_unit_operation
logger = getActionLogger("yunohost.domain")
-DOMAIN_CONFIG_PATH = "/usr/share/yunohost/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] = {}
+# The cache automatically expire every 15 seconds, to prevent desync between
+# yunohost CLI and API which run in different processes
+domain_list_cache: List[str] = []
+domain_list_cache_timestamp = 0
+main_domain_cache: Optional[str] = None
+main_domain_cache_timestamp = 0
+DOMAIN_CACHE_DURATION = 15
-def domain_list(exclude_subdomains=False):
+def _get_maindomain():
+ global main_domain_cache
+ global main_domain_cache_timestamp
+ if (
+ not main_domain_cache
+ or abs(main_domain_cache_timestamp - time.time()) > DOMAIN_CACHE_DURATION
+ ):
+ with open("/etc/yunohost/current_host", "r") as f:
+ main_domain_cache = f.readline().rstrip()
+ main_domain_cache_timestamp = time.time()
+
+ return main_domain_cache
+
+
+def _get_domains(exclude_subdomains=False):
+ global domain_list_cache
+ global domain_list_cache_timestamp
+ if (
+ not domain_list_cache
+ or abs(domain_list_cache_timestamp - time.time()) > DOMAIN_CACHE_DURATION
+ ):
+ from yunohost.utils.ldap import _get_ldap_interface
+
+ ldap = _get_ldap_interface()
+ result = [
+ entry["virtualdomain"][0]
+ for entry in ldap.search("ou=domains", "virtualdomain=*", ["virtualdomain"])
+ ]
+
+ def cmp_domain(domain):
+ # Keep the main part of the domain and the extension together
+ # eg: this.is.an.example.com -> ['example.com', 'an', 'is', 'this']
+ domain = domain.split(".")
+ domain[-1] = domain[-2] + domain.pop()
+ return list(reversed(domain))
+
+ domain_list_cache = sorted(result, key=cmp_domain)
+ domain_list_cache_timestamp = time.time()
+
+ if exclude_subdomains:
+ return [
+ domain for domain in domain_list_cache if not _get_parent_domain_of(domain)
+ ]
+
+ return domain_list_cache
+
+
+def domain_list(exclude_subdomains=False, tree=False, features=[]):
"""
List domains
Keyword argument:
exclude_subdomains -- Filter out domains that are subdomains of other declared domains
+ tree -- Display domains as a hierarchy tree
"""
- global domain_list_cache
- if not exclude_subdomains and domain_list_cache:
- return domain_list_cache
- from yunohost.utils.ldap import _get_ldap_interface
+ domains = _get_domains(exclude_subdomains)
+ main = _get_maindomain()
- ldap = _get_ldap_interface()
- result = [
- entry["virtualdomain"][0]
- for entry in ldap.search("ou=domains", "virtualdomain=*", ["virtualdomain"])
- ]
+ if features:
+ domains_filtered = []
+ for domain in domains:
+ config = domain_config_get(domain, key="feature", export=True)
+ if any(config.get(feature) == 1 for feature in features):
+ domains_filtered.append(domain)
+ domains = domains_filtered
- result_list = []
- for domain in result:
- if exclude_subdomains:
- parent_domain = domain.split(".", 1)[1]
- if parent_domain in result:
- continue
+ if not tree:
+ return {"domains": domains, "main": main}
- result_list.append(domain)
+ if tree and exclude_subdomains:
+ return {
+ "domains": OrderedDict({domain: {} for domain in domains}),
+ "main": main,
+ }
- def cmp_domain(domain):
- # Keep the main part of the domain and the extension together
- # eg: this.is.an.example.com -> ['example.com', 'an', 'is', 'this']
- domain = domain.split(".")
- domain[-1] = domain[-2] + domain.pop()
- domain = list(reversed(domain))
- return domain
+ def get_parent_dict(tree, child):
+ # If parent exists it should be the last added (see `_get_domains` ordering)
+ possible_parent = next(reversed(tree)) if tree else None
+ if possible_parent and child.endswith(f".{possible_parent}"):
+ return get_parent_dict(tree[possible_parent], child)
+ return tree
- result_list = sorted(result_list, key=cmp_domain)
+ result = OrderedDict()
+ for domain in domains:
+ parent = get_parent_dict(result, domain)
+ parent[domain] = OrderedDict()
- # Don't cache answer if using exclude_subdomains
- if exclude_subdomains:
- return {"domains": result_list, "main": _get_maindomain()}
+ return {"domains": result, "main": main}
- domain_list_cache = {"domains": result_list, "main": _get_maindomain()}
- return domain_list_cache
+
+def domain_info(domain):
+ """
+ Print aggregate data for a specific domain
+
+ Keyword argument:
+ domain -- Domain to be checked
+ """
+
+ from yunohost.app import app_info
+ from yunohost.dns import _get_registar_settings
+
+ _assert_domain_exists(domain)
+
+ registrar, _ = _get_registar_settings(domain)
+ certificate = domain_cert_status([domain], full=True)["certificates"][domain]
+
+ apps = []
+ for app in _installed_apps():
+ settings = _get_app_settings(app)
+ if settings.get("domain") == domain:
+ apps.append(
+ {
+ "name": app_info(app)["name"],
+ "id": app,
+ "path": settings.get("path", ""),
+ }
+ )
+
+ return {
+ "certificate": certificate,
+ "registrar": registrar,
+ "apps": apps,
+ "main": _get_maindomain() == domain,
+ "topest_parent": _get_parent_domain_of(domain, topest=True),
+ # TODO : add parent / child domains ?
+ }
def _assert_domain_exists(domain):
- if domain not in domain_list()["domains"]:
+ if domain not in _get_domains():
raise YunohostValidationError("domain_unknown", domain=domain)
def _list_subdomains_of(parent_domain):
-
_assert_domain_exists(parent_domain)
out = []
- for domain in domain_list()["domains"]:
+ for domain in _get_domains():
if domain.endswith(f".{parent_domain}"):
out.append(domain)
return out
-def _get_parent_domain_of(domain):
+def _get_parent_domain_of(domain, return_self=False, topest=False):
+ domains = _get_domains(exclude_subdomains=topest)
- _assert_domain_exists(domain)
+ domain_ = domain
+ while "." in domain_:
+ domain_ = domain_.split(".", 1)[1]
+ if domain_ in domains:
+ return domain_
- if "." not in domain:
- return domain
-
- parent_domain = domain.split(".", 1)[-1]
- if parent_domain not in domain_list()["domains"]:
- return domain # Domain is its own parent
-
- else:
- return _get_parent_domain_of(parent_domain)
+ return domain if return_self else None
@is_unit_operation()
@@ -148,6 +228,9 @@ def domain_add(operation_logger, domain, dyndns=False):
if domain.startswith("xmpp-upload."):
raise YunohostValidationError("domain_cannot_add_xmpp_upload")
+ if domain.startswith("muc."):
+ raise YunohostError("domain_cannot_add_muc_upload")
+
ldap = _get_ldap_interface()
try:
@@ -164,7 +247,6 @@ def domain_add(operation_logger, domain, dyndns=False):
# DynDNS domain
if dyndns:
-
from yunohost.utils.dns import is_yunohost_dyndns_domain
from yunohost.dyndns import _guess_current_dyndns_domain
@@ -199,7 +281,7 @@ def domain_add(operation_logger, domain, dyndns=False):
raise YunohostError("domain_creation_failed", domain=domain, error=e)
finally:
global domain_list_cache
- domain_list_cache = {}
+ domain_list_cache = []
# Don't regen these conf if we're still in postinstall
if os.path.exists("/etc/yunohost/installed"):
@@ -256,7 +338,7 @@ def domain_remove(operation_logger, domain, remove_apps=False, force=False):
# Check domain is not the main domain
if domain == _get_maindomain():
- other_domains = domain_list()["domains"]
+ other_domains = _get_domains()
other_domains.remove(domain)
if other_domains:
@@ -317,7 +399,7 @@ def domain_remove(operation_logger, domain, remove_apps=False, force=False):
raise YunohostError("domain_deletion_failed", domain=domain, error=e)
finally:
global domain_list_cache
- domain_list_cache = {}
+ domain_list_cache = []
stuff_to_delete = [
f"/etc/yunohost/certs/{domain}",
@@ -372,6 +454,8 @@ def domain_main_domain(operation_logger, new_main_domain=None):
if not new_main_domain:
return {"current_main_domain": _get_maindomain()}
+ old_main_domain = _get_maindomain()
+
# Check domain exists
_assert_domain_exists(new_main_domain)
@@ -381,8 +465,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 = {}
+ global main_domain_cache
+ main_domain_cache = new_main_domain
_set_hostname(new_main_domain)
except Exception as e:
logger.warning(str(e), exc_info=1)
@@ -395,6 +479,12 @@ def domain_main_domain(operation_logger, new_main_domain=None):
if os.path.exists("/etc/yunohost/installed"):
regen_conf()
+ from yunohost.user import _update_admins_group_aliases
+
+ _update_admins_group_aliases(
+ old_main_domain=old_main_domain, new_main_domain=new_main_domain
+ )
+
logger.success(m18n.n("main_domain_changed"))
@@ -410,12 +500,6 @@ def domain_url_available(domain, path):
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
@@ -444,7 +528,7 @@ def domain_config_set(
"""
Apply a new domain configuration
"""
- Question.operation_logger = operation_logger
+ BaseOption.operation_logger = operation_logger
config = DomainConfigPanel(domain)
return config.set(key, value, args, args_file, operation_logger=operation_logger)
@@ -454,6 +538,83 @@ class DomainConfigPanel(ConfigPanel):
save_path_tpl = f"{DOMAIN_SETTINGS_DIR}/{{entity}}.yml"
save_mode = "diff"
+ def get(self, key="", mode="classic"):
+ result = super().get(key=key, mode=mode)
+
+ if mode == "full":
+ for panel, section, option in self._iterate():
+ # This injects:
+ # i18n: domain_config_cert_renew_help
+ # i18n: domain_config_default_app_help
+ # i18n: domain_config_xmpp_help
+ if m18n.key_exists(self.config["i18n"] + "_" + option["id"] + "_help"):
+ option["help"] = m18n.n(
+ self.config["i18n"] + "_" + option["id"] + "_help"
+ )
+ return self.config
+
+ return result
+
+ def _get_raw_config(self):
+ toml = super()._get_raw_config()
+
+ toml["feature"]["xmpp"]["xmpp"]["default"] = (
+ 1 if self.entity == _get_maindomain() else 0
+ )
+
+ # Optimize wether or not to load the DNS section,
+ # e.g. we don't want to trigger the whole _get_registary_config_section
+ # when just getting the current value from the feature section
+ filter_key = self.filter_key.split(".") if self.filter_key != "" else []
+ if not filter_key or filter_key[0] == "dns":
+ from yunohost.dns import _get_registrar_config_section
+
+ toml["dns"]["registrar"] = _get_registrar_config_section(self.entity)
+
+ # FIXME: Ugly hack to save the registar id/value and reinject it in _get_raw_settings ...
+ self.registar_id = toml["dns"]["registrar"]["registrar"]["value"]
+ del toml["dns"]["registrar"]["registrar"]["value"]
+
+ # Cert stuff
+ if not filter_key or filter_key[0] == "cert":
+ from yunohost.certificate import certificate_status
+
+ status = certificate_status([self.entity], full=True)["certificates"][
+ self.entity
+ ]
+
+ toml["cert"]["cert"]["cert_summary"]["style"] = status["style"]
+
+ # i18n: domain_config_cert_summary_expired
+ # i18n: domain_config_cert_summary_selfsigned
+ # i18n: domain_config_cert_summary_abouttoexpire
+ # i18n: domain_config_cert_summary_ok
+ # i18n: domain_config_cert_summary_letsencrypt
+ toml["cert"]["cert"]["cert_summary"]["ask"] = m18n.n(
+ f"domain_config_cert_summary_{status['summary']}"
+ )
+
+ # FIXME: Ugly hack to save the cert status and reinject it in _get_raw_settings ...
+ self.cert_status = status
+
+ return toml
+
+ def _get_raw_settings(self):
+ # TODO add mechanism to share some settings with other domains on the same zone
+ super()._get_raw_settings()
+
+ # FIXME: Ugly hack to save the registar id/value and reinject it in _get_raw_settings ...
+ filter_key = self.filter_key.split(".") if self.filter_key != "" else []
+ if not filter_key or filter_key[0] == "dns":
+ self.values["registrar"] = self.registar_id
+
+ # FIXME: Ugly hack to save the cert status and reinject it in _get_raw_settings ...
+ if not filter_key or filter_key[0] == "cert":
+ self.values["cert_validity"] = self.cert_status["validity"]
+ self.values["cert_issuer"] = self.cert_status["CA_type"]
+ self.values["acme_eligible"] = self.cert_status["ACME_eligible"]
+ self.values["summary"] = self.cert_status["summary"]
+
def _apply(self):
if (
"default_app" in self.future_values
@@ -461,7 +622,7 @@ class DomainConfigPanel(ConfigPanel):
):
from yunohost.app import app_ssowatconf, app_map
- if "/" in app_map(raw=True)[self.entity]:
+ if "/" in app_map(raw=True).get(self.entity, {}):
raise YunohostValidationError(
"app_make_default_location_already_used",
app=self.future_values["default_app"],
@@ -478,42 +639,46 @@ class DomainConfigPanel(ConfigPanel):
):
app_ssowatconf()
- def _get_toml(self):
+ stuff_to_regen_conf = []
+ if (
+ "xmpp" in self.future_values
+ and self.future_values["xmpp"] != self.values["xmpp"]
+ ):
+ stuff_to_regen_conf.append("nginx")
+ stuff_to_regen_conf.append("metronome")
- toml = super()._get_toml()
+ if (
+ "mail_in" in self.future_values
+ and self.future_values["mail_in"] != self.values["mail_in"]
+ ) or (
+ "mail_out" in self.future_values
+ and self.future_values["mail_out"] != self.values["mail_out"]
+ ):
+ if "nginx" not in stuff_to_regen_conf:
+ stuff_to_regen_conf.append("nginx")
+ stuff_to_regen_conf.append("postfix")
+ stuff_to_regen_conf.append("dovecot")
+ stuff_to_regen_conf.append("rspamd")
- toml["feature"]["xmpp"]["xmpp"]["default"] = (
- 1 if self.entity == _get_maindomain() else 0
- )
+ if stuff_to_regen_conf:
+ regen_conf(names=stuff_to_regen_conf)
- # Optimize wether or not to load the DNS section,
- # e.g. we don't want to trigger the whole _get_registary_config_section
- # when just getting the current value from the feature section
- filter_key = self.filter_key.split(".") if self.filter_key != "" else []
- if not filter_key or filter_key[0] == "dns":
- from yunohost.dns import _get_registrar_config_section
- toml["dns"]["registrar"] = _get_registrar_config_section(self.entity)
+def domain_action_run(domain, action, args=None):
+ import urllib.parse
- # 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"]
+ if action == "cert.cert.cert_install":
+ from yunohost.certificate import certificate_install as action_func
+ elif action == "cert.cert.cert_renew":
+ from yunohost.certificate import certificate_renew as action_func
- return toml
+ args = dict(urllib.parse.parse_qsl(args or "", keep_blank_values=True))
+ no_checks = args["cert_no_checks"] in ("y", "yes", "on", "1")
- 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 ...
- filter_key = self.filter_key.split(".") if self.filter_key != "" else []
- if not filter_key or filter_key[0] == "dns":
- self.values["registrar"] = self.registar_id
+ action_func([domain], force=True, no_checks=no_checks)
def _get_domain_settings(domain: str) -> dict:
-
_assert_domain_exists(domain)
if os.path.exists(f"{DOMAIN_SETTINGS_DIR}/{domain}.yml"):
@@ -523,7 +688,6 @@ def _get_domain_settings(domain: str) -> dict:
def _set_domain_settings(domain: str, settings: dict) -> None:
-
_assert_domain_exists(domain)
write_to_yaml(f"{DOMAIN_SETTINGS_DIR}/{domain}.yml", settings)
diff --git a/src/dyndns.py b/src/dyndns.py
index 34f3dd5dc..2594abe8f 100644
--- a/src/dyndns.py
+++ b/src/dyndns.py
@@ -1,28 +1,21 @@
-# -*- 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_dyndns.py
-
- Subscribe and Update DynDNS Hosts
-"""
+#
+# Copyright (c) 2023 YunoHost Contributors
+#
+# This file is part of YunoHost (see https://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 .
+#
import os
import re
import json
@@ -234,7 +227,6 @@ def dyndns_update(
for dns_auth in DYNDNS_DNS_AUTH:
for type_ in ["A", "AAAA"]:
-
ok, result = dig(dns_auth, type_)
if ok == "ok" and len(result) and result[0]:
auth_resolvers.append(result[0])
@@ -245,7 +237,6 @@ def dyndns_update(
)
def resolve_domain(domain, rdtype):
-
ok, result = dig(domain, rdtype, resolvers=auth_resolvers)
if ok == "ok":
return result[0] if len(result) else None
diff --git a/src/firewall.py b/src/firewall.py
index a1c0b187f..392678fe1 100644
--- a/src/firewall.py
+++ b/src/firewall.py
@@ -1,28 +1,21 @@
-# -*- 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_firewall.py
-
- Manage firewall rules
-"""
+#
+# Copyright (c) 2023 YunoHost Contributors
+#
+# This file is part of YunoHost (see https://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 .
+#
import os
import yaml
import miniupnpc
@@ -39,7 +32,13 @@ logger = getActionLogger("yunohost.firewall")
def firewall_allow(
- protocol, port, ipv4_only=False, ipv6_only=False, no_upnp=False, no_reload=False
+ protocol,
+ port,
+ ipv4_only=False,
+ ipv6_only=False,
+ no_upnp=False,
+ no_reload=False,
+ reload_only_if_change=False,
):
"""
Allow connections on a port
@@ -77,14 +76,20 @@ def firewall_allow(
"ipv6",
]
+ changed = False
+
for p in protocols:
# Iterate over IP versions to add port
for i in ipvs:
if port not in firewall[i][p]:
firewall[i][p].append(port)
+ changed = True
else:
ipv = "IPv%s" % i[3]
- logger.warning(m18n.n("port_already_opened", port=port, ip_version=ipv))
+ if not reload_only_if_change:
+ logger.warning(
+ m18n.n("port_already_opened", port=port, ip_version=ipv)
+ )
# Add port forwarding with UPnP
if not no_upnp and port not in firewall["uPnP"][p]:
firewall["uPnP"][p].append(port)
@@ -96,12 +101,20 @@ def firewall_allow(
# Update and reload firewall
_update_firewall_file(firewall)
- if not no_reload:
+ if (not reload_only_if_change and not no_reload) or (
+ reload_only_if_change and changed
+ ):
return firewall_reload()
def firewall_disallow(
- protocol, port, ipv4_only=False, ipv6_only=False, upnp_only=False, no_reload=False
+ protocol,
+ port,
+ ipv4_only=False,
+ ipv6_only=False,
+ upnp_only=False,
+ no_reload=False,
+ reload_only_if_change=False,
):
"""
Disallow connections on a port
@@ -146,14 +159,20 @@ def firewall_disallow(
elif upnp_only:
ipvs = []
+ changed = False
+
for p in protocols:
# Iterate over IP versions to remove port
for i in ipvs:
if port in firewall[i][p]:
firewall[i][p].remove(port)
+ changed = True
else:
ipv = "IPv%s" % i[3]
- logger.warning(m18n.n("port_already_closed", port=port, ip_version=ipv))
+ if not reload_only_if_change:
+ logger.warning(
+ m18n.n("port_already_closed", port=port, ip_version=ipv)
+ )
# Remove port forwarding with UPnP
if upnp and port in firewall["uPnP"][p]:
firewall["uPnP"][p].remove(port)
@@ -163,7 +182,9 @@ def firewall_disallow(
# Update and reload firewall
_update_firewall_file(firewall)
- if not no_reload:
+ if (not reload_only_if_change and not no_reload) or (
+ reload_only_if_change and changed
+ ):
return firewall_reload()
@@ -310,7 +331,7 @@ def firewall_reload(skip_upnp=False):
# Refresh port forwarding with UPnP
firewall_upnp(no_refresh=False)
- _run_service_command("reload", "fail2ban")
+ _run_service_command("restart", "fail2ban")
if errors:
logger.warning(m18n.n("firewall_rules_cmd_failed"))
@@ -398,7 +419,6 @@ def firewall_upnp(action="status", no_refresh=False):
for protocol in ["TCP", "UDP"]:
if protocol + "_TO_CLOSE" in firewall["uPnP"]:
for port in firewall["uPnP"][protocol + "_TO_CLOSE"]:
-
if not isinstance(port, int):
# FIXME : how should we handle port ranges ?
logger.warning("Can't use UPnP to close '%s'" % port)
@@ -413,7 +433,6 @@ def firewall_upnp(action="status", no_refresh=False):
firewall["uPnP"][protocol + "_TO_CLOSE"] = []
for port in firewall["uPnP"][protocol]:
-
if not isinstance(port, int):
# FIXME : how should we handle port ranges ?
logger.warning("Can't use UPnP to open '%s'" % port)
diff --git a/src/hook.py b/src/hook.py
index 70d3b281b..4b07d1c17 100644
--- a/src/hook.py
+++ b/src/hook.py
@@ -1,28 +1,21 @@
-# -*- 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_hook.py
-
- Manage hooks
-"""
+#
+# Copyright (c) 2023 YunoHost Contributors
+#
+# This file is part of YunoHost (see https://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 .
+#
import os
import re
import sys
@@ -346,7 +339,6 @@ def hook_exec(
raise YunohostError("file_does_not_exist", path=path)
def is_relevant_warning(msg):
-
# Ignore empty warning messages...
if not msg:
return False
@@ -360,6 +352,31 @@ def hook_exec(
r"dpkg: warning: while removing .* not empty so not removed",
r"apt-key output should not be parsed",
r"update-rc.d: ",
+ r"update-alternatives: ",
+ # Postgresql boring messages -_-
+ r"Adding user postgres to group ssl-cert",
+ r"Building PostgreSQL dictionaries from .*",
+ r"Removing obsolete dictionary files",
+ r"Creating new PostgreSQL cluster",
+ r"/usr/lib/postgresql/13/bin/initdb",
+ r"The files belonging to this database system will be owned by user",
+ r"This user must also own the server process.",
+ r"The database cluster will be initialized with locale",
+ r"The default database encoding has accordingly been set to",
+ r"The default text search configuration will be set to",
+ r"Data page checksums are disabled.",
+ r"fixing permissions on existing directory /var/lib/postgresql/13/main ... ok",
+ r"creating subdirectories \.\.\. ok",
+ r"selecting dynamic .* \.\.\. ",
+ r"selecting default .* \.\.\. ",
+ r"creating configuration files \.\.\. ok",
+ r"running bootstrap script \.\.\. ok",
+ r"performing post-bootstrap initialization \.\.\. ok",
+ r"syncing data to disk \.\.\. ok",
+ r"Success. You can now start the database server using:",
+ r"pg_ctlcluster \d\d main start",
+ r"Ver\s*Cluster\s*Port\s*Status\s*Owner\s*Data\s*directory",
+ r"/var/lib/postgresql/\d\d/main /var/log/postgresql/postgresql-\d\d-main.log",
]
return all(not re.search(w, msg) for w in irrelevant_warnings)
@@ -396,7 +413,6 @@ def hook_exec(
def _hook_exec_bash(path, args, chdir, env, user, return_format, loggers):
-
from moulinette.utils.process import call_async_output
# Construct command variables
@@ -436,6 +452,8 @@ def _hook_exec_bash(path, args, chdir, env, user, return_format, loggers):
logger.debug("Executing command '%s'" % command)
_env = os.environ.copy()
+ if "YNH_CONTEXT" in _env:
+ del _env["YNH_CONTEXT"]
_env.update(env)
# Remove the 'HOME' var which is causing some inconsistencies between
@@ -484,7 +502,6 @@ def _hook_exec_bash(path, args, chdir, env, user, return_format, loggers):
def _hook_exec_python(path, args, env, loggers):
-
dir_ = os.path.dirname(path)
name = os.path.splitext(os.path.basename(path))[0]
@@ -504,7 +521,6 @@ def _hook_exec_python(path, args, env, loggers):
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")
diff --git a/src/log.py b/src/log.py
index 9f9e0b753..5ab918e76 100644
--- a/src/log.py
+++ b/src/log.py
@@ -1,29 +1,22 @@
-# -*- coding: utf-8 -*-
-
-""" License
-
- Copyright (C) 2018 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_log.py
-
- Manage debug logs
-"""
-
+#
+# Copyright (c) 2023 YunoHost Contributors
+#
+# This file is part of YunoHost (see https://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 .
+#
+import copy
import os
import re
import yaml
@@ -38,7 +31,7 @@ from io import IOBase
from moulinette import m18n, Moulinette
from moulinette.core import MoulinetteError
from yunohost.utils.error import YunohostError, YunohostValidationError
-from yunohost.utils.packages import get_ynh_package_version
+from yunohost.utils.system import get_ynh_package_version
from moulinette.utils.log import getActionLogger
from moulinette.utils.filesystem import read_file, read_yaml
@@ -103,7 +96,6 @@ def log_list(limit=None, with_details=False, with_suboperations=False):
logs = logs[: limit * 5]
for log in logs:
-
base_filename = log[: -len(METADATA_FILE_EXT)]
md_path = os.path.join(OPERATIONS_PATH, log)
@@ -272,7 +264,6 @@ def log_show(
return
for filename in os.listdir(OPERATIONS_PATH):
-
if not filename.endswith(METADATA_FILE_EXT):
continue
@@ -446,7 +437,6 @@ class RedactingFormatter(Formatter):
return msg
def identify_data_to_redact(self, record):
-
# Wrapping this in a try/except because we don't want this to
# break everything in case it fails miserably for some reason :s
try:
@@ -505,7 +495,6 @@ class OperationLogger:
os.makedirs(self.path)
def parent_logger(self):
-
# If there are other operation logger instances
for instance in reversed(self._instances):
# Is one of these operation logger started but not yet done ?
@@ -606,7 +595,17 @@ class OperationLogger:
Write or rewrite the metadata file with all metadata known
"""
- dump = yaml.safe_dump(self.metadata, default_flow_style=False)
+ metadata = copy.copy(self.metadata)
+
+ # Remove lower-case keys ... this is because with the new v2 app packaging,
+ # all settings are included in the env but we probably don't want to dump all of these
+ # which may contain various secret/private data ...
+ if "env" in metadata:
+ metadata["env"] = {
+ k: v for k, v in metadata["env"].items() if k == k.upper()
+ }
+
+ dump = yaml.safe_dump(metadata, default_flow_style=False)
for data in self.data_to_redact:
# N.B. : we need quotes here, otherwise yaml isn't happy about loading the yml later
dump = dump.replace(data, "'**********'")
@@ -740,7 +739,6 @@ class OperationLogger:
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()
@@ -782,7 +780,6 @@ class OperationLogger:
def _get_datetime_from_name(name):
-
# Filenames are expected to follow the format:
# 20200831-170740-short_description-and-stuff
diff --git a/src/migrations/0021_migrate_to_bullseye.py b/src/migrations/0021_migrate_to_bullseye.py
index 7577c852c..f320577e1 100644
--- a/src/migrations/0021_migrate_to_bullseye.py
+++ b/src/migrations/0021_migrate_to_bullseye.py
@@ -15,8 +15,8 @@ from yunohost.tools import (
)
from yunohost.app import unstable_apps
from yunohost.regenconf import manually_modified_files, _force_clear_hashes
-from yunohost.utils.filesystem import free_space_in_directory
-from yunohost.utils.packages import (
+from yunohost.utils.system import (
+ free_space_in_directory,
get_ynh_package_version,
_list_upgradable_apt_packages,
)
@@ -27,9 +27,6 @@ logger = getActionLogger("yunohost.migration")
N_CURRENT_DEBIAN = 10
N_CURRENT_YUNOHOST = 4
-N_NEXT_DEBAN = 11
-N_NEXT_YUNOHOST = 11
-
VENV_REQUIREMENTS_SUFFIX = ".requirements_backup_for_bullseye_upgrade.txt"
@@ -42,8 +39,11 @@ def _get_all_venvs(dir, level=0, maxlevel=3):
maxlevel - the depth of the recursion
level - do not edit this, used as an iterator
"""
- # Using os functions instead of glob, because glob doesn't support hidden folders, and we need recursion with a fixed depth
+ if not os.path.exists(dir):
+ return []
+
result = []
+ # Using os functions instead of glob, because glob doesn't support hidden folders, and we need recursion with a fixed depth
for file in os.listdir(dir):
path = os.path.join(dir, file)
if os.path.isdir(path):
@@ -66,17 +66,17 @@ def _backup_pip_freeze_for_python_app_venvs():
venvs = _get_all_venvs("/opt/") + _get_all_venvs("/var/www/")
for venv in venvs:
# Generate a requirements file from venv
- os.system(f"{venv}/bin/pip freeze > {venv}{VENV_REQUIREMENTS_SUFFIX}")
+ os.system(
+ f"{venv}/bin/pip freeze > {venv}{VENV_REQUIREMENTS_SUFFIX} 2>/dev/null"
+ )
class MyMigration(Migration):
-
"Upgrade the system to Debian Bullseye and Yunohost 11.x"
mode = "manual"
def run(self):
-
self.check_assertions()
logger.info(m18n.n("migration_0021_start"))
@@ -94,6 +94,9 @@ class MyMigration(Migration):
logger.info(m18n.n("migration_0021_patching_sources_list"))
self.patch_apt_sources_list()
+ # Stupid OVH has some repo configured which dont work with bullseye and break apt ...
+ os.system("sudo rm -f /etc/apt/sources.list.d/ovh-*.list")
+
# Force add sury if it's not there yet
# This is to solve some weird issue with php-common breaking php7.3-common,
# hence breaking many php7.3-deps
@@ -104,9 +107,17 @@ class MyMigration(Migration):
open("/etc/apt/sources.list.d/extra_php_version.list", "w").write(
"deb https://packages.sury.org/php/ bullseye main"
)
- os.system(
- 'wget --timeout 900 --quiet "https://packages.sury.org/php/apt.gpg" --output-document=- | gpg --dearmor >"/etc/apt/trusted.gpg.d/extra_php_version.gpg"'
- )
+
+ # Add Sury key even if extra_php_version.list was already there,
+ # because some old system may be using an outdated key not valid for Bullseye
+ # and that'll block the migration
+ os.system(
+ 'wget --timeout 900 --quiet "https://packages.sury.org/php/apt.gpg" --output-document=- | gpg --dearmor >"/etc/apt/trusted.gpg.d/extra_php_version.gpg"'
+ )
+
+ # Remove legacy, duplicated sury entry if it exists
+ if os.path.exists("/etc/apt/sources.list.d/sury.list"):
+ os.system("rm -rf /etc/apt/sources.list.d/sury.list")
#
# Get requirements of the different venvs from python apps
@@ -185,6 +196,47 @@ class MyMigration(Migration):
del services["postgresql"]
_save_services(services)
+ #
+ # Critical fix for RPI otherwise network is down after rebooting
+ # https://forum.yunohost.org/t/20652
+ #
+ if os.system("systemctl | grep -q dhcpcd") == 0:
+ logger.info("Applying fix for DHCPCD ...")
+ os.system("mkdir -p /etc/systemd/system/dhcpcd.service.d")
+ write_to_file(
+ "/etc/systemd/system/dhcpcd.service.d/wait.conf",
+ "[Service]\nExecStart=\nExecStart=/usr/sbin/dhcpcd -w",
+ )
+
+ #
+ # Another boring fix for the super annoying libc6-dev: Breaks libgcc-8-dev
+ # https://forum.yunohost.org/t/20617
+ #
+ if (
+ os.system("dpkg --list | grep '^ii' | grep -q ' libgcc-8-dev'") == 0
+ and os.system(
+ "LC_ALL=C apt policy libgcc-8-dev | grep Candidate | grep -q rpi"
+ )
+ == 0
+ ):
+ logger.info(
+ "Attempting to fix the build-essential / libc6-dev / libgcc-8-dev hell ..."
+ )
+ os.system("cp /var/lib/dpkg/status /root/dpkg_status.bkp")
+ # This removes the dependency to build-essential from $app-ynh-deps
+ os.system(
+ "perl -i~ -0777 -pe 's/(Package: .*-ynh-deps\\n(.+:.+\\n)+Depends:.*)(build-essential, ?)(.*)/$1$4/g' /var/lib/dpkg/status"
+ )
+ self.apt_install(
+ "build-essential-"
+ ) # Note the '-' suffix to mean that we actually want to remove the packages
+ os.system(
+ "LC_ALL=C DEBIAN_FRONTEND=noninteractive APT_LISTCHANGES_FRONTEND=none apt autoremove --assume-yes"
+ )
+ self.apt_install(
+ "gcc-8- libgcc-8-dev- equivs"
+ ) # Note the '-' suffix to mean that we actually want to remove the packages .. we also explicitly add 'equivs' to the list because sometimes apt is dumb and will derp about it
+
#
# Main upgrade
#
@@ -277,9 +329,19 @@ class MyMigration(Migration):
# Clean the mess
logger.info(m18n.n("migration_0021_cleaning_up"))
- os.system("apt autoremove --assume-yes")
+ os.system(
+ "LC_ALL=C DEBIAN_FRONTEND=noninteractive APT_LISTCHANGES_FRONTEND=none apt autoremove --assume-yes"
+ )
os.system("apt clean --assume-yes")
+ #
+ # Stupid hack for stupid dnsmasq not picking up its new init.d script then breaking everything ...
+ # https://forum.yunohost.org/t/20676
+ #
+ if os.path.exists("/etc/init.d/dnsmasq.dpkg-dist"):
+ logger.info("Copying new version for /etc/init.d/dnsmasq ...")
+ os.system("cp /etc/init.d/dnsmasq.dpkg-dist /etc/init.d/dnsmasq")
+
#
# Yunohost upgrade
#
@@ -325,7 +387,6 @@ class MyMigration(Migration):
return int(get_ynh_package_version("yunohost")["version"].split(".")[0])
def check_assertions(self):
-
# Be on buster (10.x) and yunohost 4.x
# NB : we do both check to cover situations where the upgrade crashed
# in the middle and debian version could be > 9.x but yunohost package
@@ -334,12 +395,32 @@ class MyMigration(Migration):
not self.debian_major_version() == N_CURRENT_DEBIAN
and not self.yunohost_major_version() == N_CURRENT_YUNOHOST
):
- raise YunohostError("migration_0021_not_buster")
+ try:
+ # Here we try to find the previous migration log, which should be somewhat recent and be at least 10k (we keep the biggest one)
+ maybe_previous_migration_log_id = check_output(
+ "cd /var/log/yunohost/categories/operation && find -name '*migrate*.log' -size +10k -mtime -100 -exec ls -s {} \\; | sort -n | tr './' ' ' | awk '{print $2}' | tail -n 1"
+ )
+ if maybe_previous_migration_log_id:
+ logger.info(
+ f"NB: the previous migration log id seems to be {maybe_previous_migration_log_id}. You can share it with the support team with : sudo yunohost log share {maybe_previous_migration_log_id}"
+ )
+ except Exception:
+ # Yeah it's not that important ... it's to simplify support ...
+ pass
+
+ raise YunohostError("migration_0021_not_buster2")
# Have > 1 Go free space on /var/ ?
if free_space_in_directory("/var/") / (1024**3) < 1.0:
raise YunohostError("migration_0021_not_enough_free_space")
+ # Have > 70 MB free space on /var/ ?
+ if free_space_in_directory("/boot/") / (1024**2) < 70.0:
+ raise YunohostError(
+ "/boot/ has less than 70MB available. This will probably trigger a crash during the upgrade because a new kernel needs to be installed. Please look for advice on the forum on how to remove old, unused kernels to free up some space in /boot/.",
+ raw_msg=True,
+ )
+
# Check system is up to date
# (but we don't if 'bullseye' is already in the sources.list ...
# which means maybe a previous upgrade crashed and we're re-running it)
@@ -369,7 +450,6 @@ class MyMigration(Migration):
@property
def disclaimer(self):
-
# Avoid having a super long disclaimer + uncessary check if we ain't
# on buster / yunohost 4.x anymore
# NB : we do both check to cover situations where the upgrade crashed
@@ -410,7 +490,6 @@ class MyMigration(Migration):
return message
def patch_apt_sources_list(self):
-
sources_list = glob.glob("/etc/apt/sources.list.d/*.list")
if os.path.exists("/etc/apt/sources.list"):
sources_list.append("/etc/apt/sources.list")
@@ -432,7 +511,6 @@ class MyMigration(Migration):
os.system(command)
def get_apps_equivs_packages(self):
-
command = (
"dpkg --get-selections"
" | grep -v deinstall"
diff --git a/src/migrations/0022_php73_to_php74_pools.py b/src/migrations/0022_php73_to_php74_pools.py
index a2e5eae54..dc428e504 100644
--- a/src/migrations/0022_php73_to_php74_pools.py
+++ b/src/migrations/0022_php73_to_php74_pools.py
@@ -27,7 +27,6 @@ MIGRATION_COMMENT = (
class MyMigration(Migration):
-
"Migrate php7.3-fpm 'pool' conf files to php7.4"
dependencies = ["migrate_to_bullseye"]
@@ -43,7 +42,6 @@ class MyMigration(Migration):
oldphp_pool_files = [f for f in oldphp_pool_files if f != "www.conf"]
for pf in oldphp_pool_files:
-
# Copy the files to the php7.3 pool
src = "{}/{}".format(OLDPHP_POOLS, pf)
dest = "{}/{}".format(NEWPHP_POOLS, pf)
diff --git a/src/migrations/0023_postgresql_11_to_13.py b/src/migrations/0023_postgresql_11_to_13.py
index 8f03f8c5f..6d37ffa74 100644
--- a/src/migrations/0023_postgresql_11_to_13.py
+++ b/src/migrations/0023_postgresql_11_to_13.py
@@ -1,23 +1,31 @@
import subprocess
import time
+import os
from moulinette import m18n
from yunohost.utils.error import YunohostError, YunohostValidationError
from moulinette.utils.log import getActionLogger
from yunohost.tools import Migration
-from yunohost.utils.filesystem import free_space_in_directory, space_used_by_directory
+from yunohost.utils.system import free_space_in_directory, space_used_by_directory
logger = getActionLogger("yunohost.migration")
class MyMigration(Migration):
-
"Migrate DBs from Postgresql 11 to 13 after migrating to Bullseye"
dependencies = ["migrate_to_bullseye"]
def run(self):
+ if (
+ os.system(
+ 'grep -A10 "ynh-deps" /var/lib/dpkg/status | grep -E "Package:|Depends:" | grep -B1 postgresql'
+ )
+ != 0
+ ):
+ logger.info("No YunoHost app seem to require postgresql... Skipping!")
+ return
if not self.package_is_installed("postgresql-11"):
logger.warning(m18n.n("migration_0023_postgresql_11_not_installed"))
@@ -53,7 +61,6 @@ class MyMigration(Migration):
self.runcmd("systemctl start postgresql")
def package_is_installed(self, package_name):
-
(returncode, out, err) = self.runcmd(
"dpkg --list | grep '^ii ' | grep -q -w {}".format(package_name),
raise_on_errors=False,
@@ -61,7 +68,6 @@ class MyMigration(Migration):
return returncode == 0
def runcmd(self, cmd, raise_on_errors=True):
-
logger.debug("Running command: " + cmd)
p = subprocess.Popen(
diff --git a/src/migrations/0024_rebuild_python_venv.py b/src/migrations/0024_rebuild_python_venv.py
index f39c27c49..01a229b87 100644
--- a/src/migrations/0024_rebuild_python_venv.py
+++ b/src/migrations/0024_rebuild_python_venv.py
@@ -14,7 +14,6 @@ VENV_REQUIREMENTS_SUFFIX = ".requirements_backup_for_bullseye_upgrade.txt"
def extract_app_from_venv_path(venv_path):
-
venv_path = venv_path.replace("/var/www/", "")
venv_path = venv_path.replace("/opt/yunohost/", "")
venv_path = venv_path.replace("/opt/", "")
@@ -30,6 +29,9 @@ def _get_all_venvs(dir, level=0, maxlevel=3):
maxlevel - the depth of the recursion
level - do not edit this, used as an iterator
"""
+ if not os.path.exists(dir):
+ return []
+
# Using os functions instead of glob, because glob doesn't support hidden
# folders, and we need recursion with a fixed depth
result = []
@@ -65,6 +67,7 @@ class MyMigration(Migration):
"pgadmin",
"tracim",
"synapse",
+ "matrix-synapse",
"weblate",
]
@@ -95,6 +98,10 @@ class MyMigration(Migration):
if not self.is_pending():
return None
+ # Disclaimer should be empty if in auto, otherwise it excepts the --accept-disclaimer option during debian postinst
+ if self.mode == "auto":
+ return None
+
ignored_apps = []
rebuild_apps = []
@@ -129,10 +136,11 @@ class MyMigration(Migration):
return msg
def run(self):
+ if self.mode == "auto":
+ return
venvs = _get_all_venvs("/opt/") + _get_all_venvs("/var/www/")
for venv in venvs:
-
app_corresponding_to_venv = extract_app_from_venv_path(venv)
# Search for ignore apps
diff --git a/src/migrations/0025_global_settings_to_configpanel.py b/src/migrations/0025_global_settings_to_configpanel.py
new file mode 100644
index 000000000..3a8818461
--- /dev/null
+++ b/src/migrations/0025_global_settings_to_configpanel.py
@@ -0,0 +1,42 @@
+import os
+
+from yunohost.utils.error import YunohostError
+from moulinette.utils.log import getActionLogger
+from moulinette.utils.filesystem import read_json, write_to_yaml
+
+from yunohost.tools import Migration
+from yunohost.utils.legacy import translate_legacy_settings_to_configpanel_settings
+
+logger = getActionLogger("yunohost.migration")
+
+SETTINGS_PATH = "/etc/yunohost/settings.yml"
+OLD_SETTINGS_PATH = "/etc/yunohost/settings.json"
+
+
+class MyMigration(Migration):
+ "Migrate old global settings to the new ConfigPanel global settings"
+
+ dependencies = ["migrate_to_bullseye"]
+
+ def run(self):
+ if not os.path.exists(OLD_SETTINGS_PATH):
+ return
+
+ try:
+ old_settings = read_json(OLD_SETTINGS_PATH)
+ except Exception as e:
+ raise YunohostError(f"Can't open setting file : {e}", raw_msg=True)
+
+ settings = {
+ translate_legacy_settings_to_configpanel_settings(k).split(".")[-1]: v[
+ "value"
+ ]
+ for k, v in old_settings.items()
+ }
+
+ if settings.get("smtp_relay_host"):
+ settings["smtp_relay_enabled"] = True
+
+ # Here we don't use settings_set() from settings.py to prevent
+ # Questions to be asked when one run the migration from CLI.
+ write_to_yaml(SETTINGS_PATH, settings)
diff --git a/src/migrations/0026_new_admins_group.py b/src/migrations/0026_new_admins_group.py
new file mode 100644
index 000000000..43f10a7b6
--- /dev/null
+++ b/src/migrations/0026_new_admins_group.py
@@ -0,0 +1,153 @@
+from moulinette.utils.log import getActionLogger
+
+from yunohost.tools import Migration
+
+logger = getActionLogger("yunohost.migration")
+
+###################################################
+# Tools used also for restoration
+###################################################
+
+
+class MyMigration(Migration):
+ """
+ Add new permissions around SSH/SFTP features
+ """
+
+ introduced_in_version = "11.1" # FIXME?
+ dependencies = []
+
+ ldap_migration_started = False
+
+ @Migration.ldap_migration
+ def run(self, *args):
+ from yunohost.user import (
+ user_list,
+ user_info,
+ user_group_update,
+ user_update,
+ user_group_add_mailalias,
+ ADMIN_ALIASES,
+ )
+ from yunohost.utils.ldap import _get_ldap_interface
+ from yunohost.permission import permission_sync_to_user
+ from yunohost.domain import _get_maindomain
+
+ main_domain = _get_maindomain()
+ ldap = _get_ldap_interface()
+
+ all_users = user_list()["users"].keys()
+ new_admin_user = None
+ for user in all_users:
+ if any(
+ alias.startswith("root@")
+ for alias in user_info(user).get("mail-aliases", [])
+ ):
+ new_admin_user = user
+ break
+
+ # For some reason some system have no user with root@ alias,
+ # but the user does has admin / postmaster / ... alias
+ # ... try to find it instead otherwise this creashes the migration
+ # later because the admin@, postmaster@, .. aliases will already exist
+ if not new_admin_user:
+ for user in all_users:
+ aliases = user_info(user).get("mail-aliases", [])
+ if any(
+ alias.startswith(f"admin@{main_domain}") for alias in aliases
+ ) or any(
+ alias.startswith(f"postmaster@{main_domain}") for alias in aliases
+ ):
+ new_admin_user = user
+ break
+
+ self.ldap_migration_started = True
+
+ if new_admin_user:
+ aliases = user_info(new_admin_user).get("mail-aliases", [])
+ old_admin_aliases_to_remove = [
+ alias
+ for alias in aliases
+ if any(
+ alias.startswith(a)
+ for a in [
+ "root@",
+ "admin@",
+ "admins@",
+ "webmaster@",
+ "postmaster@",
+ "abuse@",
+ ]
+ )
+ ]
+
+ user_update(new_admin_user, remove_mailalias=old_admin_aliases_to_remove)
+
+ admin_hashs = ldap.search("cn=admin", attrs={"userPassword"})[0]["userPassword"]
+
+ stuff_to_delete = [
+ "cn=admin,ou=sudo",
+ "cn=admin",
+ "cn=admins,ou=groups",
+ ]
+
+ for stuff in stuff_to_delete:
+ if ldap.search(stuff):
+ ldap.remove(stuff)
+
+ ldap.add(
+ "cn=admins,ou=sudo",
+ {
+ "cn": ["admins"],
+ "objectClass": ["top", "sudoRole"],
+ "sudoCommand": ["ALL"],
+ "sudoUser": ["%admins"],
+ "sudoHost": ["ALL"],
+ },
+ )
+
+ ldap.add(
+ "cn=admins,ou=groups",
+ {
+ "cn": ["admins"],
+ "objectClass": ["top", "posixGroup", "groupOfNamesYnh"],
+ "gidNumber": ["4001"],
+ },
+ )
+
+ user_group_add_mailalias(
+ "admins", [f"{alias}@{main_domain}" for alias in ADMIN_ALIASES]
+ )
+
+ permission_sync_to_user()
+
+ if new_admin_user:
+ user_group_update(groupname="admins", add=new_admin_user, sync_perm=True)
+
+ # Re-add admin as a regular user
+ attr_dict = {
+ "objectClass": [
+ "mailAccount",
+ "inetOrgPerson",
+ "posixAccount",
+ "userPermissionYnh",
+ ],
+ "givenName": ["Admin"],
+ "sn": ["Admin"],
+ "displayName": ["Admin"],
+ "cn": ["Admin"],
+ "uid": ["admin"],
+ "mail": "admin_legacy",
+ "maildrop": ["admin"],
+ "mailuserquota": ["0"],
+ "userPassword": admin_hashs,
+ "gidNumber": ["1007"],
+ "uidNumber": ["1007"],
+ "homeDirectory": ["/home/admin"],
+ "loginShell": ["/bin/bash"],
+ }
+ ldap.add("uid=admin,ou=users", attr_dict)
+ user_group_update(groupname="admins", add="admin", sync_perm=True)
+
+ def run_after_system_restore(self):
+ self.run()
diff --git a/src/permission.py b/src/permission.py
index 2a6f6d954..72975561f 100644
--- a/src/permission.py
+++ b/src/permission.py
@@ -1,29 +1,21 @@
-# -*- coding: utf-8 -*-
-
-""" License
-
- Copyright (C) 2014 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
-
-"""
-
-""" yunohost_permission.py
-
- Manage permissions
-"""
-
+#
+# Copyright (c) 2023 YunoHost Contributors
+#
+# This file is part of YunoHost (see https://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 .
+#
import re
import copy
import grp
@@ -87,7 +79,6 @@ def user_permission_list(
permissions = {}
for infos in permissions_infos:
-
name = infos["cn"][0]
app = name.split(".")[0]
@@ -487,6 +478,7 @@ def permission_url(
url=None,
add_url=None,
remove_url=None,
+ set_url=None,
auth_header=None,
clear_urls=False,
sync_perm=True,
@@ -499,6 +491,7 @@ def permission_url(
url -- (optional) URL for which access will be allowed/forbidden.
add_url -- (optional) List of additional url to add for which access will be allowed/forbidden
remove_url -- (optional) List of additional url to remove for which access will be allowed/forbidden
+ set_url -- (optional) List of additional url to set/replace for which access will be allowed/forbidden
auth_header -- (optional) Define for the URL of this permission, if SSOwat pass the authentication header to the application
clear_urls -- (optional) Clean all urls (url and additional_urls)
"""
@@ -564,6 +557,9 @@ def permission_url(
new_additional_urls = [u for u in new_additional_urls if u not in remove_url]
+ if set_url:
+ new_additional_urls = set_url
+
if auth_header is None:
auth_header = existing_permission["auth_header"]
@@ -657,7 +653,6 @@ def permission_sync_to_user():
permissions = user_permission_list(full=True)["permissions"]
for permission_name, permission_infos in permissions.items():
-
# These are the users currently allowed because there's an 'inheritPermission' object corresponding to it
currently_allowed_users = set(permission_infos["corresponding_users"])
@@ -743,7 +738,6 @@ def _update_ldap_group_permission(
update["isProtected"] = [str(protected).upper()]
if show_tile is not None:
-
if show_tile is True:
if not existing_permission["url"]:
logger.warning(
@@ -820,10 +814,12 @@ def _update_ldap_group_permission(
def _get_absolute_url(url, base_path):
#
# For example transform:
- # (/api, domain.tld/nextcloud) into domain.tld/nextcloud/api
- # (/api, domain.tld/nextcloud/) into domain.tld/nextcloud/api
- # (re:/foo.*, domain.tld/app) into re:domain\.tld/app/foo.*
- # (domain.tld/bar, domain.tld/app) into domain.tld/bar
+ # (/, domain.tld/) into domain.tld (no trailing /)
+ # (/api, domain.tld/nextcloud) into domain.tld/nextcloud/api
+ # (/api, domain.tld/nextcloud/) into domain.tld/nextcloud/api
+ # (re:/foo.*, domain.tld/app) into re:domain\.tld/app/foo.*
+ # (domain.tld/bar, domain.tld/app) into domain.tld/bar
+ # (some.other.domain/, domain.tld/app) into some.other.domain (no trailing /)
#
base_path = base_path.rstrip("/")
if url is None:
@@ -833,7 +829,7 @@ def _get_absolute_url(url, base_path):
if url.startswith("re:/"):
return "re:" + base_path.replace(".", "\\.") + url[3:]
else:
- return url
+ return url.rstrip("/")
def _validate_and_sanitize_permission_url(url, app_base_path, app):
@@ -879,7 +875,6 @@ def _validate_and_sanitize_permission_url(url, app_base_path, app):
raise YunohostValidationError("invalid_regex", regex=regex)
if url.startswith("re:"):
-
# regex without domain
# we check for the first char after 're:'
if url[3] in ["/", "^", "\\"]:
diff --git a/src/regenconf.py b/src/regenconf.py
index e513a1506..74bbdb17c 100644
--- a/src/regenconf.py
+++ b/src/regenconf.py
@@ -1,24 +1,21 @@
-# -*- coding: utf-8 -*-
-
-""" License
-
- Copyright (C) 2019 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
-
-"""
-
+#
+# Copyright (c) 2023 YunoHost Contributors
+#
+# This file is part of YunoHost (see https://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 .
+#
import os
import yaml
import shutil
@@ -80,7 +77,6 @@ def regen_conf(
for category, conf_files in pending_conf.items():
for system_path, pending_path in conf_files.items():
-
pending_conf[category][system_path] = {
"pending_conf": pending_path,
"diff": _get_files_diff(system_path, pending_path, True),
@@ -143,6 +139,7 @@ def regen_conf(
env["YNH_MAIN_DOMAINS"] = " ".join(
domain_list(exclude_subdomains=True)["domains"]
)
+ env["YNH_CONTEXT"] = "regenconf"
pre_result = hook_callback("conf_regen", names, pre_callback=_pre_call, env=env)
@@ -598,7 +595,6 @@ def _update_conf_hashes(category, hashes):
def _force_clear_hashes(paths):
-
categories = _get_regenconf_infos()
for path in paths:
for category in categories.keys():
@@ -678,7 +674,6 @@ def _process_regen_conf(system_conf, new_conf=None, save=True):
def manually_modified_files():
-
output = []
regenconf_categories = _get_regenconf_infos()
for category, infos in regenconf_categories.items():
@@ -693,7 +688,6 @@ def manually_modified_files():
def manually_modified_files_compared_to_debian_default(
ignore_handled_by_regenconf=False,
):
-
# from https://serverfault.com/a/90401
files = check_output(
"dpkg-query -W -f='${Conffiles}\n' '*' \
diff --git a/src/service.py b/src/service.py
index 5800f6e4d..47bc1903a 100644
--- a/src/service.py
+++ b/src/service.py
@@ -1,29 +1,21 @@
-# -*- 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_service.py
-
- Manage services
-"""
-
+#
+# Copyright (c) 2023 YunoHost Contributors
+#
+# This file is part of YunoHost (see https://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 .
+#
import re
import os
import time
@@ -257,12 +249,10 @@ def service_reload_or_restart(names, test_conf=True):
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,
@@ -401,7 +391,6 @@ def _get_service_information_from_systemd(service):
def _get_and_format_service_status(service, infos):
-
systemd_service = infos.get("actual_systemd_service", service)
raw_status, raw_service = _get_service_information_from_systemd(systemd_service)
@@ -422,7 +411,6 @@ def _get_and_format_service_status(service, infos):
# If no description was there, try to get it from the .json locales
if not description:
-
translation_key = f"service_description_{service}"
if m18n.key_exists(translation_key):
description = m18n.n(translation_key)
@@ -529,7 +517,6 @@ def service_log(name, number=50):
result["journalctl"] = _get_journalctl_logs(name, number).splitlines()
for log_path in log_list:
-
if not os.path.exists(log_path):
continue
@@ -628,7 +615,6 @@ def _run_service_command(action, service):
def _give_lock(action, service, p):
-
# Depending of the action, systemctl calls the PID differently :/
if action == "start" or action == "restart":
systemctl_PID_name = "MainPID"
@@ -720,6 +706,10 @@ def _get_services():
"category": "web",
}
+ # Ignore metronome entirely if XMPP was disabled on all domains
+ if "metronome" in services and not glob("/etc/metronome/conf.d/*.cfg.lua"):
+ del services["metronome"]
+
# Remove legacy /var/log/daemon.log and /var/log/syslog from log entries
# because they are too general. Instead, now the journalctl log is
# returned by default which is more relevant.
@@ -748,7 +738,6 @@ def _save_services(services):
diff = {}
for service_name, service_infos in services.items():
-
# Ignore php-fpm services, they are to be added dynamically by the core,
# but not actually saved
if service_name.startswith("php") and service_name.endswith("-fpm"):
@@ -786,7 +775,7 @@ def _tail(file, n):
f = gzip.open(file)
lines = f.read().splitlines()
else:
- f = open(file)
+ f = open(file, errors="replace")
pos = 1
lines = []
while len(lines) < to_read and pos > 0:
diff --git a/src/settings.py b/src/settings.py
index cec416550..6690ab3fd 100644
--- a/src/settings.py
+++ b/src/settings.py
@@ -1,129 +1,40 @@
+#
+# Copyright (c) 2023 YunoHost Contributors
+#
+# This file is part of YunoHost (see https://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 .
+#
import os
-import json
import subprocess
-from datetime import datetime
-from collections import OrderedDict
-
from moulinette import m18n
from yunohost.utils.error import YunohostError, YunohostValidationError
+from yunohost.utils.configpanel import ConfigPanel
+from yunohost.utils.form import BaseOption
from moulinette.utils.log import getActionLogger
from yunohost.regenconf import regen_conf
from yunohost.firewall import firewall_reload
+from yunohost.log import is_unit_operation
+from yunohost.utils.legacy import translate_legacy_settings_to_configpanel_settings
logger = getActionLogger("yunohost.settings")
-SETTINGS_PATH = "/etc/yunohost/settings.json"
-SETTINGS_PATH_OTHER_LOCATION = "/etc/yunohost/settings-%s.json"
+SETTINGS_PATH = "/etc/yunohost/settings.yml"
-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
-
- Keyword arguments:
- arg -- The string to check
-
- Returns:
- (is_boolean, 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 + FALSE:
- return True, str(value).lower() in TRUE
- else:
- return False, None
- else:
- return False, None
-
-
-# a settings entry is in the form of:
-# namespace.subnamespace.name: {type, value, default, description, [choices]}
-# choices is only for enum
-# the keyname can have as many subnamespace as needed but should have at least
-# one level of namespace
-
-# description is implied from the translated strings
-# the key is "global_settings_setting_%s" % key.replace(".", "_")
-
-# type can be:
-# * bool
-# * int
-# * string
-# * enum (in the form of a python list)
-
-DEFAULTS = OrderedDict(
- [
- # Password Validation
- # -1 disabled, 0 alert if listed, 1 8-letter, 2 normal, 3 strong, 4 strongest
- ("security.password.admin.strength", {"type": "int", "default": 1}),
- ("security.password.user.strength", {"type": "int", "default": 1}),
- (
- "service.ssh.allow_deprecated_dsa_hostkey",
- {"type": "bool", "default": False},
- ),
- (
- "security.ssh.compatibility",
- {
- "type": "enum",
- "default": "modern",
- "choices": ["intermediate", "modern"],
- },
- ),
- (
- "security.ssh.port",
- {"type": "int", "default": 22},
- ),
- (
- "security.ssh.password_authentication",
- {"type": "bool", "default": True},
- ),
- (
- "security.nginx.redirect_to_https",
- {
- "type": "bool",
- "default": True,
- },
- ),
- (
- "security.nginx.compatibility",
- {
- "type": "enum",
- "default": "intermediate",
- "choices": ["intermediate", "modern"],
- },
- ),
- (
- "security.postfix.compatibility",
- {
- "type": "enum",
- "default": "intermediate",
- "choices": ["intermediate", "modern"],
- },
- ),
- ("pop3.enabled", {"type": "bool", "default": False}),
- ("smtp.allow_ipv6", {"type": "bool", "default": True}),
- ("smtp.relay.host", {"type": "string", "default": ""}),
- ("smtp.relay.port", {"type": "int", "default": 587}),
- ("smtp.relay.user", {"type": "string", "default": ""}),
- ("smtp.relay.password", {"type": "string", "default": ""}),
- ("backup.compress_tar_archives", {"type": "bool", "default": False}),
- ("ssowat.panel_overlay.enabled", {"type": "bool", "default": True}),
- ("security.webadmin.allowlist.enabled", {"type": "bool", "default": False}),
- ("security.webadmin.allowlist", {"type": "string", "default": ""}),
- ("security.experimental.enabled", {"type": "bool", "default": False}),
- ]
-)
-
-
-def settings_get(key, full=False):
+def settings_get(key="", full=False, export=False):
"""
Get an entry value in the settings
@@ -131,28 +42,38 @@ def settings_get(key, full=False):
key -- Settings key
"""
- settings = _get_settings()
-
- if key not in settings:
+ if full and export:
raise YunohostValidationError(
- "global_settings_key_doesnt_exists", settings_key=key
+ "You can't use --full and --export together.", raw_msg=True
)
if full:
- return settings[key]
+ mode = "full"
+ elif export:
+ mode = "export"
+ else:
+ mode = "classic"
- return settings[key]["value"]
+ settings = SettingsConfigPanel()
+ key = translate_legacy_settings_to_configpanel_settings(key)
+ return settings.get(key, mode)
-def settings_list():
- """
- List all entries of the settings
+def settings_list(full=False):
+ settings = settings_get(full=full)
- """
- return _get_settings()
+ if full:
+ return settings
+ else:
+ return {
+ k: v
+ for k, v in settings.items()
+ if not k.startswith("security.root_access")
+ }
-def settings_set(key, value):
+@is_unit_operation()
+def settings_set(operation_logger, key=None, value=None, args=None, args_file=None):
"""
Set an entry value in the settings
@@ -161,78 +82,14 @@ def settings_set(key, value):
value -- New value
"""
- settings = _get_settings()
-
- if key not in settings:
- raise YunohostValidationError(
- "global_settings_key_doesnt_exists", settings_key=key
- )
-
- key_type = settings[key]["type"]
-
- if key_type == "bool":
- boolean_value = is_boolean(value)
- if boolean_value[0]:
- value = boolean_value[1]
- else:
- raise YunohostValidationError(
- "global_settings_bad_type_for_setting",
- setting=key,
- received_type=type(value).__name__,
- expected_type=key_type,
- )
- elif key_type == "int":
- if not isinstance(value, int) or isinstance(value, bool):
- if isinstance(value, str):
- try:
- value = int(value)
- except Exception:
- raise YunohostValidationError(
- "global_settings_bad_type_for_setting",
- setting=key,
- received_type=type(value).__name__,
- expected_type=key_type,
- )
- else:
- raise YunohostValidationError(
- "global_settings_bad_type_for_setting",
- setting=key,
- received_type=type(value).__name__,
- expected_type=key_type,
- )
- elif key_type == "string":
- if not isinstance(value, str):
- raise YunohostValidationError(
- "global_settings_bad_type_for_setting",
- setting=key,
- received_type=type(value).__name__,
- expected_type=key_type,
- )
- elif key_type == "enum":
- if value not in settings[key]["choices"]:
- raise YunohostValidationError(
- "global_settings_bad_choice_for_enum",
- setting=key,
- choice=str(value),
- available_choices=", ".join(settings[key]["choices"]),
- )
- else:
- raise YunohostValidationError(
- "global_settings_unknown_type", setting=key, unknown_type=key_type
- )
-
- old_value = settings[key].get("value")
- settings[key]["value"] = value
- _save_settings(settings)
-
- try:
- trigger_post_change_hook(key, old_value, value)
- except Exception as e:
- logger.error(f"Post-change hook for setting {key} failed : {e}")
- raise
+ BaseOption.operation_logger = operation_logger
+ settings = SettingsConfigPanel()
+ key = translate_legacy_settings_to_configpanel_settings(key)
+ return settings.set(key, value, args, args_file, operation_logger=operation_logger)
-def settings_reset(key):
+@is_unit_operation()
+def settings_reset(operation_logger, key):
"""
Set an entry value to its default one
@@ -240,18 +97,14 @@ def settings_reset(key):
key -- Settings key
"""
- settings = _get_settings()
- if key not in settings:
- raise YunohostValidationError(
- "global_settings_key_doesnt_exists", settings_key=key
- )
-
- settings[key]["value"] = settings[key]["default"]
- _save_settings(settings)
+ settings = SettingsConfigPanel()
+ key = translate_legacy_settings_to_configpanel_settings(key)
+ return settings.reset(key, operation_logger=operation_logger)
-def settings_reset_all():
+@is_unit_operation()
+def settings_reset_all(operation_logger):
"""
Reset all settings to their default value
@@ -259,110 +112,150 @@ def settings_reset_all():
yes -- Yes I'm sure I want to do that
"""
- settings = _get_settings()
-
- # For now on, we backup the previous settings in case of but we don't have
- # any mecanism to take advantage of those backups. It could be a nice
- # addition but we'll see if this is a common need.
- # Another solution would be to use etckeeper and integrate those
- # modification inside of it and take advantage of its git history
- old_settings_backup_path = (
- SETTINGS_PATH_OTHER_LOCATION % datetime.utcnow().strftime("%F_%X")
- )
- _save_settings(settings, location=old_settings_backup_path)
-
- for value in settings.values():
- value["value"] = value["default"]
-
- _save_settings(settings)
-
- return {
- "old_settings_backup_path": old_settings_backup_path,
- "message": m18n.n(
- "global_settings_reset_success", path=old_settings_backup_path
- ),
- }
+ settings = SettingsConfigPanel()
+ return settings.reset(operation_logger=operation_logger)
-def _get_setting_description(key):
- return m18n.n(f"global_settings_setting_{key}".replace(".", "_"))
+class SettingsConfigPanel(ConfigPanel):
+ entity_type = "global"
+ save_path_tpl = SETTINGS_PATH
+ save_mode = "diff"
+ virtual_settings = ["root_password", "root_password_confirm", "passwordless_sudo"]
+ def __init__(self, config_path=None, save_path=None, creation=False):
+ super().__init__("settings")
-def _get_settings():
+ def get(self, key="", mode="classic"):
+ result = super().get(key=key, mode=mode)
- settings = {}
-
- for key, value in DEFAULTS.copy().items():
- settings[key] = value
- settings[key]["value"] = value["default"]
- settings[key]["description"] = _get_setting_description(key)
-
- if not os.path.exists(SETTINGS_PATH):
- return settings
-
- # we have a very strict policy on only allowing settings that we know in
- # the OrderedDict DEFAULTS
- # For various reason, while reading the local settings we might encounter
- # settings that aren't in DEFAULTS, those can come from settings key that
- # we have removed, errors or the user trying to modify
- # /etc/yunohost/settings.json
- # To avoid to simply overwrite them, we store them in
- # /etc/yunohost/settings-unknown.json in case of
- unknown_settings = {}
- unknown_settings_path = SETTINGS_PATH_OTHER_LOCATION % "unknown"
-
- if os.path.exists(unknown_settings_path):
- try:
- unknown_settings = json.load(open(unknown_settings_path, "r"))
- except Exception as e:
- logger.warning(f"Error while loading unknown settings {e}")
-
- try:
- with open(SETTINGS_PATH) as settings_fd:
- local_settings = json.load(settings_fd)
-
- for key, value in local_settings.items():
- if key in settings:
- settings[key] = value
- settings[key]["description"] = _get_setting_description(key)
- else:
- logger.warning(
- m18n.n(
- "global_settings_unknown_setting_from_settings_file",
- setting_key=key,
- )
+ if mode == "full":
+ for panel, section, option in self._iterate():
+ if m18n.key_exists(self.config["i18n"] + "_" + option["id"] + "_help"):
+ option["help"] = m18n.n(
+ self.config["i18n"] + "_" + option["id"] + "_help"
)
- unknown_settings[key] = value
- except Exception as e:
- raise YunohostValidationError("global_settings_cant_open_settings", reason=e)
+ return self.config
+
+ # Dirty hack to let settings_get() to work from a python script
+ if isinstance(result, str) and result in ["True", "False"]:
+ result = bool(result == "True")
+
+ return result
+
+ def reset(self, key="", operation_logger=None):
+ self.filter_key = key
+
+ # Read config panel toml
+ self._get_config_panel()
+
+ if not self.config:
+ raise YunohostValidationError("config_no_panel")
+
+ # Replace all values with default values
+ self.values = self._get_default_values()
+
+ BaseOption.operation_logger = operation_logger
+
+ if operation_logger:
+ operation_logger.start()
- if unknown_settings:
try:
- _save_settings(unknown_settings, location=unknown_settings_path)
- _save_settings(settings)
- except Exception as e:
- logger.warning(f"Failed to save unknown settings (because {e}), aborting.")
+ 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
- return settings
+ error = m18n.n("unexpected_error", error="\n" + traceback.format_exc())
+ logger.error(m18n.n("config_apply_failed", error=error))
+ raise
+ logger.success(m18n.n("global_settings_reset_success"))
+ operation_logger.success()
-def _save_settings(settings, location=SETTINGS_PATH):
- settings_without_description = {}
- for key, value in settings.items():
- settings_without_description[key] = value
- if "description" in value:
- del settings_without_description[key]["description"]
+ def _get_raw_config(self):
+ toml = super()._get_raw_config()
- try:
- result = json.dumps(settings_without_description, indent=4)
- except Exception as e:
- raise YunohostError("global_settings_cant_serialize_settings", reason=e)
+ # Dynamic choice list for portal themes
+ THEMEDIR = "/usr/share/ssowat/portal/assets/themes/"
+ try:
+ themes = [d for d in os.listdir(THEMEDIR) if os.path.isdir(THEMEDIR + d)]
+ except Exception:
+ themes = ["unsplash", "vapor", "light", "default", "clouds"]
+ toml["misc"]["portal"]["portal_theme"]["choices"] = themes
- try:
- with open(location, "w") as settings_fd:
- settings_fd.write(result)
- except Exception as e:
- raise YunohostError("global_settings_cant_write_settings", reason=e)
+ return toml
+
+ def _get_raw_settings(self):
+ super()._get_raw_settings()
+
+ # Specific logic for those settings who are "virtual" settings
+ # and only meant to have a custom setter mapped to tools_rootpw
+ self.values["root_password"] = ""
+ self.values["root_password_confirm"] = ""
+
+ # Specific logic for virtual setting "passwordless_sudo"
+ try:
+ from yunohost.utils.ldap import _get_ldap_interface
+
+ ldap = _get_ldap_interface()
+ self.values["passwordless_sudo"] = "!authenticate" in ldap.search(
+ "ou=sudo", "cn=admins", ["sudoOption"]
+ )[0].get("sudoOption", [])
+ except Exception:
+ self.values["passwordless_sudo"] = False
+
+ def _apply(self):
+ root_password = self.new_values.pop("root_password", None)
+ root_password_confirm = self.new_values.pop("root_password_confirm", None)
+ passwordless_sudo = self.new_values.pop("passwordless_sudo", None)
+
+ self.values = {
+ k: v for k, v in self.values.items() if k not in self.virtual_settings
+ }
+ self.new_values = {
+ k: v for k, v in self.new_values.items() if k not in self.virtual_settings
+ }
+
+ assert all(v not in self.future_values for v in self.virtual_settings)
+
+ if root_password and root_password.strip():
+ if root_password != root_password_confirm:
+ raise YunohostValidationError("password_confirmation_not_the_same")
+
+ from yunohost.tools import tools_rootpw
+
+ tools_rootpw(root_password, check_strength=True)
+
+ if passwordless_sudo is not None:
+ from yunohost.utils.ldap import _get_ldap_interface
+
+ ldap = _get_ldap_interface()
+ ldap.update(
+ "cn=admins,ou=sudo",
+ {"sudoOption": ["!authenticate"] if passwordless_sudo else []},
+ )
+
+ super()._apply()
+
+ settings = {
+ k: v for k, v in self.future_values.items() if self.values.get(k) != v
+ }
+ for setting_name, value in settings.items():
+ try:
+ trigger_post_change_hook(
+ setting_name, self.values.get(setting_name), value
+ )
+ except Exception as e:
+ logger.error(f"Post-change hook for setting failed : {e}")
+ raise
# Meant to be a dict of setting_name -> function to call
@@ -370,13 +263,8 @@ post_change_hooks = {}
def post_change_hook(setting_name):
+ # TODO: Check that setting_name exists
def decorator(func):
- assert (
- setting_name in DEFAULTS.keys()
- ), f"The setting {setting_name} does not exists"
- assert (
- setting_name not in post_change_hooks
- ), f"You can only register one post change hook per setting (in particular for {setting_name})"
post_change_hooks[setting_name] = func
return func
@@ -404,55 +292,63 @@ def trigger_post_change_hook(setting_name, old_value, new_value):
# ===========================================
-@post_change_hook("ssowat.panel_overlay.enabled")
-@post_change_hook("security.nginx.redirect_to_https")
-@post_change_hook("security.nginx.compatibility")
-@post_change_hook("security.webadmin.allowlist.enabled")
-@post_change_hook("security.webadmin.allowlist")
+@post_change_hook("portal_theme")
+def regen_ssowatconf(setting_name, old_value, new_value):
+ if old_value != new_value:
+ from yunohost.app import app_ssowatconf
+
+ app_ssowatconf()
+
+
+@post_change_hook("ssowat_panel_overlay_enabled")
+@post_change_hook("nginx_redirect_to_https")
+@post_change_hook("nginx_compatibility")
+@post_change_hook("webadmin_allowlist_enabled")
+@post_change_hook("webadmin_allowlist")
def reconfigure_nginx(setting_name, old_value, new_value):
if old_value != new_value:
regen_conf(names=["nginx"])
-@post_change_hook("security.experimental.enabled")
+@post_change_hook("security_experimental_enabled")
def reconfigure_nginx_and_yunohost(setting_name, old_value, new_value):
if old_value != new_value:
regen_conf(names=["nginx", "yunohost"])
-@post_change_hook("security.ssh.compatibility")
-@post_change_hook("security.ssh.password_authentication")
+@post_change_hook("ssh_compatibility")
+@post_change_hook("ssh_password_authentication")
def reconfigure_ssh(setting_name, old_value, new_value):
if old_value != new_value:
regen_conf(names=["ssh"])
-@post_change_hook("security.ssh.port")
+@post_change_hook("ssh_port")
def reconfigure_ssh_and_fail2ban(setting_name, old_value, new_value):
if old_value != new_value:
regen_conf(names=["ssh", "fail2ban"])
firewall_reload()
-@post_change_hook("smtp.allow_ipv6")
-@post_change_hook("smtp.relay.host")
-@post_change_hook("smtp.relay.port")
-@post_change_hook("smtp.relay.user")
-@post_change_hook("smtp.relay.password")
-@post_change_hook("security.postfix.compatibility")
+@post_change_hook("smtp_allow_ipv6")
+@post_change_hook("smtp_relay_host")
+@post_change_hook("smtp_relay_port")
+@post_change_hook("smtp_relay_user")
+@post_change_hook("smtp_relay_password")
+@post_change_hook("postfix_compatibility")
def reconfigure_postfix(setting_name, old_value, new_value):
if old_value != new_value:
regen_conf(names=["postfix"])
-@post_change_hook("pop3.enabled")
+@post_change_hook("pop3_enabled")
def reconfigure_dovecot(setting_name, old_value, new_value):
dovecot_package = "dovecot-pop3d"
environment = os.environ.copy()
environment.update({"DEBIAN_FRONTEND": "noninteractive"})
- if new_value == "True":
+ if new_value is True:
command = [
"apt-get",
"-y",
diff --git a/src/ssh.py b/src/ssh.py
index b89dc6c8e..8526e278f 100644
--- a/src/ssh.py
+++ b/src/ssh.py
@@ -1,4 +1,21 @@
-# encoding: utf-8
+#
+# Copyright (c) 2023 YunoHost Contributors
+#
+# This file is part of YunoHost (see https://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 .
+#
import re
import os
@@ -64,7 +81,7 @@ def user_ssh_add_key(username, key, comment):
parents=True,
uid=user["uid"][0],
)
- chmod(os.path.join(user["homeDirectory"][0], ".ssh"), 0o600)
+ chmod(os.path.join(user["homeDirectory"][0], ".ssh"), 0o700)
# create empty file to set good permissions
write_to_file(authorized_keys_file, "")
@@ -158,15 +175,6 @@ def _get_user_for_ssh(username, attrs=None):
"home_path": root_unix.pw_dir,
}
- if username == "admin":
- admin_unix = pwd.getpwnam("admin")
- return {
- "username": "admin",
- "fullname": "",
- "mail": "",
- "home_path": admin_unix.pw_dir,
- }
-
# TODO escape input using https://www.python-ldap.org/doc/html/ldap-filter.html
from yunohost.utils.ldap import _get_ldap_interface
diff --git a/src/tests/conftest.py b/src/tests/conftest.py
index cd5cb307e..393c33564 100644
--- a/src/tests/conftest.py
+++ b/src/tests/conftest.py
@@ -51,7 +51,6 @@ old_translate = moulinette.core.Translator.translate
def new_translate(self, key, *args, **kwargs):
-
if key not in self._translations[self.default_locale].keys():
raise KeyError("Unable to retrieve key %s for default locale !" % key)
@@ -67,7 +66,6 @@ moulinette.core.Translator.translate = new_translate
def pytest_cmdline_main(config):
-
import sys
sys.path.insert(0, "/usr/lib/moulinette/")
@@ -76,7 +74,6 @@ def pytest_cmdline_main(config):
yunohost.init(debug=config.option.yunodebug)
class DummyInterface:
-
type = "cli"
def prompt(self, *args, **kwargs):
diff --git a/src/tests/test_app_catalog.py b/src/tests/test_app_catalog.py
index e9ecb1c12..f7363dabe 100644
--- a/src/tests/test_app_catalog.py
+++ b/src/tests/test_app_catalog.py
@@ -44,7 +44,6 @@ class AnyStringWith(str):
def setup_function(function):
-
# Clear apps catalog cache
shutil.rmtree(APPS_CATALOG_CACHE, ignore_errors=True)
@@ -54,7 +53,6 @@ def setup_function(function):
def teardown_function(function):
-
# Clear apps catalog cache
# Otherwise when using apps stuff after running the test,
# we'll still have the dummy unusable list
@@ -67,7 +65,6 @@ def teardown_function(function):
def test_apps_catalog_init(mocker):
-
# Cache is empty
assert not glob.glob(APPS_CATALOG_CACHE + "/*")
# Conf doesn't exist yet
@@ -91,7 +88,6 @@ def test_apps_catalog_init(mocker):
def test_apps_catalog_emptylist():
-
# Initialize ...
_initialize_apps_catalog_system()
@@ -104,7 +100,6 @@ def test_apps_catalog_emptylist():
def test_apps_catalog_update_nominal(mocker):
-
# Initialize ...
_initialize_apps_catalog_system()
@@ -113,7 +108,6 @@ def test_apps_catalog_update_nominal(mocker):
# Update
with requests_mock.Mocker() as m:
-
_actual_apps_catalog_api_url,
# Mock the server response with a dummy apps catalog
m.register_uri("GET", APPS_CATALOG_DEFAULT_URL_FULL, text=DUMMY_APP_CATALOG)
@@ -139,12 +133,10 @@ def test_apps_catalog_update_nominal(mocker):
def test_apps_catalog_update_404(mocker):
-
# Initialize ...
_initialize_apps_catalog_system()
with requests_mock.Mocker() as m:
-
# 404 error
m.register_uri("GET", APPS_CATALOG_DEFAULT_URL_FULL, status_code=404)
@@ -155,12 +147,10 @@ def test_apps_catalog_update_404(mocker):
def test_apps_catalog_update_timeout(mocker):
-
# Initialize ...
_initialize_apps_catalog_system()
with requests_mock.Mocker() as m:
-
# Timeout
m.register_uri(
"GET", APPS_CATALOG_DEFAULT_URL_FULL, exc=requests.exceptions.ConnectTimeout
@@ -173,12 +163,10 @@ def test_apps_catalog_update_timeout(mocker):
def test_apps_catalog_update_sslerror(mocker):
-
# Initialize ...
_initialize_apps_catalog_system()
with requests_mock.Mocker() as m:
-
# SSL error
m.register_uri(
"GET", APPS_CATALOG_DEFAULT_URL_FULL, exc=requests.exceptions.SSLError
@@ -191,12 +179,10 @@ def test_apps_catalog_update_sslerror(mocker):
def test_apps_catalog_update_corrupted(mocker):
-
# Initialize ...
_initialize_apps_catalog_system()
with requests_mock.Mocker() as m:
-
# Corrupted json
m.register_uri(
"GET", APPS_CATALOG_DEFAULT_URL_FULL, text=DUMMY_APP_CATALOG[:-2]
@@ -209,7 +195,6 @@ def test_apps_catalog_update_corrupted(mocker):
def test_apps_catalog_load_with_empty_cache(mocker):
-
# Initialize ...
_initialize_apps_catalog_system()
@@ -218,7 +203,6 @@ def test_apps_catalog_load_with_empty_cache(mocker):
# Update
with requests_mock.Mocker() as m:
-
# Mock the server response with a dummy apps catalog
m.register_uri("GET", APPS_CATALOG_DEFAULT_URL_FULL, text=DUMMY_APP_CATALOG)
@@ -237,7 +221,6 @@ def test_apps_catalog_load_with_empty_cache(mocker):
def test_apps_catalog_load_with_conflicts_between_lists(mocker):
-
# Initialize ...
_initialize_apps_catalog_system()
@@ -253,7 +236,6 @@ def test_apps_catalog_load_with_conflicts_between_lists(mocker):
# Update
with requests_mock.Mocker() as m:
-
# Mock the server response with a dummy apps catalog
# + the same apps catalog for the second list
m.register_uri("GET", APPS_CATALOG_DEFAULT_URL_FULL, text=DUMMY_APP_CATALOG)
@@ -277,13 +259,11 @@ def test_apps_catalog_load_with_conflicts_between_lists(mocker):
def test_apps_catalog_load_with_oudated_api_version(mocker):
-
# Initialize ...
_initialize_apps_catalog_system()
# Update
with requests_mock.Mocker() as m:
-
mocker.spy(m18n, "n")
m.register_uri("GET", APPS_CATALOG_DEFAULT_URL_FULL, text=DUMMY_APP_CATALOG)
_update_apps_catalog()
@@ -300,7 +280,6 @@ def test_apps_catalog_load_with_oudated_api_version(mocker):
# Update
with requests_mock.Mocker() as m:
-
# Mock the server response with a dummy apps catalog
m.register_uri("GET", APPS_CATALOG_DEFAULT_URL_FULL, text=DUMMY_APP_CATALOG)
diff --git a/src/tests/test_app_config.py b/src/tests/test_app_config.py
index d6cf8045d..4a74cbc0d 100644
--- a/src/tests/test_app_config.py
+++ b/src/tests/test_app_config.py
@@ -25,17 +25,14 @@ 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()
@@ -43,7 +40,6 @@ def clean():
test_apps = ["config_app", "legacy_app"]
for test_app in test_apps:
-
if _is_installed(test_app):
app_remove(test_app)
@@ -66,7 +62,6 @@ def clean():
@pytest.fixture()
def legacy_app(request):
-
main_domain = _get_maindomain()
app_install(
@@ -85,7 +80,6 @@ def legacy_app(request):
@pytest.fixture()
def config_app(request):
-
app_install(
os.path.join(get_test_apps_dir(), "config_app_ynh"),
args="",
@@ -101,27 +95,24 @@ def config_app(request):
def test_app_config_get(config_app):
-
- user_create("alice", "Alice", "White", _get_maindomain(), "test123Ynh")
+ user_create("alice", _get_maindomain(), "test123Ynh", fullname="Alice White")
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"
+ assert app_config_get(config_app, "main.components.boolean") == 0
user_delete("alice")
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")
@@ -140,17 +131,16 @@ def test_app_config_get_nonexistentstuff(config_app):
def test_app_config_regular_setting(config_app):
-
- assert app_config_get(config_app, "main.components.boolean") == "0"
+ 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_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_config_get(config_app, "main.components.boolean") == 1
assert app_setting(config_app, "boolean") == "1"
with pytest.raises(YunohostValidationError), patch.object(
@@ -160,7 +150,6 @@ def test_app_config_regular_setting(config_app):
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"
@@ -173,18 +162,17 @@ def test_app_config_bind_on_file(config_app):
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_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")
@@ -198,7 +186,6 @@ def test_app_config_custom_validator(config_app):
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
diff --git a/src/tests/test_app_resources.py b/src/tests/test_app_resources.py
new file mode 100644
index 000000000..d2df647a3
--- /dev/null
+++ b/src/tests/test_app_resources.py
@@ -0,0 +1,391 @@
+import os
+import pytest
+
+from moulinette.utils.process import check_output
+
+from yunohost.app import app_setting
+from yunohost.domain import _get_maindomain
+from yunohost.utils.resources import (
+ AppResource,
+ AppResourceManager,
+ AppResourceClassesByType,
+)
+from yunohost.permission import user_permission_list, permission_delete
+from yunohost.firewall import firewall_list
+
+dummyfile = "/tmp/dummyappresource-testapp"
+
+
+class DummyAppResource(AppResource):
+ type = "dummy"
+
+ default_properties = {
+ "file": "/tmp/dummyappresource-__APP__",
+ "content": "foo",
+ }
+
+ def provision_or_update(self, context):
+ open(self.file, "w").write(self.content)
+
+ if self.content == "forbiddenvalue":
+ raise Exception("Emeged you used the forbidden value!1!£&")
+
+ def deprovision(self, context):
+ os.system(f"rm -f {self.file}")
+
+
+AppResourceClassesByType["dummy"] = DummyAppResource
+
+
+def setup_function(function):
+ clean()
+
+ os.system("mkdir /etc/yunohost/apps/testapp")
+ os.system("echo 'id: testapp' > /etc/yunohost/apps/testapp/settings.yml")
+ os.system("echo 'packaging_format = 2' > /etc/yunohost/apps/testapp/manifest.toml")
+ os.system("echo 'id = \"testapp\"' >> /etc/yunohost/apps/testapp/manifest.toml")
+
+
+def teardown_function(function):
+ clean()
+
+
+def clean():
+ os.system(f"rm -f {dummyfile}")
+ os.system("rm -rf /etc/yunohost/apps/testapp")
+ os.system("rm -rf /var/www/testapp")
+ os.system("rm -rf /home/yunohost.app/testapp")
+ os.system("apt remove lolcat sl nyancat yarn >/dev/null 2>/dev/null")
+ os.system("userdel testapp 2>/dev/null")
+
+ for p in user_permission_list()["permissions"]:
+ if p.startswith("testapp."):
+ permission_delete(p, force=True, sync_perm=False)
+
+
+def test_provision_dummy():
+ current = {"resources": {}}
+ wanted = {"resources": {"dummy": {}}}
+
+ assert not os.path.exists(dummyfile)
+ AppResourceManager("testapp", current=current, wanted=wanted).apply(
+ rollback_and_raise_exception_if_failure=False
+ )
+ assert open(dummyfile).read().strip() == "foo"
+
+
+def test_deprovision_dummy():
+ current = {"resources": {"dummy": {}}}
+ wanted = {"resources": {}}
+
+ open(dummyfile, "w").write("foo")
+
+ assert open(dummyfile).read().strip() == "foo"
+ AppResourceManager("testapp", current=current, wanted=wanted).apply(
+ rollback_and_raise_exception_if_failure=False
+ )
+ assert not os.path.exists(dummyfile)
+
+
+def test_provision_dummy_nondefaultvalue():
+ current = {"resources": {}}
+ wanted = {"resources": {"dummy": {"content": "bar"}}}
+
+ assert not os.path.exists(dummyfile)
+ AppResourceManager("testapp", current=current, wanted=wanted).apply(
+ rollback_and_raise_exception_if_failure=False
+ )
+ assert open(dummyfile).read().strip() == "bar"
+
+
+def test_update_dummy():
+ current = {"resources": {"dummy": {}}}
+ wanted = {"resources": {"dummy": {"content": "bar"}}}
+
+ open(dummyfile, "w").write("foo")
+
+ assert open(dummyfile).read().strip() == "foo"
+ AppResourceManager("testapp", current=current, wanted=wanted).apply(
+ rollback_and_raise_exception_if_failure=False
+ )
+ assert open(dummyfile).read().strip() == "bar"
+
+
+def test_update_dummy_failwithrollback():
+ current = {"resources": {"dummy": {}}}
+ wanted = {"resources": {"dummy": {"content": "forbiddenvalue"}}}
+
+ open(dummyfile, "w").write("foo")
+
+ assert open(dummyfile).read().strip() == "foo"
+ with pytest.raises(Exception):
+ AppResourceManager("testapp", current=current, wanted=wanted).apply(
+ rollback_and_raise_exception_if_failure=True
+ )
+ assert open(dummyfile).read().strip() == "foo"
+
+
+def test_resource_system_user():
+ r = AppResourceClassesByType["system_user"]
+
+ conf = {}
+
+ assert os.system("getent passwd testapp 2>/dev/null") != 0
+
+ r(conf, "testapp").provision_or_update()
+
+ assert os.system("getent passwd testapp >/dev/null") == 0
+ assert os.system("groups testapp | grep -q 'sftp.app'") != 0
+
+ conf["allow_sftp"] = True
+ r(conf, "testapp").provision_or_update()
+
+ assert os.system("getent passwd testapp >/dev/null") == 0
+ assert os.system("groups testapp | grep -q 'sftp.app'") == 0
+
+ r(conf, "testapp").deprovision()
+
+ assert os.system("getent passwd testapp 2>/dev/null") != 0
+
+
+def test_resource_install_dir():
+ r = AppResourceClassesByType["install_dir"]
+ conf = {"owner": "nobody:rx", "group": "nogroup:rx"}
+
+ # FIXME: should also check settings ?
+ # FIXME: should also check automigrate from final_path
+ # FIXME: should also test changing the install folder location ?
+
+ assert not os.path.exists("/var/www/testapp")
+
+ r(conf, "testapp").provision_or_update()
+
+ assert os.path.exists("/var/www/testapp")
+ unixperms = check_output("ls -ld /var/www/testapp").split()
+ assert unixperms[0] == "dr-xr-x---"
+ assert unixperms[2] == "nobody"
+ assert unixperms[3] == "nogroup"
+
+ conf["owner"] = "nobody:rwx"
+ conf["group"] = "www-data:x"
+
+ r(conf, "testapp").provision_or_update()
+
+ assert os.path.exists("/var/www/testapp")
+ unixperms = check_output("ls -ld /var/www/testapp").split()
+ assert unixperms[0] == "drwx--x---"
+ assert unixperms[2] == "nobody"
+ assert unixperms[3] == "www-data"
+
+ r(conf, "testapp").deprovision()
+
+ assert not os.path.exists("/var/www/testapp")
+
+
+def test_resource_data_dir():
+ r = AppResourceClassesByType["data_dir"]
+ conf = {"owner": "nobody:rx", "group": "nogroup:rx"}
+
+ assert not os.path.exists("/home/yunohost.app/testapp")
+
+ r(conf, "testapp").provision_or_update()
+
+ assert os.path.exists("/home/yunohost.app/testapp")
+ unixperms = check_output("ls -ld /home/yunohost.app/testapp").split()
+ assert unixperms[0] == "dr-xr-x---"
+ assert unixperms[2] == "nobody"
+ assert unixperms[3] == "nogroup"
+
+ conf["owner"] = "nobody:rwx"
+ conf["group"] = "www-data:x"
+
+ r(conf, "testapp").provision_or_update()
+
+ assert os.path.exists("/home/yunohost.app/testapp")
+ unixperms = check_output("ls -ld /home/yunohost.app/testapp").split()
+ assert unixperms[0] == "drwx--x---"
+ assert unixperms[2] == "nobody"
+ assert unixperms[3] == "www-data"
+
+ r(conf, "testapp").deprovision()
+
+ # FIXME : implement and check purge option
+ # assert not os.path.exists("/home/yunohost.app/testapp")
+
+
+def test_resource_ports():
+ r = AppResourceClassesByType["ports"]
+ conf = {}
+
+ assert not app_setting("testapp", "port")
+
+ r(conf, "testapp").provision_or_update()
+
+ assert app_setting("testapp", "port")
+
+ r(conf, "testapp").deprovision()
+
+ assert not app_setting("testapp", "port")
+
+
+def test_resource_ports_several():
+ r = AppResourceClassesByType["ports"]
+ conf = {"main": {"default": 12345}, "foobar": {"default": 23456}}
+
+ assert not app_setting("testapp", "port")
+ assert not app_setting("testapp", "port_foobar")
+
+ r(conf, "testapp").provision_or_update()
+
+ assert app_setting("testapp", "port")
+ assert app_setting("testapp", "port_foobar")
+
+ r(conf, "testapp").deprovision()
+
+ assert not app_setting("testapp", "port")
+ assert not app_setting("testapp", "port_foobar")
+
+
+def test_resource_ports_firewall():
+ r = AppResourceClassesByType["ports"]
+ conf = {"main": {"default": 12345}}
+
+ r(conf, "testapp").provision_or_update()
+
+ assert 12345 not in firewall_list()["opened_ports"]
+
+ conf = {"main": {"default": 12345, "exposed": "TCP"}}
+
+ r(conf, "testapp").provision_or_update()
+
+ assert 12345 in firewall_list()["opened_ports"]
+
+ r(conf, "testapp").deprovision()
+
+ assert 12345 not in firewall_list()["opened_ports"]
+
+
+def test_resource_database():
+ r = AppResourceClassesByType["database"]
+ conf = {"type": "mysql"}
+
+ assert os.system("mysqlshow 'testapp' >/dev/null 2>/dev/null") != 0
+ assert not app_setting("testapp", "db_name")
+ assert not app_setting("testapp", "db_user")
+ assert not app_setting("testapp", "db_pwd")
+
+ r(conf, "testapp").provision_or_update()
+
+ assert os.system("mysqlshow 'testapp' >/dev/null 2>/dev/null") == 0
+ assert app_setting("testapp", "db_name")
+ assert app_setting("testapp", "db_user")
+ assert app_setting("testapp", "db_pwd")
+
+ r(conf, "testapp").deprovision()
+
+ assert os.system("mysqlshow 'testapp' >/dev/null 2>/dev/null") != 0
+ assert not app_setting("testapp", "db_name")
+ assert not app_setting("testapp", "db_user")
+ assert not app_setting("testapp", "db_pwd")
+
+
+def test_resource_apt():
+ r = AppResourceClassesByType["apt"]
+ conf = {
+ "packages": "nyancat, sl",
+ "extras": {
+ "yarn": {
+ "repo": "deb https://dl.yarnpkg.com/debian/ stable main",
+ "key": "https://dl.yarnpkg.com/debian/pubkey.gpg",
+ "packages": "yarn",
+ }
+ },
+ }
+
+ assert os.system("dpkg --list | grep -q 'ii *nyancat '") != 0
+ assert os.system("dpkg --list | grep -q 'ii *sl '") != 0
+ assert os.system("dpkg --list | grep -q 'ii *yarn '") != 0
+ assert os.system("dpkg --list | grep -q 'ii *lolcat '") != 0
+ assert os.system("dpkg --list | grep -q 'ii *testapp-ynh-deps '") != 0
+
+ r(conf, "testapp").provision_or_update()
+
+ assert os.system("dpkg --list | grep -q 'ii *nyancat '") == 0
+ assert os.system("dpkg --list | grep -q 'ii *sl '") == 0
+ assert os.system("dpkg --list | grep -q 'ii *yarn '") == 0
+ assert (
+ os.system("dpkg --list | grep -q 'ii *lolcat '") != 0
+ ) # Lolcat shouldnt be installed yet
+ assert os.system("dpkg --list | grep -q 'ii *testapp-ynh-deps '") == 0
+
+ conf["packages"] += ", lolcat"
+ r(conf, "testapp").provision_or_update()
+
+ assert os.system("dpkg --list | grep -q 'ii *nyancat '") == 0
+ assert os.system("dpkg --list | grep -q 'ii *sl '") == 0
+ assert os.system("dpkg --list | grep -q 'ii *yarn '") == 0
+ assert os.system("dpkg --list | grep -q 'ii *lolcat '") == 0
+ assert os.system("dpkg --list | grep -q 'ii *testapp-ynh-deps '") == 0
+
+ r(conf, "testapp").deprovision()
+
+ assert os.system("dpkg --list | grep -q 'ii *nyancat '") != 0
+ assert os.system("dpkg --list | grep -q 'ii *sl '") != 0
+ assert os.system("dpkg --list | grep -q 'ii *yarn '") != 0
+ assert os.system("dpkg --list | grep -q 'ii *lolcat '") != 0
+ assert os.system("dpkg --list | grep -q 'ii *testapp-ynh-deps '") != 0
+
+
+def test_resource_permissions():
+ maindomain = _get_maindomain()
+ os.system(f"echo 'domain: {maindomain}' >> /etc/yunohost/apps/testapp/settings.yml")
+ os.system("echo 'path: /testapp' >> /etc/yunohost/apps/testapp/settings.yml")
+
+ # A manager object is required to set the label of the app...
+ manager = AppResourceManager("testapp", current={}, wanted={"name": "Test App"})
+ r = AppResourceClassesByType["permissions"]
+ conf = {
+ "main": {
+ "url": "/",
+ "allowed": "visitors"
+ # TODO: test protected?
+ },
+ }
+
+ res = user_permission_list(full=True)["permissions"]
+ assert not any(key.startswith("testapp.") for key in res)
+
+ r(conf, "testapp", manager).provision_or_update()
+
+ res = user_permission_list(full=True)["permissions"]
+ assert "testapp.main" in res
+ assert "visitors" in res["testapp.main"]["allowed"]
+ assert res["testapp.main"]["url"] == "/"
+ assert "testapp.admin" not in res
+
+ conf["admin"] = {"url": "/admin", "allowed": ""}
+
+ r(conf, "testapp", manager).provision_or_update()
+
+ res = user_permission_list(full=True)["permissions"]
+
+ assert "testapp.main" in list(res.keys())
+ assert "visitors" in res["testapp.main"]["allowed"]
+ assert res["testapp.main"]["url"] == "/"
+
+ assert "testapp.admin" in res
+ assert not res["testapp.admin"]["allowed"]
+ assert res["testapp.admin"]["url"] == "/admin"
+
+ conf["admin"]["url"] = "/adminpanel"
+
+ r(conf, "testapp", manager).provision_or_update()
+
+ res = user_permission_list(full=True)["permissions"]
+
+ assert res["testapp.admin"]["url"] == "/adminpanel"
+
+ r(conf, "testapp").deprovision()
+
+ res = user_permission_list(full=True)["permissions"]
+ assert "testapp.main" not in res
diff --git a/src/tests/test_apps.py b/src/tests/test_apps.py
index 2a808b5bd..d7a591a36 100644
--- a/src/tests/test_apps.py
+++ b/src/tests/test_apps.py
@@ -15,9 +15,11 @@ from yunohost.app import (
_is_installed,
app_upgrade,
app_map,
+ app_manifest,
+ app_info,
)
from yunohost.domain import _get_maindomain, domain_add, domain_remove, domain_list
-from yunohost.utils.error import YunohostError
+from yunohost.utils.error import YunohostError, YunohostValidationError
from yunohost.tests.test_permission import (
check_LDAP_db_integrity,
check_permission_for_apps,
@@ -26,17 +28,14 @@ from yunohost.permission import user_permission_list, permission_delete
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()
@@ -45,12 +44,12 @@ def clean():
"break_yo_system",
"legacy_app",
"legacy_app__2",
+ "manifestv2_app",
"full_domain_app",
"my_webapp",
]
for test_app in test_apps:
-
if _is_installed(test_app):
app_remove(test_app)
@@ -92,7 +91,6 @@ def check_permission_for_apps_call():
@pytest.fixture(scope="module")
def secondary_domain(request):
-
if "example.test" not in domain_list()["domains"]:
domain_add("example.test")
@@ -110,32 +108,31 @@ def secondary_domain(request):
def app_expected_files(domain, app):
-
yield "/etc/nginx/conf.d/{}.d/{}.conf".format(domain, app)
if app.startswith("legacy_app"):
yield "/var/www/%s/index.html" % app
yield "/etc/yunohost/apps/%s/settings.yml" % app
- yield "/etc/yunohost/apps/%s/manifest.json" % app
+ if "manifestv2" in app or "my_webapp" in app:
+ yield "/etc/yunohost/apps/%s/manifest.toml" % app
+ else:
+ yield "/etc/yunohost/apps/%s/manifest.json" % app
yield "/etc/yunohost/apps/%s/scripts/install" % app
yield "/etc/yunohost/apps/%s/scripts/remove" % app
def app_is_installed(domain, app):
-
return _is_installed(app) and all(
os.path.exists(f) for f in app_expected_files(domain, app)
)
def app_is_not_installed(domain, app):
-
return not _is_installed(app) and not all(
os.path.exists(f) for f in app_expected_files(domain, app)
)
def app_is_exposed_on_http(domain, path, message_in_page):
-
try:
r = requests.get(
"https://127.0.0.1" + path + "/",
@@ -149,7 +146,6 @@ def app_is_exposed_on_http(domain, path, message_in_page):
def install_legacy_app(domain, path, public=True):
-
app_install(
os.path.join(get_test_apps_dir(), "legacy_app_ynh"),
args="domain={}&path={}&is_public={}".format(domain, path, 1 if public else 0),
@@ -157,8 +153,17 @@ def install_legacy_app(domain, path, public=True):
)
-def install_full_domain_app(domain):
+def install_manifestv2_app(domain, path, public=True):
+ app_install(
+ os.path.join(get_test_apps_dir(), "manifestv2_app_ynh"),
+ args="domain={}&path={}&init_main_permission={}".format(
+ domain, path, "visitors" if public else "all_users"
+ ),
+ force=True,
+ )
+
+def install_full_domain_app(domain):
app_install(
os.path.join(get_test_apps_dir(), "full_domain_app_ynh"),
args="domain=%s" % domain,
@@ -167,7 +172,6 @@ def install_full_domain_app(domain):
def install_break_yo_system(domain, breakwhat):
-
app_install(
os.path.join(get_test_apps_dir(), "break_yo_system_ynh"),
args="domain={}&breakwhat={}".format(domain, breakwhat),
@@ -176,7 +180,6 @@ def install_break_yo_system(domain, breakwhat):
def test_legacy_app_install_main_domain():
-
main_domain = _get_maindomain()
install_legacy_app(main_domain, "/legacy")
@@ -195,12 +198,139 @@ def test_legacy_app_install_main_domain():
assert app_is_not_installed(main_domain, "legacy_app")
+def test_legacy_app_manifest_preinstall():
+ m = app_manifest(os.path.join(get_test_apps_dir(), "legacy_app_ynh"))
+ # v1 manifesto are expected to have been autoconverted to v2
+
+ assert "id" in m
+ assert "description" in m
+ assert "integration" in m
+ assert "install" in m
+ assert m["doc"] == {}
+ assert m["notifications"] == {
+ "PRE_INSTALL": {},
+ "PRE_UPGRADE": {},
+ "POST_INSTALL": {},
+ "POST_UPGRADE": {},
+ }
+
+
+def test_manifestv2_app_manifest_preinstall():
+ m = app_manifest(os.path.join(get_test_apps_dir(), "manifestv2_app_ynh"))
+
+ assert "id" in m
+ assert "install" in m
+ assert "description" in m
+ assert "doc" in m
+ assert (
+ "This is a dummy description of this app features"
+ in m["doc"]["DESCRIPTION"]["en"]
+ )
+ assert (
+ "Ceci est une fausse description des fonctionalités de l'app"
+ in m["doc"]["DESCRIPTION"]["fr"]
+ )
+ assert "notifications" in m
+ assert (
+ "This is a dummy disclaimer to display prior to the install"
+ in m["notifications"]["PRE_INSTALL"]["main"]["en"]
+ )
+ assert (
+ "Ceci est un faux disclaimer à présenter avant l'installation"
+ in m["notifications"]["PRE_INSTALL"]["main"]["fr"]
+ )
+
+
+def test_manifestv2_app_install_main_domain():
+ main_domain = _get_maindomain()
+
+ install_manifestv2_app(main_domain, "/manifestv2")
+
+ app_map_ = app_map(raw=True)
+ assert main_domain in app_map_
+ assert "/manifestv2" in app_map_[main_domain]
+ assert "id" in app_map_[main_domain]["/manifestv2"]
+ assert app_map_[main_domain]["/manifestv2"]["id"] == "manifestv2_app"
+
+ assert app_is_installed(main_domain, "manifestv2_app")
+ assert app_is_exposed_on_http(main_domain, "/manifestv2", "Hextris")
+
+ app_remove("manifestv2_app")
+
+ assert app_is_not_installed(main_domain, "manifestv2_app")
+
+
+def test_manifestv2_app_info_postinstall():
+ main_domain = _get_maindomain()
+ install_manifestv2_app(main_domain, "/manifestv2")
+ m = app_info("manifestv2_app", full=True)["manifest"]
+
+ assert "id" in m
+ assert "install" in m
+ assert "description" in m
+ assert "doc" in m
+ assert "The app install dir is /var/www/manifestv2_app" in m["doc"]["ADMIN"]["en"]
+ assert (
+ "Le dossier d'install de l'app est /var/www/manifestv2_app"
+ in m["doc"]["ADMIN"]["fr"]
+ )
+ assert "notifications" in m
+ assert (
+ "The app install dir is /var/www/manifestv2_app"
+ in m["notifications"]["POST_INSTALL"]["main"]["en"]
+ )
+ assert (
+ "The app id is manifestv2_app"
+ in m["notifications"]["POST_INSTALL"]["main"]["en"]
+ )
+ assert (
+ f"The app url is {main_domain}/manifestv2"
+ in m["notifications"]["POST_INSTALL"]["main"]["en"]
+ )
+
+
+def test_manifestv2_app_info_preupgrade(monkeypatch):
+ manifest = app_manifest(os.path.join(get_test_apps_dir(), "manifestv2_app_ynh"))
+
+ from yunohost.app_catalog import _load_apps_catalog as original_load_apps_catalog
+
+ def custom_load_apps_catalog(*args, **kwargs):
+ res = original_load_apps_catalog(*args, **kwargs)
+ res["apps"]["manifestv2_app"] = {
+ "id": "manifestv2_app",
+ "level": 10,
+ "lastUpdate": 999999999,
+ "maintained": True,
+ "manifest": manifest,
+ "state": "working",
+ }
+ res["apps"]["manifestv2_app"]["manifest"]["version"] = "99999~ynh1"
+
+ return res
+
+ monkeypatch.setattr("yunohost.app._load_apps_catalog", custom_load_apps_catalog)
+
+ main_domain = _get_maindomain()
+ install_manifestv2_app(main_domain, "/manifestv2")
+ i = app_info("manifestv2_app", full=True)
+
+ assert i["upgradable"] == "yes"
+ assert i["new_version"] == "99999~ynh1"
+ # FIXME : as I write this test, I realize that this implies the catalog API
+ # does provide the notifications, which means the list builder script
+ # should parse the files in the original app repo, possibly with proper i18n etc
+ assert (
+ "This is a dummy disclaimer to display prior to any upgrade"
+ in i["from_catalog"]["manifest"]["notifications"]["PRE_UPGRADE"]["main"]["en"]
+ )
+
+
def test_app_from_catalog():
main_domain = _get_maindomain()
app_install(
"my_webapp",
- args=f"domain={main_domain}&path=/site&with_sftp=0&password=superpassword&is_public=1&with_mysql=0",
+ args=f"domain={main_domain}&path=/site&with_sftp=0&password=superpassword&init_main_permission=visitors&with_mysql=0&phpversion=none",
)
app_map_ = app_map(raw=True)
assert main_domain in app_map_
@@ -209,7 +339,9 @@ def test_app_from_catalog():
assert app_map_[main_domain]["/site"]["id"] == "my_webapp"
assert app_is_installed(main_domain, "my_webapp")
- assert app_is_exposed_on_http(main_domain, "/site", "Custom Web App")
+ assert app_is_exposed_on_http(
+ main_domain, "/site", "you have just installed My Webapp"
+ )
# Try upgrade, should do nothing
app_upgrade("my_webapp")
@@ -222,7 +354,6 @@ def test_app_from_catalog():
def test_legacy_app_install_secondary_domain(secondary_domain):
-
install_legacy_app(secondary_domain, "/legacy")
assert app_is_installed(secondary_domain, "legacy_app")
@@ -234,7 +365,6 @@ def test_legacy_app_install_secondary_domain(secondary_domain):
def test_legacy_app_install_secondary_domain_on_root(secondary_domain):
-
install_legacy_app(secondary_domain, "/")
app_map_ = app_map(raw=True)
@@ -252,7 +382,6 @@ def test_legacy_app_install_secondary_domain_on_root(secondary_domain):
def test_legacy_app_install_private(secondary_domain):
-
install_legacy_app(secondary_domain, "/legacy", public=False)
assert app_is_installed(secondary_domain, "legacy_app")
@@ -266,7 +395,6 @@ def test_legacy_app_install_private(secondary_domain):
def test_legacy_app_install_unknown_domain(mocker):
-
with pytest.raises(YunohostError):
with message(mocker, "app_argument_invalid"):
install_legacy_app("whatever.nope", "/legacy")
@@ -275,7 +403,6 @@ def test_legacy_app_install_unknown_domain(mocker):
def test_legacy_app_install_multiple_instances(secondary_domain):
-
install_legacy_app(secondary_domain, "/foo")
install_legacy_app(secondary_domain, "/bar")
@@ -297,7 +424,6 @@ def test_legacy_app_install_multiple_instances(secondary_domain):
def test_legacy_app_install_path_unavailable(mocker, secondary_domain):
-
# These will be removed in teardown
install_legacy_app(secondary_domain, "/legacy")
@@ -310,7 +436,6 @@ def test_legacy_app_install_path_unavailable(mocker, secondary_domain):
def test_legacy_app_install_with_nginx_down(mocker, secondary_domain):
-
os.system("systemctl stop nginx")
with raiseYunohostError(
@@ -320,7 +445,6 @@ def test_legacy_app_install_with_nginx_down(mocker, secondary_domain):
def test_legacy_app_failed_install(mocker, secondary_domain):
-
# This will conflict with the folder that the app
# attempts to create, making the install fail
mkdir("/var/www/legacy_app/", 0o750)
@@ -333,7 +457,6 @@ def test_legacy_app_failed_install(mocker, secondary_domain):
def test_legacy_app_failed_remove(mocker, secondary_domain):
-
install_legacy_app(secondary_domain, "/legacy")
# The remove script runs with set -eu and attempt to remove this
@@ -353,14 +476,12 @@ def test_legacy_app_failed_remove(mocker, secondary_domain):
def test_full_domain_app(secondary_domain):
-
install_full_domain_app(secondary_domain)
assert app_is_exposed_on_http(secondary_domain, "/", "This is a dummy app")
def test_full_domain_app_with_conflicts(mocker, secondary_domain):
-
install_legacy_app(secondary_domain, "/legacy")
with raiseYunohostError(mocker, "app_full_domain_unavailable"):
@@ -368,7 +489,6 @@ def test_full_domain_app_with_conflicts(mocker, secondary_domain):
def test_systemfuckedup_during_app_install(mocker, secondary_domain):
-
with pytest.raises(YunohostError):
with message(mocker, "app_install_failed"):
with message(mocker, "app_action_broke_system"):
@@ -378,7 +498,6 @@ def test_systemfuckedup_during_app_install(mocker, secondary_domain):
def test_systemfuckedup_during_app_remove(mocker, secondary_domain):
-
install_break_yo_system(secondary_domain, breakwhat="remove")
with pytest.raises(YunohostError):
@@ -390,7 +509,6 @@ def test_systemfuckedup_during_app_remove(mocker, secondary_domain):
def test_systemfuckedup_during_app_install_and_remove(mocker, secondary_domain):
-
with pytest.raises(YunohostError):
with message(mocker, "app_install_failed"):
with message(mocker, "app_action_broke_system"):
@@ -400,7 +518,6 @@ def test_systemfuckedup_during_app_install_and_remove(mocker, secondary_domain):
def test_systemfuckedup_during_app_upgrade(mocker, secondary_domain):
-
install_break_yo_system(secondary_domain, breakwhat="upgrade")
with pytest.raises(YunohostError):
@@ -412,7 +529,6 @@ def test_systemfuckedup_during_app_upgrade(mocker, secondary_domain):
def test_failed_multiple_app_upgrade(mocker, secondary_domain):
-
install_legacy_app(secondary_domain, "/legacy")
install_break_yo_system(secondary_domain, breakwhat="upgrade")
@@ -427,3 +543,176 @@ def test_failed_multiple_app_upgrade(mocker, secondary_domain):
"legacy": os.path.join(get_test_apps_dir(), "legacy_app_ynh"),
},
)
+
+
+class TestMockedAppUpgrade:
+ """
+ This class is here to test the logical workflow of app_upgrade and thus
+ mock nearly all side effects
+ """
+
+ def setup_method(self, method):
+ self.apps_list = []
+ self.upgradable_apps_list = []
+
+ def _mock_app_upgrade(self, mocker):
+ # app list
+ self._installed_apps = mocker.patch(
+ "yunohost.app._installed_apps", side_effect=lambda: self.apps_list
+ )
+
+ # just check if an app is really installed
+ mocker.patch(
+ "yunohost.app._is_installed", side_effect=lambda app: app in self.apps_list
+ )
+
+ # app_dict =
+ mocker.patch(
+ "yunohost.app.app_info",
+ side_effect=lambda app, full: {
+ "upgradable": "yes" if app in self.upgradable_apps_list else "no",
+ "manifest": {"id": app},
+ "version": "?",
+ },
+ )
+
+ def custom_extract_app(app):
+ return (
+ {
+ "version": "?",
+ "packaging_format": 1,
+ "id": app,
+ "notifications": {"PRE_UPGRADE": None, "POST_UPGRADE": None},
+ },
+ "MOCKED_BY_TEST",
+ )
+
+ # return (manifest, extracted_app_folder)
+ mocker.patch("yunohost.app._extract_app", side_effect=custom_extract_app)
+
+ # for [(name, passed, values, err), ...] in
+ mocker.patch(
+ "yunohost.app._check_manifest_requirements",
+ return_value=[(None, True, None, None)],
+ )
+
+ # raise on failure
+ mocker.patch("yunohost.app._assert_system_is_sane_for_app", return_value=True)
+
+ from os.path import exists # import the unmocked function
+
+ def custom_os_path_exists(path):
+ if path.endswith("manifest.toml"):
+ return True
+ return exists(path)
+
+ mocker.patch("os.path.exists", side_effect=custom_os_path_exists)
+
+ # manifest =
+ mocker.patch(
+ "yunohost.app.read_toml", return_value={"arguments": {"install": []}}
+ )
+
+ # install_failed, failure_message_with_debug_instructions =
+ self.hook_exec_with_script_debug_if_failure = mocker.patch(
+ "yunohost.hook.hook_exec_with_script_debug_if_failure",
+ return_value=(False, ""),
+ )
+ # settings =
+ mocker.patch("yunohost.app._get_app_settings", return_value={})
+ # return nothing
+ mocker.patch("yunohost.app._set_app_settings")
+
+ from os import listdir # import the unmocked function
+
+ def custom_os_listdir(path):
+ if path.endswith("MOCKED_BY_TEST"):
+ return []
+ return listdir(path)
+
+ mocker.patch("os.listdir", side_effect=custom_os_listdir)
+ mocker.patch("yunohost.app.rm")
+ mocker.patch("yunohost.app.cp")
+ mocker.patch("shutil.rmtree")
+ mocker.patch("yunohost.app.chmod")
+ mocker.patch("yunohost.app.chown")
+ mocker.patch("yunohost.app.app_ssowatconf")
+
+ def test_app_upgrade_no_apps(self, mocker):
+ self._mock_app_upgrade(mocker)
+
+ with pytest.raises(YunohostValidationError):
+ app_upgrade()
+
+ def test_app_upgrade_app_not_install(self, mocker):
+ self._mock_app_upgrade(mocker)
+
+ with pytest.raises(YunohostValidationError):
+ app_upgrade("some_app")
+
+ def test_app_upgrade_one_app(self, mocker):
+ self._mock_app_upgrade(mocker)
+ self.apps_list = ["some_app"]
+
+ # yunohost is happy, not apps to upgrade
+ app_upgrade()
+
+ self.hook_exec_with_script_debug_if_failure.assert_not_called()
+
+ self.upgradable_apps_list.append("some_app")
+ app_upgrade()
+
+ self.hook_exec_with_script_debug_if_failure.assert_called_once()
+ assert (
+ self.hook_exec_with_script_debug_if_failure.call_args.kwargs["env"][
+ "YNH_APP_ID"
+ ]
+ == "some_app"
+ )
+
+ def test_app_upgrade_continue_on_failure(self, mocker):
+ self._mock_app_upgrade(mocker)
+ self.apps_list = ["a", "b", "c"]
+ self.upgradable_apps_list = self.apps_list
+
+ def fails_on_b(self, *args, env, **kwargs):
+ if env["YNH_APP_ID"] == "b":
+ return True, "failed"
+ return False, "ok"
+
+ self.hook_exec_with_script_debug_if_failure.side_effect = fails_on_b
+
+ with pytest.raises(YunohostError):
+ app_upgrade()
+
+ app_upgrade(continue_on_failure=True)
+
+ def test_app_upgrade_continue_on_failure_broken_system(self, mocker):
+ """--continue-on-failure should stop on a broken system"""
+
+ self._mock_app_upgrade(mocker)
+ self.apps_list = ["a", "broke_the_system", "c"]
+ self.upgradable_apps_list = self.apps_list
+
+ def fails_on_b(self, *args, env, **kwargs):
+ if env["YNH_APP_ID"] == "broke_the_system":
+ return True, "failed"
+ return False, "ok"
+
+ self.hook_exec_with_script_debug_if_failure.side_effect = fails_on_b
+
+ def _assert_system_is_sane_for_app(manifest, state):
+ if state == "post" and manifest["id"] == "broke_the_system":
+ raise Exception()
+ return True
+
+ mocker.patch(
+ "yunohost.app._assert_system_is_sane_for_app",
+ side_effect=_assert_system_is_sane_for_app,
+ )
+
+ with pytest.raises(YunohostError):
+ app_upgrade()
+
+ with pytest.raises(YunohostError):
+ app_upgrade(continue_on_failure=True)
diff --git a/src/tests/test_appurl.py b/src/tests/test_appurl.py
index c036ae28a..d0c55f732 100644
--- a/src/tests/test_appurl.py
+++ b/src/tests/test_appurl.py
@@ -18,7 +18,6 @@ maindomain = _get_maindomain()
def setup_function(function):
-
try:
app_remove("register_url_app")
except Exception:
@@ -26,7 +25,6 @@ def setup_function(function):
def teardown_function(function):
-
try:
app_remove("register_url_app")
except Exception:
@@ -34,7 +32,6 @@ def teardown_function(function):
def test_parse_app_instance_name():
-
assert _parse_app_instance_name("yolo") == ("yolo", 1)
assert _parse_app_instance_name("yolo1") == ("yolo1", 1)
assert _parse_app_instance_name("yolo__0") == ("yolo__0", 1)
@@ -72,8 +69,23 @@ def test_repo_url_definition():
assert _is_app_repo_url("git@github.com:YunoHost-Apps/foobar_ynh.git")
assert _is_app_repo_url("https://git.super.host/~max/foobar_ynh")
+ ### Gitea
+ assert _is_app_repo_url("https://gitea.instance.tld/user/repo_ynh")
+ assert _is_app_repo_url(
+ "https://gitea.instance.tld/user/repo_ynh/src/branch/branch_name"
+ )
+ assert _is_app_repo_url("https://gitea.instance.tld/user/repo_ynh/src/tag/tag_name")
+ assert _is_app_repo_url(
+ "https://gitea.instance.tld/user/repo_ynh/src/commit/abcd1234"
+ )
+
+ ### Invalid patterns
+
+ # no schema
assert not _is_app_repo_url("github.com/YunoHost-Apps/foobar_ynh")
+ # http
assert not _is_app_repo_url("http://github.com/YunoHost-Apps/foobar_ynh")
+ # does not end in `_ynh`
assert not _is_app_repo_url("https://github.com/YunoHost-Apps/foobar_wat")
assert not _is_app_repo_url("https://github.com/YunoHost-Apps/foobar_ynh_wat")
assert not _is_app_repo_url("https://github.com/YunoHost-Apps/foobar/tree/testing")
@@ -86,7 +98,6 @@ def test_repo_url_definition():
def test_urlavailable():
-
# Except the maindomain/macnuggets to be available
assert domain_url_available(maindomain, "/macnuggets")
@@ -96,7 +107,6 @@ def test_urlavailable():
def test_registerurl():
-
app_install(
os.path.join(get_test_apps_dir(), "register_url_app_ynh"),
args="domain={}&path={}".format(maindomain, "/urlregisterapp"),
@@ -115,7 +125,6 @@ def test_registerurl():
def test_registerurl_baddomain():
-
with pytest.raises(YunohostError):
app_install(
os.path.join(get_test_apps_dir(), "register_url_app_ynh"),
diff --git a/src/tests/test_backuprestore.py b/src/tests/test_backuprestore.py
index 03c3aa0c7..bca1b29a5 100644
--- a/src/tests/test_backuprestore.py
+++ b/src/tests/test_backuprestore.py
@@ -6,6 +6,8 @@ from mock import patch
from .conftest import message, raiseYunohostError, get_test_apps_dir
+from moulinette.utils.text import random_ascii
+
from yunohost.app import app_install, app_remove, app_ssowatconf
from yunohost.app import _is_installed
from yunohost.backup import (
@@ -30,7 +32,6 @@ maindomain = ""
def setup_function(function):
-
global maindomain
maindomain = _get_maindomain()
@@ -54,7 +55,7 @@ def setup_function(function):
if "with_legacy_app_installed" in markers:
assert not app_is_installed("legacy_app")
- install_app("legacy_app_ynh", "/yolo")
+ install_app("legacy_app_ynh", "/yolo", "&is_public=true")
assert app_is_installed("legacy_app")
if "with_backup_recommended_app_installed" in markers:
@@ -77,7 +78,7 @@ 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")
+ user_create("alice", maindomain, "test123Ynh", fullname="Alice White")
with patch.object(os, "isatty", return_value=False):
install_app("permissions_app_ynh", "/urlpermissionapp" "&admin=alice")
assert app_is_installed("permissions_app")
@@ -89,7 +90,6 @@ def setup_function(function):
def teardown_function(function):
-
assert tmp_backup_directory_is_empty()
reset_ssowat_conf()
@@ -133,7 +133,6 @@ def check_permission_for_apps_call():
def app_is_installed(app):
-
if app == "permissions_app":
return _is_installed(app)
@@ -147,7 +146,6 @@ def app_is_installed(app):
def backup_test_dependencies_are_met():
-
# Dummy test apps (or backup archives)
assert os.path.exists(
os.path.join(get_test_apps_dir(), "backup_wordpress_from_4p2")
@@ -161,7 +159,6 @@ def backup_test_dependencies_are_met():
def tmp_backup_directory_is_empty():
-
if not os.path.exists("/home/yunohost.backup/tmp/"):
return True
else:
@@ -169,7 +166,6 @@ def tmp_backup_directory_is_empty():
def clean_tmp_backup_directory():
-
if tmp_backup_directory_is_empty():
return
@@ -191,27 +187,23 @@ def clean_tmp_backup_directory():
def reset_ssowat_conf():
-
# Make sure we have a ssowat
os.system("mkdir -p /etc/ssowat/")
app_ssowatconf()
def delete_all_backups():
-
for archive in backup_list()["archives"]:
backup_delete(archive)
def uninstall_test_apps_if_needed():
-
for app in ["legacy_app", "backup_recommended_app", "wordpress", "permissions_app"]:
if _is_installed(app):
app_remove(app)
def install_app(app, path, additionnal_args=""):
-
app_install(
os.path.join(get_test_apps_dir(), app),
args="domain={}&path={}{}".format(maindomain, path, additionnal_args),
@@ -220,7 +212,6 @@ def install_app(app, path, additionnal_args=""):
def add_archive_wordpress_from_4p2():
-
os.system("mkdir -p /home/yunohost.backup/archives")
os.system(
@@ -231,7 +222,6 @@ def add_archive_wordpress_from_4p2():
def add_archive_system_from_4p2():
-
os.system("mkdir -p /home/yunohost.backup/archives")
os.system(
@@ -247,10 +237,10 @@ def add_archive_system_from_4p2():
def test_backup_only_ldap(mocker):
-
# Create the backup
- with message(mocker, "backup_created"):
- backup_create(system=["conf_ldap"], apps=None)
+ name = random_ascii(8)
+ with message(mocker, "backup_created", name=name):
+ backup_create(name=name, system=["conf_ldap"], apps=None)
archives = backup_list()["archives"]
assert len(archives) == 1
@@ -262,7 +252,6 @@ def test_backup_only_ldap(mocker):
def test_backup_system_part_that_does_not_exists(mocker):
-
# Create the backup
with message(mocker, "backup_hook_unknown", hook="doesnt_exist"):
with raiseYunohostError(mocker, "backup_nothings_done"):
@@ -275,10 +264,10 @@ def test_backup_system_part_that_does_not_exists(mocker):
def test_backup_and_restore_all_sys(mocker):
-
+ name = random_ascii(8)
# Create the backup
- with message(mocker, "backup_created"):
- backup_create(system=[], apps=None)
+ with message(mocker, "backup_created", name=name):
+ backup_create(name=name, system=[], apps=None)
archives = backup_list()["archives"]
assert len(archives) == 1
@@ -309,10 +298,10 @@ def test_backup_and_restore_all_sys(mocker):
@pytest.mark.with_system_archive_from_4p2
def test_restore_system_from_Ynh4p2(monkeypatch, mocker):
-
+ name = random_ascii(8)
# Backup current system
- with message(mocker, "backup_created"):
- backup_create(system=[], apps=None)
+ with message(mocker, "backup_created", name=name):
+ backup_create(name=name, system=[], apps=None)
archives = backup_list()["archives"]
assert len(archives) == 2
@@ -337,7 +326,6 @@ def test_restore_system_from_Ynh4p2(monkeypatch, mocker):
@pytest.mark.with_backup_recommended_app_installed
def test_backup_script_failure_handling(monkeypatch, mocker):
def custom_hook_exec(name, *args, **kwargs):
-
if os.path.basename(name).startswith("backup_"):
raise Exception
else:
@@ -355,13 +343,15 @@ def test_backup_script_failure_handling(monkeypatch, mocker):
@pytest.mark.with_backup_recommended_app_installed
def test_backup_not_enough_free_space(monkeypatch, mocker):
- def custom_disk_usage(path):
+ def custom_space_used_by_directory(path, *args, **kwargs):
return 99999999999999999
def custom_free_space_in_directory(dirpath):
return 0
- monkeypatch.setattr("yunohost.backup.disk_usage", custom_disk_usage)
+ monkeypatch.setattr(
+ "yunohost.backup.space_used_by_directory", custom_space_used_by_directory
+ )
monkeypatch.setattr(
"yunohost.backup.free_space_in_directory", custom_free_space_in_directory
)
@@ -371,7 +361,6 @@ def test_backup_not_enough_free_space(monkeypatch, mocker):
def test_backup_app_not_installed(mocker):
-
assert not _is_installed("wordpress")
with message(mocker, "unbackup_app", app="wordpress"):
@@ -381,7 +370,6 @@ def test_backup_app_not_installed(mocker):
@pytest.mark.with_backup_recommended_app_installed
def test_backup_app_with_no_backup_script(mocker):
-
backup_script = "/etc/yunohost/apps/backup_recommended_app/scripts/backup"
os.system("rm %s" % backup_script)
assert not os.path.exists(backup_script)
@@ -395,7 +383,6 @@ def test_backup_app_with_no_backup_script(mocker):
@pytest.mark.with_backup_recommended_app_installed
def test_backup_app_with_no_restore_script(mocker):
-
restore_script = "/etc/yunohost/apps/backup_recommended_app/scripts/restore"
os.system("rm %s" % restore_script)
assert not os.path.exists(restore_script)
@@ -411,17 +398,17 @@ def test_backup_app_with_no_restore_script(mocker):
@pytest.mark.clean_opt_dir
def test_backup_with_different_output_directory(mocker):
-
+ name = random_ascii(8)
# Create the backup
- with message(mocker, "backup_created"):
+ with message(mocker, "backup_created", name=name):
backup_create(
system=["conf_ynh_settings"],
apps=None,
output_directory="/opt/test_backup_output_directory",
- name="backup",
+ name=name,
)
- assert os.path.exists("/opt/test_backup_output_directory/backup.tar")
+ assert os.path.exists(f"/opt/test_backup_output_directory/{name}.tar")
archives = backup_list()["archives"]
assert len(archives) == 1
@@ -434,15 +421,15 @@ def test_backup_with_different_output_directory(mocker):
@pytest.mark.clean_opt_dir
def test_backup_using_copy_method(mocker):
-
# Create the backup
- with message(mocker, "backup_created"):
+ name = random_ascii(8)
+ with message(mocker, "backup_created", name=name):
backup_create(
system=["conf_ynh_settings"],
apps=None,
output_directory="/opt/test_backup_output_directory",
methods=["copy"],
- name="backup",
+ name=name,
)
assert os.path.exists("/opt/test_backup_output_directory/info.json")
@@ -456,7 +443,6 @@ def test_backup_using_copy_method(mocker):
@pytest.mark.with_wordpress_archive_from_4p2
@pytest.mark.with_custom_domain("yolo.test")
def test_restore_app_wordpress_from_Ynh4p2(mocker):
-
with message(mocker, "restore_complete"):
backup_restore(
system=None, name=backup_list()["archives"][0], apps=["wordpress"]
@@ -505,7 +491,6 @@ def test_restore_app_not_enough_free_space(monkeypatch, mocker):
@pytest.mark.with_wordpress_archive_from_4p2
def test_restore_app_not_in_backup(mocker):
-
assert not _is_installed("wordpress")
assert not _is_installed("yoloswag")
@@ -522,7 +507,6 @@ def test_restore_app_not_in_backup(mocker):
@pytest.mark.with_wordpress_archive_from_4p2
@pytest.mark.with_custom_domain("yolo.test")
def test_restore_app_already_installed(mocker):
-
assert not _is_installed("wordpress")
with message(mocker, "restore_complete"):
@@ -542,25 +526,21 @@ def test_restore_app_already_installed(mocker):
@pytest.mark.with_legacy_app_installed
def test_backup_and_restore_legacy_app(mocker):
-
_test_backup_and_restore_app(mocker, "legacy_app")
@pytest.mark.with_backup_recommended_app_installed
def test_backup_and_restore_recommended_app(mocker):
-
_test_backup_and_restore_app(mocker, "backup_recommended_app")
@pytest.mark.with_backup_recommended_app_installed_with_ynh_restore
def test_backup_and_restore_with_ynh_restore(mocker):
-
_test_backup_and_restore_app(mocker, "backup_recommended_app")
@pytest.mark.with_permission_app_installed
def test_backup_and_restore_permission_app(mocker):
-
res = user_permission_list(full=True)["permissions"]
assert "permissions_app.main" in res
assert "permissions_app.admin" in res
@@ -591,10 +571,10 @@ def test_backup_and_restore_permission_app(mocker):
def _test_backup_and_restore_app(mocker, app):
-
# Create a backup of this app
- with message(mocker, "backup_created"):
- backup_create(system=None, apps=[app])
+ name = random_ascii(8)
+ with message(mocker, "backup_created", name=name):
+ backup_create(name=name, system=None, apps=[app])
archives = backup_list()["archives"]
assert len(archives) == 1
@@ -626,7 +606,6 @@ def _test_backup_and_restore_app(mocker, app):
def test_restore_archive_with_no_json(mocker):
-
# Create a backup with no info.json associated
os.system("touch /tmp/afile")
os.system("tar -cvf /home/yunohost.backup/archives/badbackup.tar /tmp/afile")
@@ -639,7 +618,6 @@ def test_restore_archive_with_no_json(mocker):
@pytest.mark.with_wordpress_archive_from_4p2
def test_restore_archive_with_bad_archive(mocker):
-
# Break the archive
os.system(
"head -n 1000 /home/yunohost.backup/archives/backup_wordpress_from_4p2.tar > /home/yunohost.backup/archives/backup_wordpress_from_4p2_bad.tar"
@@ -654,13 +632,13 @@ def test_restore_archive_with_bad_archive(mocker):
def test_restore_archive_with_custom_hook(mocker):
-
custom_restore_hook_folder = os.path.join(CUSTOM_HOOK_FOLDER, "restore")
os.system("touch %s/99-yolo" % custom_restore_hook_folder)
# Backup with custom hook system
- with message(mocker, "backup_created"):
- backup_create(system=[], apps=None)
+ name = random_ascii(8)
+ with message(mocker, "backup_created", name=name):
+ backup_create(name=name, system=[], apps=None)
archives = backup_list()["archives"]
assert len(archives) == 1
@@ -697,5 +675,6 @@ def test_backup_binds_are_readonly(mocker, monkeypatch):
)
# Create the backup
- with message(mocker, "backup_created"):
- backup_create(system=[])
+ name = random_ascii(8)
+ with message(mocker, "backup_created", name=name):
+ backup_create(name=name, system=[])
diff --git a/src/tests/test_dns.py b/src/tests/test_dns.py
index a23ac7982..e896d9c9f 100644
--- a/src/tests/test_dns.py
+++ b/src/tests/test_dns.py
@@ -12,12 +12,10 @@ from yunohost.dns import (
def setup_function(function):
-
clean()
def teardown_function(function):
-
clean()
@@ -76,7 +74,6 @@ def example_domain():
def test_domain_dns_suggest(example_domain):
-
assert _build_dns_conf(example_domain)
diff --git a/src/tests/test_domains.py b/src/tests/test_domains.py
index 95a33e0ba..b414c21d8 100644
--- a/src/tests/test_domains.py
+++ b/src/tests/test_domains.py
@@ -19,7 +19,6 @@ 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"]
@@ -52,7 +51,6 @@ def setup_function(function):
def teardown_function(function):
-
clean()
@@ -102,7 +100,6 @@ def test_domain_config_get_default():
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
diff --git a/src/tests/test_ldapauth.py b/src/tests/test_ldapauth.py
index a95dea443..9e3ae36cc 100644
--- a/src/tests/test_ldapauth.py
+++ b/src/tests/test_ldapauth.py
@@ -2,58 +2,79 @@ import pytest
import os
from yunohost.authenticators.ldap_admin import Authenticator as LDAPAuth
-from yunohost.tools import tools_adminpw
+from yunohost.user import user_create, user_list, user_update, user_delete
+from yunohost.domain import _get_maindomain
from moulinette import m18n
from moulinette.core import MoulinetteError
def setup_function(function):
+ for u in user_list()["users"]:
+ user_delete(u, purge=True)
- if os.system("systemctl is-active slapd") != 0:
+ maindomain = _get_maindomain()
+
+ if os.system("systemctl is-active slapd >/dev/null") != 0:
os.system("systemctl start slapd && sleep 3")
- tools_adminpw("yunohost", check_strength=False)
+ user_create("alice", maindomain, "Yunohost", admin=True, fullname="Alice White")
+ user_create("bob", maindomain, "test123Ynh", fullname="Bob Snow")
+
+
+def teardown_function():
+ os.system("systemctl is-active slapd >/dev/null || systemctl start slapd; sleep 5")
+
+ for u in user_list()["users"]:
+ user_delete(u, purge=True)
def test_authenticate():
- LDAPAuth().authenticate_credentials(credentials="yunohost")
+ LDAPAuth().authenticate_credentials(credentials="alice:Yunohost")
+
+
+def test_authenticate_with_no_user():
+ with pytest.raises(MoulinetteError):
+ LDAPAuth().authenticate_credentials(credentials="Yunohost")
+
+ with pytest.raises(MoulinetteError):
+ LDAPAuth().authenticate_credentials(credentials=":Yunohost")
+
+
+def test_authenticate_with_user_who_is_not_admin():
+ with pytest.raises(MoulinetteError) as exception:
+ LDAPAuth().authenticate_credentials(credentials="bob:test123Ynh")
+
+ translation = m18n.n("invalid_credentials")
+ expected_msg = translation.format()
+ assert expected_msg in str(exception)
def test_authenticate_with_wrong_password():
with pytest.raises(MoulinetteError) as exception:
- LDAPAuth().authenticate_credentials(credentials="bad_password_lul")
+ LDAPAuth().authenticate_credentials(credentials="alice:bad_password_lul")
- translation = m18n.n("invalid_password")
+ translation = m18n.n("invalid_credentials")
expected_msg = translation.format()
assert expected_msg in str(exception)
def test_authenticate_server_down(mocker):
- os.system("systemctl stop slapd && sleep 3")
+ os.system("systemctl stop slapd && sleep 5")
- # Now if slapd is down, moulinette tries to restart it
- mocker.patch("os.system")
- mocker.patch("time.sleep")
- with pytest.raises(MoulinetteError) as exception:
- LDAPAuth().authenticate_credentials(credentials="yunohost")
-
- translation = m18n.n("ldap_server_down")
- expected_msg = translation.format()
- assert expected_msg in str(exception)
+ LDAPAuth().authenticate_credentials(credentials="alice:Yunohost")
def test_authenticate_change_password():
+ LDAPAuth().authenticate_credentials(credentials="alice:Yunohost")
- LDAPAuth().authenticate_credentials(credentials="yunohost")
-
- tools_adminpw("plopette", check_strength=False)
+ user_update("alice", change_password="plopette")
with pytest.raises(MoulinetteError) as exception:
- LDAPAuth().authenticate_credentials(credentials="yunohost")
+ LDAPAuth().authenticate_credentials(credentials="alice:Yunohost")
- translation = m18n.n("invalid_password")
+ translation = m18n.n("invalid_credentials")
expected_msg = translation.format()
assert expected_msg in str(exception)
- LDAPAuth().authenticate_credentials(credentials="plopette")
+ LDAPAuth().authenticate_credentials(credentials="alice:plopette")
diff --git a/src/tests/test_permission.py b/src/tests/test_permission.py
index 4e7f9f53d..10bd018d2 100644
--- a/src/tests/test_permission.py
+++ b/src/tests/test_permission.py
@@ -78,6 +78,7 @@ def _permission_create_with_dummy_app(
"name": app,
"id": app,
"description": {"en": "Dummy app to test permissions"},
+ "arguments": {"install": []},
},
f,
)
@@ -108,7 +109,7 @@ def clean_user_groups_permission():
user_delete(u)
for g in user_group_list()["groups"]:
- if g not in ["all_users", "visitors"]:
+ if g not in ["all_users", "visitors", "admins"]:
user_group_delete(g)
for p in user_permission_list()["permissions"]:
@@ -157,8 +158,8 @@ def setup_function(function):
socket.getaddrinfo = new_getaddrinfo
- user_create("alice", "Alice", "White", maindomain, dummy_password)
- user_create("bob", "Bob", "Snow", maindomain, dummy_password)
+ user_create("alice", maindomain, dummy_password, fullname="Alice White")
+ user_create("bob", maindomain, dummy_password, fullname="Bob Snow")
_permission_create_with_dummy_app(
permission="wiki.main",
url="/",
@@ -257,7 +258,7 @@ def check_LDAP_db_integrity():
for user in user_search:
user_dn = "uid=" + user["uid"][0] + ",ou=users,dc=yunohost,dc=org"
- group_list = [_ldap_path_extract(m, "cn") for m in user["memberOf"]]
+ group_list = [_ldap_path_extract(m, "cn") for m in user.get("memberOf", [])]
permission_list = [
_ldap_path_extract(m, "cn") for m in user.get("permission", [])
]
@@ -353,7 +354,6 @@ def check_permission_for_apps():
def can_access_webpage(webpath, logged_as=None):
-
webpath = webpath.rstrip("/")
sso_url = "https://" + maindomain + "/yunohost/sso/"
@@ -1093,7 +1093,6 @@ def test_permission_protection_management_by_helper():
@pytest.mark.other_domains(number=1)
def test_permission_app_propagation_on_ssowat():
-
app_install(
os.path.join(get_test_apps_dir(), "permissions_app_ynh"),
args="domain=%s&domain_2=%s&path=%s&is_public=1&admin=%s"
@@ -1130,7 +1129,6 @@ def test_permission_app_propagation_on_ssowat():
@pytest.mark.other_domains(number=1)
def test_permission_legacy_app_propagation_on_ssowat():
-
app_install(
os.path.join(get_test_apps_dir(), "legacy_app_ynh"),
args="domain=%s&domain_2=%s&path=%s&is_public=1"
diff --git a/src/tests/test_questions.py b/src/tests/test_questions.py
index 5917d32d4..e23be9925 100644
--- a/src/tests/test_questions.py
+++ b/src/tests/test_questions.py
@@ -1,20 +1,28 @@
+import inspect
import sys
import pytest
import os
+import tempfile
+from contextlib import contextmanager
from mock import patch
from io import StringIO
+from typing import Any, Literal, Sequence, TypedDict, Union
+
+from _pytest.mark.structures import ParameterSet
+
from moulinette import Moulinette
-
-from yunohost import domain, user
-from yunohost.utils.config import (
+from yunohost import app, domain, user
+from yunohost.utils.form import (
+ OPTIONS,
ask_questions_and_parse_answers,
- PasswordQuestion,
- DomainQuestion,
- PathQuestion,
- BooleanQuestion,
- FileQuestion,
+ DisplayTextOption,
+ PasswordOption,
+ DomainOption,
+ WebPathOption,
+ BooleanOption,
+ FileOption,
evaluate_simple_js_expression,
)
from yunohost.utils.error import YunohostError, YunohostValidationError
@@ -23,69 +31,1964 @@ from yunohost.utils.error import YunohostError, YunohostValidationError
"""
Argument default format:
{
- "name": "the_name",
- "type": "one_of_the_available_type", // "sting" is not specified
- "ask": {
- "en": "the question in english",
- "fr": "the question in french"
- },
- "help": {
- "en": "some help text in english",
- "fr": "some help text in french"
- },
- "example": "an example value", // optional
- "default", "some stuff", // optional, not available for all types
- "optional": true // optional, will skip if not answered
+ "the_name": {
+ "type": "one_of_the_available_type", // "sting" is not specified
+ "ask": {
+ "en": "the question in english",
+ "fr": "the question in french"
+ },
+ "help": {
+ "en": "some help text in english",
+ "fr": "some help text in french"
+ },
+ "example": "an example value", // optional
+ "default", "some stuff", // optional, not available for all types
+ "optional": true // optional, will skip if not answered
+ }
}
User answers:
-{"name": "value", ...}
+{"the_name": "value", ...}
"""
-def test_question_empty():
- ask_questions_and_parse_answers([], {}) == []
+# ╭───────────────────────────────────────────────────────╮
+# │ ┌─╮╭─┐╶┬╴╭─╴╷ ╷╶┬╴╭╮╷╭─╮ │
+# │ ├─╯├─┤ │ │ ├─┤ │ ││││╶╮ │
+# │ ╵ ╵ ╵ ╵ ╰─╴╵ ╵╶┴╴╵╰╯╰─╯ │
+# ╰───────────────────────────────────────────────────────╯
-def test_question_string():
- questions = [
- {
- "name": "some_string",
- "type": "string",
- }
+@contextmanager
+def patch_isatty(isatty):
+ with patch.object(os, "isatty", return_value=isatty):
+ yield
+
+
+@contextmanager
+def patch_interface(interface: Literal["api", "cli"] = "api"):
+ with patch.object(Moulinette.interface, "type", interface), patch_isatty(
+ interface == "cli"
+ ):
+ yield
+
+
+@contextmanager
+def patch_prompt(return_value):
+ with patch_interface("cli"), patch.object(
+ Moulinette, "prompt", return_value=return_value
+ ) as prompt:
+ yield prompt
+
+
+@pytest.fixture
+def patch_no_tty():
+ with patch_isatty(False):
+ yield
+
+
+@pytest.fixture
+def patch_with_tty():
+ with patch_isatty(True):
+ yield
+
+
+# ╭───────────────────────────────────────────────────────╮
+# │ ╭─╴╭─╴┌─╴╭╮╷╭─┐┌─╮╶┬╴╭─╮╭─╴ │
+# │ ╰─╮│ ├─╴│││├─┤├┬╯ │ │ │╰─╮ │
+# │ ╶─╯╰─╴╰─╴╵╰╯╵ ╵╵ ╰╶┴╴╰─╯╶─╯ │
+# ╰───────────────────────────────────────────────────────╯
+
+
+MinScenario = tuple[Any, Union[Literal["FAIL"], Any]]
+PartialScenario = tuple[Any, Union[Literal["FAIL"], Any], dict[str, Any]]
+FullScenario = tuple[Any, Union[Literal["FAIL"], Any], dict[str, Any], dict[str, Any]]
+
+Scenario = Union[
+ MinScenario,
+ PartialScenario,
+ FullScenario,
+ "InnerScenario",
+]
+
+
+class InnerScenario(TypedDict, total=False):
+ scenarios: Sequence[Scenario]
+ raw_options: Sequence[dict[str, Any]]
+ data: Sequence[dict[str, Any]]
+
+
+# ╭───────────────────────────────────────────────────────╮
+# │ Scenario generators/helpers │
+# ╰───────────────────────────────────────────────────────╯
+
+
+def get_hydrated_scenarios(raw_options, scenarios, data=[{}]):
+ """
+ Normalize and hydrate a mixed list of scenarios to proper tuple/pytest.param flattened list values.
+
+ Example::
+ scenarios = [
+ {
+ "raw_options": [{}, {"optional": True}],
+ "scenarios": [
+ ("", "value", {"default": "value"}),
+ *unchanged("value", "other"),
+ ]
+ },
+ *all_fails(-1, 0, 1, raw_options={"optional": True}),
+ *xfail(scenarios=[(True, "True"), (False, "False)], reason="..."),
+ ]
+ # Is exactly the same as
+ scenarios = [
+ ("", "value", {"default": "value"}),
+ ("", "value", {"optional": True, "default": "value"}),
+ ("value", "value", {}),
+ ("value", "value", {"optional": True}),
+ ("other", "other", {}),
+ ("other", "other", {"optional": True}),
+ (-1, FAIL, {"optional": True}),
+ (0, FAIL, {"optional": True}),
+ (1, FAIL, {"optional": True}),
+ pytest.param(True, "True", {}, marks=pytest.mark.xfail(reason="...")),
+ pytest.param(False, "False", {}, marks=pytest.mark.xfail(reason="...")),
+ ]
+ """
+ hydrated_scenarios = []
+ for raw_option in raw_options:
+ for mocked_data in data:
+ for scenario in scenarios:
+ if isinstance(scenario, dict):
+ merged_raw_options = [
+ {**raw_option, **raw_opt}
+ for raw_opt in scenario.get("raw_options", [{}])
+ ]
+ hydrated_scenarios += get_hydrated_scenarios(
+ merged_raw_options,
+ scenario["scenarios"],
+ scenario.get("data", [mocked_data]),
+ )
+ elif isinstance(scenario, ParameterSet):
+ intake, output, custom_raw_option = (
+ scenario.values
+ if len(scenario.values) == 3
+ else (*scenario.values, {})
+ )
+ merged_raw_option = {**raw_option, **custom_raw_option}
+ hydrated_scenarios.append(
+ pytest.param(
+ intake,
+ output,
+ merged_raw_option,
+ mocked_data,
+ marks=scenario.marks,
+ )
+ )
+ elif isinstance(scenario, tuple):
+ intake, output, custom_raw_option = (
+ scenario if len(scenario) == 3 else (*scenario, {})
+ )
+ merged_raw_option = {**raw_option, **custom_raw_option}
+ hydrated_scenarios.append(
+ (intake, output, merged_raw_option, mocked_data)
+ )
+ else:
+ raise Exception(
+ "Test scenario should be tuple(intake, output, raw_option), pytest.param(intake, output, raw_option) or dict(raw_options, scenarios, data)"
+ )
+
+ return hydrated_scenarios
+
+
+def generate_test_name(intake, output, raw_option, data):
+ values_as_str = []
+ for value in (intake, output):
+ if isinstance(value, str) and value != FAIL:
+ values_as_str.append(f"'{value}'")
+ elif inspect.isclass(value) and issubclass(value, Exception):
+ values_as_str.append(value.__name__)
+ else:
+ values_as_str.append(value)
+ name = f"{values_as_str[0]} -> {values_as_str[1]}"
+
+ keys = [
+ "=".join(
+ [
+ key,
+ str(raw_option[key])
+ if not isinstance(raw_option[key], str)
+ else f"'{raw_option[key]}'",
+ ]
+ )
+ for key in raw_option.keys()
+ if key not in ("id", "type")
]
- answers = {"some_string": "some_value"}
-
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_string"
- assert out.type == "string"
- assert out.value == "some_value"
+ if keys:
+ name += " (" + ",".join(keys) + ")"
+ return name
-def test_question_string_from_query_string():
+def pytest_generate_tests(metafunc):
+ """
+ Pytest test factory that, for each `BaseTest` subclasses, parametrize its
+ methods if it requires it by checking the method's parameters.
+ For those and based on their `cls.scenarios`, a series of `pytest.param` are
+ automaticly injected as test values.
+ """
+ if metafunc.cls and issubclass(metafunc.cls, BaseTest):
+ argnames = []
+ argvalues = []
+ ids = []
+ fn_params = inspect.signature(metafunc.function).parameters
- questions = [
- {
- "name": "some_string",
- "type": "string",
- }
+ for params in [
+ ["intake", "expected_output", "raw_option", "data"],
+ ["intake", "expected_normalized", "raw_option", "data"],
+ ["intake", "expected_humanized", "raw_option", "data"],
+ ]:
+ if all(param in fn_params for param in params):
+ argnames += params
+ if params[1] == "expected_output":
+ # Hydrate scenarios with generic raw_option data
+ argvalues += get_hydrated_scenarios(
+ [metafunc.cls.raw_option], metafunc.cls.scenarios
+ )
+ ids += [
+ generate_test_name(*args.values)
+ if isinstance(args, ParameterSet)
+ else generate_test_name(*args)
+ for args in argvalues
+ ]
+ elif params[1] == "expected_normalized":
+ argvalues += metafunc.cls.normalized
+ ids += [
+ f"{metafunc.cls.raw_option['type']}-normalize-{scenario[0]}"
+ for scenario in metafunc.cls.normalized
+ ]
+ elif params[1] == "expected_humanized":
+ argvalues += metafunc.cls.humanized
+ ids += [
+ f"{metafunc.cls.raw_option['type']}-normalize-{scenario[0]}"
+ for scenario in metafunc.cls.humanized
+ ]
+
+ metafunc.parametrize(argnames, argvalues, ids=ids)
+
+
+# ╭───────────────────────────────────────────────────────╮
+# │ Scenario helpers │
+# ╰───────────────────────────────────────────────────────╯
+
+FAIL = YunohostValidationError
+
+
+def nones(
+ *nones, output, raw_option: dict[str, Any] = {}, fail_if_required: bool = True
+) -> list[PartialScenario]:
+ """
+ Returns common scenarios for ~None values.
+ - required and required + as default -> `FAIL`
+ - optional and optional + as default -> `expected_output=None`
+ """
+ return [
+ (none, FAIL if fail_if_required else output, base_raw_option | raw_option) # type: ignore
+ for none in nones
+ for base_raw_option in ({}, {"default": none})
+ ] + [
+ (none, output, base_raw_option | raw_option)
+ for none in nones
+ for base_raw_option in ({"optional": True}, {"optional": True, "default": none})
]
- answers = "foo=bar&some_string=some_value&lorem=ipsum"
- out = ask_questions_and_parse_answers(questions, answers)[0]
- assert out.name == "some_string"
- assert out.type == "string"
- assert out.value == "some_value"
+def unchanged(*args, raw_option: dict[str, Any] = {}) -> list[PartialScenario]:
+ """
+ Returns a series of params for which output is expected to be the same as its intake
+
+ Example::
+ # expect `"value"` to output as `"value"`, etc.
+ unchanged("value", "yes", "none")
+
+ """
+ return [(arg, arg, raw_option.copy()) for arg in args]
+
+
+def all_as(*args, output, raw_option: dict[str, Any] = {}) -> list[PartialScenario]:
+ """
+ Returns a series of params for which output is expected to be the same single value
+
+ Example::
+ # expect all values to output as `True`
+ all_as("y", "yes", 1, True, output=True)
+ """
+ return [(arg, output, raw_option.copy()) for arg in args]
+
+
+def all_fails(
+ *args, raw_option: dict[str, Any] = {}, error=FAIL
+) -> list[PartialScenario]:
+ """
+ Returns a series of params for which output is expected to be failing with validation error
+ """
+ return [(arg, error, raw_option.copy()) for arg in args]
+
+
+def xpass(*, scenarios: list[Scenario], reason="unknown") -> list[Scenario]:
+ """
+ Return a pytest param for which test should have fail but currently passes.
+ """
+ return [
+ pytest.param(
+ *scenario,
+ marks=pytest.mark.xfail(
+ reason=f"Currently valid but probably shouldn't. details: {reason}."
+ ),
+ )
+ for scenario in scenarios
+ ]
+
+
+def xfail(*, scenarios: list[Scenario], reason="unknown") -> list[Scenario]:
+ """
+ Return a pytest param for which test should have passed but currently fails.
+ """
+ return [
+ pytest.param(
+ *scenario,
+ marks=pytest.mark.xfail(
+ reason=f"Currently invalid but should probably pass. details: {reason}."
+ ),
+ )
+ for scenario in scenarios
+ ]
+
+
+# ╭───────────────────────────────────────────────────────╮
+# │ ╶┬╴┌─╴╭─╴╶┬╴╭─╴ │
+# │ │ ├─╴╰─╮ │ ╰─╮ │
+# │ ╵ ╰─╴╶─╯ ╵ ╶─╯ │
+# ╰───────────────────────────────────────────────────────╯
+
+
+def _fill_or_prompt_one_option(raw_option, intake):
+ raw_option = raw_option.copy()
+ id_ = raw_option.pop("id")
+ options = {id_: raw_option}
+ answers = {id_: intake} if intake is not None else {}
+
+ option = ask_questions_and_parse_answers(options, answers)[0]
+
+ return (option, option.value)
+
+
+def _test_value_is_expected_output(value, expected_output):
+ """
+ Properly compares bools and None
+ """
+ if isinstance(expected_output, bool) or expected_output is None:
+ assert value is expected_output
+ else:
+ assert value == expected_output
+
+
+def _test_intake(raw_option, intake, expected_output):
+ option, value = _fill_or_prompt_one_option(raw_option, intake)
+
+ _test_value_is_expected_output(value, expected_output)
+
+
+def _test_intake_may_fail(raw_option, intake, expected_output):
+ if inspect.isclass(expected_output) and issubclass(expected_output, Exception):
+ with pytest.raises(expected_output):
+ _fill_or_prompt_one_option(raw_option, intake)
+ else:
+ _test_intake(raw_option, intake, expected_output)
+
+
+class BaseTest:
+ raw_option: dict[str, Any] = {}
+ prefill: dict[Literal["raw_option", "prefill", "intake"], Any]
+ scenarios: list[Scenario]
+
+ # fmt: off
+ # scenarios = [
+ # *all_fails(False, True, 0, 1, -1, 1337, 13.37, [], ["one"], {}, raw_option={"optional": True}),
+ # *all_fails("none", "_none", "False", "True", "0", "1", "-1", "1337", "13.37", "[]", ",", "['one']", "one,two", r"{}", "value", "value\n", raw_option={"optional": True}),
+ # *nones(None, "", output=""),
+ # ]
+ # fmt: on
+ # TODO
+ # - pattern (also on Date for example to see if it override the default pattern)
+ # - example
+ # - visible
+ # - redact
+ # - regex
+ # - hooks
+
+ @classmethod
+ def get_raw_option(cls, raw_option={}, **kwargs):
+ base_raw_option = cls.raw_option.copy()
+ base_raw_option.update(**raw_option)
+ base_raw_option.update(**kwargs)
+ return base_raw_option
+
+ @classmethod
+ def _test_basic_attrs(self):
+ raw_option = self.get_raw_option(optional=True)
+ id_ = raw_option["id"]
+ option, value = _fill_or_prompt_one_option(raw_option, None)
+
+ is_special_readonly_option = isinstance(option, DisplayTextOption)
+
+ assert isinstance(option, OPTIONS[raw_option["type"]])
+ assert option.type == raw_option["type"]
+ assert option.name == id_
+ assert option.ask == {"en": id_}
+ assert option.readonly is (True if is_special_readonly_option else False)
+ assert option.visible is None
+ # assert option.bind is None
+
+ if is_special_readonly_option:
+ assert value is None
+
+ return (raw_option, option, value)
+
+ @pytest.mark.usefixtures("patch_no_tty")
+ def test_basic_attrs(self):
+ """
+ Test basic options factories and BaseOption default attributes values.
+ """
+ # Intermediate method since pytest doesn't like tests that returns something.
+ # This allow a test class to call `_test_basic_attrs` then do additional checks
+ self._test_basic_attrs()
+
+ def test_options_prompted_with_ask_help(self, prefill_data=None):
+ """
+ Test that assert that moulinette prompt is called with:
+ - `message` with translated string and possible choices list
+ - help` with translated string
+ - `prefill` is the expected string value from a custom default
+ - `is_password` is true for `password`s only
+ - `is_multiline` is true for `text`s only
+ - `autocomplete` is option choices
+
+ Ran only once with `cls.prefill` data
+ """
+ if prefill_data is None:
+ prefill_data = self.prefill
+
+ base_raw_option = prefill_data["raw_option"]
+ prefill = prefill_data["prefill"]
+
+ with patch_prompt("") as prompt:
+ raw_option = self.get_raw_option(
+ raw_option=base_raw_option,
+ ask={"en": "Can i haz question?"},
+ help={"en": "Here's help!"},
+ )
+ option, value = _fill_or_prompt_one_option(raw_option, None)
+
+ expected_message = option.ask["en"]
+
+ if option.choices:
+ choices = (
+ option.choices
+ if isinstance(option.choices, list)
+ else option.choices.keys()
+ )
+ expected_message += f" [{' | '.join(choices)}]"
+ if option.type == "boolean":
+ expected_message += " [yes | no]"
+
+ prompt.assert_called_with(
+ message=expected_message,
+ is_password=option.type == "password",
+ confirm=False, # FIXME no confirm?
+ prefill=prefill,
+ is_multiline=option.type == "text",
+ autocomplete=option.choices or [],
+ help=option.help["en"],
+ )
+
+ def test_scenarios(self, intake, expected_output, raw_option, data):
+ with patch_interface("api"):
+ _test_intake_may_fail(
+ raw_option,
+ intake,
+ expected_output,
+ )
+
+
+# ╭───────────────────────────────────────────────────────╮
+# │ DISPLAY_TEXT │
+# ╰───────────────────────────────────────────────────────╯
+
+
+class TestDisplayText(BaseTest):
+ raw_option = {"type": "display_text", "id": "display_text_id"}
+ prefill = {
+ "raw_option": {},
+ "prefill": " custom default",
+ }
+ # fmt: off
+ scenarios = [
+ (None, None, {"ask": "Some text\na new line"}),
+ (None, None, {"ask": {"en": "Some text\na new line", "fr": "Un peu de texte\nune nouvelle ligne"}}),
+ ]
+ # fmt: on
+
+ def test_options_prompted_with_ask_help(self, prefill_data=None):
+ pytest.skip(reason="no prompt for display types")
+
+ def test_scenarios(self, intake, expected_output, raw_option, data):
+ _id = raw_option.pop("id")
+ answers = {_id: intake} if intake is not None else {}
+ options = None
+ with patch_interface("cli"):
+ if inspect.isclass(expected_output) and issubclass(
+ expected_output, Exception
+ ):
+ with pytest.raises(expected_output):
+ ask_questions_and_parse_answers({_id: raw_option}, answers)
+ else:
+ with patch.object(sys, "stdout", new_callable=StringIO) as stdout:
+ options = ask_questions_and_parse_answers(
+ {_id: raw_option}, answers
+ )
+ assert stdout.getvalue() == f"{options[0].ask['en']}\n"
+
+
+# ╭───────────────────────────────────────────────────────╮
+# │ MARKDOWN │
+# ╰───────────────────────────────────────────────────────╯
+
+
+class TestMarkdown(TestDisplayText):
+ raw_option = {"type": "markdown", "id": "markdown_id"}
+ # in cli this option is exactly the same as "display_text", no markdown support for now
+
+
+# ╭───────────────────────────────────────────────────────╮
+# │ ALERT │
+# ╰───────────────────────────────────────────────────────╯
+
+
+class TestAlert(TestDisplayText):
+ raw_option = {"type": "alert", "id": "alert_id"}
+ prefill = {
+ "raw_option": {"ask": " Custom info message"},
+ "prefill": " custom default",
+ }
+ # fmt: off
+ scenarios = [
+ (None, None, {"ask": "Some text\na new line"}),
+ (None, None, {"ask": {"en": "Some text\na new line", "fr": "Un peu de texte\nune nouvelle ligne"}}),
+ *[(None, None, {"ask": "question", "style": style}) for style in ("success", "info", "warning", "danger")],
+ *xpass(scenarios=[
+ (None, None, {"ask": "question", "style": "nimp"}),
+ ], reason="Should fail, wrong style"),
+ ]
+ # fmt: on
+
+ def test_scenarios(self, intake, expected_output, raw_option, data):
+ style = raw_option.get("style", "info")
+ colors = {"danger": "31", "warning": "33", "info": "36", "success": "32"}
+ answers = {"alert_id": intake} if intake is not None else {}
+
+ with patch_interface("cli"):
+ if inspect.isclass(expected_output) and issubclass(
+ expected_output, Exception
+ ):
+ with pytest.raises(expected_output):
+ ask_questions_and_parse_answers(
+ {"display_text_id": raw_option}, answers
+ )
+ else:
+ with patch.object(sys, "stdout", new_callable=StringIO) as stdout:
+ options = ask_questions_and_parse_answers(
+ {"display_text_id": raw_option}, answers
+ )
+ ask = options[0].ask["en"]
+ if style in colors:
+ color = colors[style]
+ title = style.title() + (":" if style != "success" else "!")
+ assert (
+ stdout.getvalue()
+ == f"\x1b[{color}m\x1b[1m{title}\x1b[m {ask}\n"
+ )
+ else:
+ # FIXME should fail
+ stdout.getvalue() == f"{ask}\n"
+
+
+# ╭───────────────────────────────────────────────────────╮
+# │ BUTTON │
+# ╰───────────────────────────────────────────────────────╯
+
+
+# TODO
+
+
+# ╭───────────────────────────────────────────────────────╮
+# │ STRING │
+# ╰───────────────────────────────────────────────────────╯
+
+
+class TestString(BaseTest):
+ raw_option = {"type": "string", "id": "string_id"}
+ prefill = {
+ "raw_option": {"default": " custom default"},
+ "prefill": " custom default",
+ }
+ # fmt: off
+ scenarios = [
+ *nones(None, "", output=""),
+ # basic typed values
+ *unchanged(False, True, 0, 1, -1, 1337, 13.37, [], ["one"], {}, raw_option={"optional": True}), # FIXME should output as str?
+ *unchanged("none", "_none", "False", "True", "0", "1", "-1", "1337", "13.37", "[]", ",", "['one']", "one,two", r"{}", "value", raw_option={"optional": True}),
+ *xpass(scenarios=[
+ ([], []),
+ ], reason="Should fail"),
+ # test strip
+ ("value", "value"),
+ ("value\n", "value"),
+ (" \n value\n", "value"),
+ (" \\n value\\n", "\\n value\\n"),
+ (" \tvalue\t", "value"),
+ (r" ##value \n \tvalue\n ", r"##value \n \tvalue\n"),
+ *xpass(scenarios=[
+ ("value\nvalue", "value\nvalue"),
+ (" ##value \n \tvalue\n ", "##value \n \tvalue"),
+ ], reason=r"should fail or without `\n`?"),
+ # readonly
+ *xfail(scenarios=[
+ ("overwrite", "expected value", {"readonly": True, "default": "expected value"}),
+ ], reason="Should not be overwritten"),
+ ]
+ # fmt: on
+
+
+# ╭───────────────────────────────────────────────────────╮
+# │ TEXT │
+# ╰───────────────────────────────────────────────────────╯
+
+
+class TestText(BaseTest):
+ raw_option = {"type": "text", "id": "text_id"}
+ prefill = {
+ "raw_option": {"default": "some value\nanother line "},
+ "prefill": "some value\nanother line ",
+ }
+ # fmt: off
+ scenarios = [
+ *nones(None, "", output=""),
+ # basic typed values
+ *unchanged(False, True, 0, 1, -1, 1337, 13.37, [], ["one"], {}, raw_option={"optional": True}), # FIXME should fail or output as str?
+ *unchanged("none", "_none", "False", "True", "0", "1", "-1", "1337", "13.37", "[]", ",", "['one']", "one,two", r"{}", "value", raw_option={"optional": True}),
+ *xpass(scenarios=[
+ ([], [])
+ ], reason="Should fail"),
+ ("value", "value"),
+ ("value\n value", "value\n value"),
+ # test no strip
+ *xpass(scenarios=[
+ ("value\n", "value"),
+ (" \n value\n", "value"),
+ (" \\n value\\n", "\\n value\\n"),
+ (" \tvalue\t", "value"),
+ (" ##value \n \tvalue\n ", "##value \n \tvalue"),
+ (r" ##value \n \tvalue\n ", r"##value \n \tvalue\n"),
+ ], reason="Should not be stripped"),
+ # readonly
+ *xfail(scenarios=[
+ ("overwrite", "expected value", {"readonly": True, "default": "expected value"}),
+ ], reason="Should not be overwritten"),
+ ]
+ # fmt: on
+
+
+# ╭───────────────────────────────────────────────────────╮
+# │ PASSWORD │
+# ╰───────────────────────────────────────────────────────╯
+
+
+class TestPassword(BaseTest):
+ raw_option = {"type": "password", "id": "password_id"}
+ prefill = {
+ "raw_option": {"default": None, "optional": True},
+ "prefill": "",
+ }
+ # fmt: off
+ scenarios = [
+ *all_fails(False, True, 0, 1, -1, 1337, 13.37, raw_option={"optional": True}, error=TypeError), # FIXME those fails with TypeError
+ *all_fails([], ["one"], {}, raw_option={"optional": True}, error=AttributeError), # FIXME those fails with AttributeError
+ *all_fails("none", "_none", "False", "True", "0", "1", "-1", "1337", "13.37", "[]", ",", "['one']", "one,two", r"{}", "value", "value\n", raw_option={"optional": True}),
+ *nones(None, "", output=""),
+ ("s3cr3t!!", FAIL, {"default": "SUPAs3cr3t!!"}), # default is forbidden
+ *xpass(scenarios=[
+ ("s3cr3t!!", "s3cr3t!!", {"example": "SUPAs3cr3t!!"}), # example is forbidden
+ ], reason="Should fail; example is forbidden"),
+ *xpass(scenarios=[
+ (" value \n moarc0mpl1cat3d\n ", "value \n moarc0mpl1cat3d"),
+ (" some_ value", "some_ value"),
+ ], reason="Should output exactly the same"),
+ ("s3cr3t!!", "s3cr3t!!"),
+ ("secret", FAIL),
+ *[("supersecret" + char, FAIL) for char in PasswordOption.forbidden_chars], # FIXME maybe add ` \n` to the list?
+ # readonly
+ *xpass(scenarios=[
+ ("s3cr3t!!", "s3cr3t!!", {"readonly": True}),
+ ], reason="Should fail since readonly is forbidden"),
+ ]
+ # fmt: on
+
+
+# ╭───────────────────────────────────────────────────────╮
+# │ COLOR │
+# ╰───────────────────────────────────────────────────────╯
+
+
+class TestColor(BaseTest):
+ raw_option = {"type": "color", "id": "color_id"}
+ prefill = {
+ "raw_option": {"default": "#ff0000"},
+ "prefill": "#ff0000",
+ # "intake": "#ff00ff",
+ }
+ # fmt: off
+ scenarios = [
+ *all_fails(False, True, 0, 1, -1, 1337, 13.37, [], ["one"], {}, raw_option={"optional": True}),
+ *all_fails("none", "_none", "False", "True", "0", "1", "-1", "1337", "13.37", "[]", ",", "['one']", "one,two", r"{}", "value", "value\n", raw_option={"optional": True}),
+ *nones(None, "", output=""),
+ # custom valid
+ ("#000000", "#000000"),
+ ("#000", "#000"),
+ ("#fe100", "#fe100"),
+ (" #fe100 ", "#fe100"),
+ ("#ABCDEF", "#ABCDEF"),
+ # custom fail
+ *xpass(scenarios=[
+ ("#feaf", "#feaf"),
+ ], reason="Should fail; not a legal color value"),
+ ("000000", FAIL),
+ ("#12", FAIL),
+ ("#gggggg", FAIL),
+ ("#01010101af", FAIL),
+ *xfail(scenarios=[
+ ("red", "#ff0000"),
+ ("yellow", "#ffff00"),
+ ], reason="Should work with pydantic"),
+ # readonly
+ *xfail(scenarios=[
+ ("#ffff00", "#fe100", {"readonly": True, "default": "#fe100"}),
+ ], reason="Should not be overwritten"),
+ ]
+ # fmt: on
+
+
+# ╭───────────────────────────────────────────────────────╮
+# │ NUMBER | RANGE │
+# ╰───────────────────────────────────────────────────────╯
+# Testing only number since "range" is only for webadmin (slider instead of classic intake).
+
+
+class TestNumber(BaseTest):
+ raw_option = {"type": "number", "id": "number_id"}
+ prefill = {
+ "raw_option": {"default": 10},
+ "prefill": "10",
+ }
+ # fmt: off
+ scenarios = [
+ *all_fails([], ["one"], {}),
+ *all_fails("none", "_none", "False", "True", "[]", ",", "['one']", "one,two", r"{}", "value"),
+
+ *nones(None, "", output=None),
+ *unchanged(0, 1, -1, 1337),
+ *xpass(scenarios=[(False, False)], reason="should fail or output as `0`"),
+ *xpass(scenarios=[(True, True)], reason="should fail or output as `1`"),
+ *all_as("0", 0, output=0),
+ *all_as("1", 1, output=1),
+ *all_as("1337", 1337, output=1337),
+ *xfail(scenarios=[
+ ("-1", -1)
+ ], reason="should output as `-1` instead of failing"),
+ *all_fails(13.37, "13.37"),
+
+ *unchanged(10, 5000, 10000, raw_option={"min": 10, "max": 10000}),
+ *all_fails(9, 10001, raw_option={"min": 10, "max": 10000}),
+
+ *all_as(None, "", output=0, raw_option={"default": 0}),
+ *all_as(None, "", output=0, raw_option={"default": 0, "optional": True}),
+ (-10, -10, {"default": 10}),
+ (-10, -10, {"default": 10, "optional": True}),
+ # readonly
+ *xfail(scenarios=[
+ (1337, 10000, {"readonly": True, "default": 10000}),
+ ], reason="Should not be overwritten"),
+ ]
+ # fmt: on
+ # FIXME should `step` be some kind of "multiple of"?
+
+
+# ╭───────────────────────────────────────────────────────╮
+# │ BOOLEAN │
+# ╰───────────────────────────────────────────────────────╯
+
+
+class TestBoolean(BaseTest):
+ raw_option = {"type": "boolean", "id": "boolean_id"}
+ prefill = {
+ "raw_option": {"default": True},
+ "prefill": "yes",
+ }
+ # fmt: off
+ truthy_values = (True, 1, "1", "True", "true", "Yes", "yes", "y", "on")
+ falsy_values = (False, 0, "0", "False", "false", "No", "no", "n", "off")
+ scenarios = [
+ *all_as(None, "", output=0),
+ *all_fails("none", "None"), # FIXME should output as `0` (default) like other none values when required?
+ *all_as(None, "", output=0, raw_option={"optional": True}), # FIXME should output as `None`?
+ *all_as("none", "None", output=None, raw_option={"optional": True}),
+ # FIXME even if default is explicity `None|""`, it ends up with class_default `0`
+ *all_as(None, "", output=0, raw_option={"default": None}), # FIXME this should fail, default is `None`
+ *all_as(None, "", output=0, raw_option={"optional": True, "default": None}), # FIXME even if default is explicity None, it ends up with class_default
+ *all_as(None, "", output=0, raw_option={"default": ""}), # FIXME this should fail, default is `""`
+ *all_as(None, "", output=0, raw_option={"optional": True, "default": ""}), # FIXME even if default is explicity None, it ends up with class_default
+ # With "none" behavior is ok
+ *all_fails(None, "", raw_option={"default": "none"}),
+ *all_as(None, "", output=None, raw_option={"optional": True, "default": "none"}),
+ # Unhandled types should fail
+ *all_fails(1337, "1337", "string", [], "[]", ",", "one,two"),
+ *all_fails(1337, "1337", "string", [], "[]", ",", "one,two", {"optional": True}),
+ # Required
+ *all_as(*truthy_values, output=1),
+ *all_as(*falsy_values, output=0),
+ # Optional
+ *all_as(*truthy_values, output=1, raw_option={"optional": True}),
+ *all_as(*falsy_values, output=0, raw_option={"optional": True}),
+ # test values as default, as required option without intake
+ *[(None, 1, {"default": true for true in truthy_values})],
+ *[(None, 0, {"default": false for false in falsy_values})],
+ # custom boolean output
+ ("", "disallow", {"yes": "allow", "no": "disallow"}), # required -> default to False -> `"disallow"`
+ ("n", "disallow", {"yes": "allow", "no": "disallow"}),
+ ("y", "allow", {"yes": "allow", "no": "disallow"}),
+ ("", False, {"yes": True, "no": False}), # required -> default to False -> `False`
+ ("n", False, {"yes": True, "no": False}),
+ ("y", True, {"yes": True, "no": False}),
+ ("", -1, {"yes": 1, "no": -1}), # required -> default to False -> `-1`
+ ("n", -1, {"yes": 1, "no": -1}),
+ ("y", 1, {"yes": 1, "no": -1}),
+ {
+ "raw_options": [
+ {"yes": "no", "no": "yes", "optional": True},
+ {"yes": False, "no": True, "optional": True},
+ {"yes": "0", "no": "1", "optional": True},
+ ],
+ # "no" for "yes" and "yes" for "no" should fail
+ "scenarios": all_fails("", "y", "n", error=AssertionError),
+ },
+ # readonly
+ *xfail(scenarios=[
+ (1, 0, {"readonly": True, "default": 0}),
+ ], reason="Should not be overwritten"),
+ ]
+
+
+# ╭───────────────────────────────────────────────────────╮
+# │ DATE │
+# ╰───────────────────────────────────────────────────────╯
+
+
+class TestDate(BaseTest):
+ raw_option = {"type": "date", "id": "date_id"}
+ prefill = {
+ "raw_option": {"default": "2024-12-29"},
+ "prefill": "2024-12-29",
+ }
+ # fmt: off
+ scenarios = [
+ *all_fails(False, True, 0, 1, -1, 1337, 13.37, [], ["one"], {}, raw_option={"optional": True}),
+ *all_fails("none", "_none", "False", "True", "0", "1", "-1", "1337", "13.37", "[]", ",", "['one']", "one,two", r"{}", "value", "value\n", raw_option={"optional": True}),
+ *nones(None, "", output=""),
+ # custom valid
+ ("2070-12-31", "2070-12-31"),
+ ("2024-02-29", "2024-02-29"),
+ *xfail(scenarios=[
+ ("2025-06-15T13:45:30", "2025-06-15"),
+ ("2025-06-15 13:45:30", "2025-06-15")
+ ], reason="iso date repr should be valid and extra data striped"),
+ *xfail(scenarios=[
+ (1749938400, "2025-06-15"),
+ (1749938400.0, "2025-06-15"),
+ ("1749938400", "2025-06-15"),
+ ("1749938400.0", "2025-06-15"),
+ ], reason="timestamp could be an accepted value"),
+ # custom invalid
+ ("29-12-2070", FAIL),
+ ("12-01-10", FAIL),
+ ("2022-02-29", FAIL),
+ # readonly
+ *xfail(scenarios=[
+ ("2070-12-31", "2024-02-29", {"readonly": True, "default": "2024-02-29"}),
+ ], reason="Should not be overwritten"),
+ ]
+ # fmt: on
+
+
+# ╭───────────────────────────────────────────────────────╮
+# │ TIME │
+# ╰───────────────────────────────────────────────────────╯
+
+
+class TestTime(BaseTest):
+ raw_option = {"type": "time", "id": "time_id"}
+ prefill = {
+ "raw_option": {"default": "12:26"},
+ "prefill": "12:26",
+ }
+ # fmt: off
+ scenarios = [
+ *all_fails(False, True, 0, 1, -1, 1337, 13.37, [], ["one"], {}, raw_option={"optional": True}),
+ *all_fails("none", "_none", "False", "True", "0", "1", "-1", "1337", "13.37", "[]", ",", "['one']", "one,two", r"{}", "value", "value\n", raw_option={"optional": True}),
+ *nones(None, "", output=""),
+ # custom valid
+ *unchanged("00:00", "08:00", "12:19", "20:59", "23:59"),
+ ("3:00", "3:00"), # FIXME should fail or output as `"03:00"`?
+ *xfail(scenarios=[
+ ("22:35:05", "22:35"),
+ ("22:35:03.514", "22:35"),
+ ], reason="time as iso format could be valid"),
+ # custom invalid
+ ("24:00", FAIL),
+ ("23:1", FAIL),
+ ("23:005", FAIL),
+ # readonly
+ *xfail(scenarios=[
+ ("00:00", "08:00", {"readonly": True, "default": "08:00"}),
+ ], reason="Should not be overwritten"),
+ ]
+ # fmt: on
+
+
+# ╭───────────────────────────────────────────────────────╮
+# │ EMAIL │
+# ╰───────────────────────────────────────────────────────╯
+
+
+class TestEmail(BaseTest):
+ raw_option = {"type": "email", "id": "email_id"}
+ prefill = {
+ "raw_option": {"default": "Abc@example.tld"},
+ "prefill": "Abc@example.tld",
+ }
+ # fmt: off
+ scenarios = [
+ *all_fails(False, True, 0, 1, 1337, 13.37, [], ["one"], {}, raw_option={"optional": True}),
+ *all_fails("none", "_none", "False", "True", "0", "1", "1337", "13.37", "[]", ",", "['one']", "one,two", r"{}", "value", "value\n", raw_option={"optional": True}),
+
+ *nones(None, "", output=""),
+ ("\n Abc@example.tld ", "Abc@example.tld"),
+ # readonly
+ *xfail(scenarios=[
+ ("Abc@example.tld", "admin@ynh.local", {"readonly": True, "default": "admin@ynh.local"}),
+ ], reason="Should not be overwritten"),
+
+ # Next examples are from https://github.com/JoshData/python-email-validator/blob/main/tests/test_syntax.py
+ # valid email values
+ ("Abc@example.tld", "Abc@example.tld"),
+ ("Abc.123@test-example.com", "Abc.123@test-example.com"),
+ ("user+mailbox/department=shipping@example.tld", "user+mailbox/department=shipping@example.tld"),
+ ("伊昭傑@郵件.商務", "伊昭傑@郵件.商務"),
+ ("राम@मोहन.ईन्फो", "राम@मोहन.ईन्फो"),
+ ("юзер@екзампл.ком", "юзер@екзампл.ком"),
+ ("θσερ@εχαμπλε.ψομ", "θσερ@εχαμπλε.ψομ"),
+ ("葉士豪@臺網中心.tw", "葉士豪@臺網中心.tw"),
+ ("jeff@臺網中心.tw", "jeff@臺網中心.tw"),
+ ("葉士豪@臺網中心.台灣", "葉士豪@臺網中心.台灣"),
+ ("jeff葉@臺網中心.tw", "jeff葉@臺網中心.tw"),
+ ("ñoñó@example.tld", "ñoñó@example.tld"),
+ ("甲斐黒川日本@example.tld", "甲斐黒川日本@example.tld"),
+ ("чебурашкаящик-с-апельсинами.рф@example.tld", "чебурашкаящик-с-апельсинами.рф@example.tld"),
+ ("उदाहरण.परीक्ष@domain.with.idn.tld", "उदाहरण.परीक्ष@domain.with.idn.tld"),
+ ("ιωάννης@εεττ.gr", "ιωάννης@εεττ.gr"),
+ # invalid email (Hiding because our current regex is very permissive)
+ # ("my@localhost", FAIL),
+ # ("my@.leadingdot.com", FAIL),
+ # ("my@.leadingfwdot.com", FAIL),
+ # ("my@twodots..com", FAIL),
+ # ("my@twofwdots...com", FAIL),
+ # ("my@trailingdot.com.", FAIL),
+ # ("my@trailingfwdot.com.", FAIL),
+ # ("me@-leadingdash", FAIL),
+ # ("me@-leadingdashfw", FAIL),
+ # ("me@trailingdash-", FAIL),
+ # ("me@trailingdashfw-", FAIL),
+ # ("my@baddash.-.com", FAIL),
+ # ("my@baddash.-a.com", FAIL),
+ # ("my@baddash.b-.com", FAIL),
+ # ("my@baddashfw.-.com", FAIL),
+ # ("my@baddashfw.-a.com", FAIL),
+ # ("my@baddashfw.b-.com", FAIL),
+ # ("my@example.com\n", FAIL),
+ # ("my@example\n.com", FAIL),
+ # ("me@x!", FAIL),
+ # ("me@x ", FAIL),
+ # (".leadingdot@domain.com", FAIL),
+ # ("twodots..here@domain.com", FAIL),
+ # ("trailingdot.@domain.email", FAIL),
+ # ("me@⒈wouldbeinvalid.com", FAIL),
+ ("@example.com", FAIL),
+ # ("\nmy@example.com", FAIL),
+ ("m\ny@example.com", FAIL),
+ ("my\n@example.com", FAIL),
+ # ("11111111112222222222333333333344444444445555555555666666666677777@example.com", FAIL),
+ # ("111111111122222222223333333333444444444455555555556666666666777777@example.com", FAIL),
+ # ("me@1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.111111111122222222223333333333444444444455555555556.com", FAIL),
+ # ("me@1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444444444555555555566.com", FAIL),
+ # ("me@中1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444444444555555555566.com", FAIL),
+ # ("my.long.address@1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.11111111112222222222333333333344444.info", FAIL),
+ # ("my.long.address@λ111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.11111111112222222222333333.info", FAIL),
+ # ("my.long.address@λ111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444.info", FAIL),
+ # ("my.λong.address@1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.111111111122222222223333333333444.info", FAIL),
+ # ("my.λong.address@1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444.info", FAIL),
+ # ("me@bad-tld-1", FAIL),
+ # ("me@bad.tld-2", FAIL),
+ # ("me@xn--0.tld", FAIL),
+ # ("me@yy--0.tld", FAIL),
+ # ("me@yy--0.tld", FAIL),
+ ]
+ # fmt: on
+
+
+# ╭───────────────────────────────────────────────────────╮
+# │ PATH │
+# ╰───────────────────────────────────────────────────────╯
+
+
+class TestWebPath(BaseTest):
+ raw_option = {"type": "path", "id": "path_id"}
+ prefill = {
+ "raw_option": {"default": "some_path"},
+ "prefill": "some_path",
+ }
+ # fmt: off
+ scenarios = [
+ *all_fails(False, True, 0, 1, -1, 1337, 13.37, [], ["one"], {}, raw_option={"optional": True}),
+
+ *nones(None, "", output=""),
+ # custom valid
+ ("/", "/"),
+ ("/one/two", "/one/two"),
+ *[
+ (v, "/" + v)
+ for v in ("none", "_none", "False", "True", "0", "1", "-1", "1337", "13.37", "[]", ",", "['one']", "one,two", r"{}", "value")
+ ],
+ ("value\n", "/value"),
+ ("//value", "/value"),
+ ("///value///", "/value"),
+ *xpass(scenarios=[
+ ("value\nvalue", "/value\nvalue"),
+ ("value value", "/value value"),
+ ("value//value", "/value//value"),
+ ], reason="Should fail"),
+ *xpass(scenarios=[
+ ("./here", "/./here"),
+ ("../here", "/../here"),
+ ("/somewhere/../here", "/somewhere/../here"),
+ ], reason="Should fail or flattened"),
+
+ *xpass(scenarios=[
+ ("/one?withquery=ah", "/one?withquery=ah"),
+ ], reason="Should fail or query string removed"),
+ *xpass(scenarios=[
+ ("https://example.com/folder", "/https://example.com/folder")
+ ], reason="Should fail or scheme+domain removed"),
+ # readonly
+ *xfail(scenarios=[
+ ("/overwrite", "/value", {"readonly": True, "default": "/value"}),
+ ], reason="Should not be overwritten"),
+ # FIXME should path have forbidden_chars?
+ ]
+ # fmt: on
+
+
+# ╭───────────────────────────────────────────────────────╮
+# │ URL │
+# ╰───────────────────────────────────────────────────────╯
+
+
+class TestUrl(BaseTest):
+ raw_option = {"type": "url", "id": "url_id"}
+ prefill = {
+ "raw_option": {"default": "https://domain.tld"},
+ "prefill": "https://domain.tld",
+ }
+ # fmt: off
+ scenarios = [
+ *all_fails(False, True, 0, 1, -1, 1337, 13.37, [], ["one"], {}, raw_option={"optional": True}),
+ *all_fails("none", "_none", "False", "True", "0", "1", "-1", "1337", "13.37", "[]", ",", "['one']", "one,two", r"{}", "value", "value\n", raw_option={"optional": True}),
+
+ *nones(None, "", output=""),
+ ("http://some.org/folder/file.txt", "http://some.org/folder/file.txt"),
+ # readonly
+ *xfail(scenarios=[
+ ("https://overwrite.org", "https://example.org", {"readonly": True, "default": "https://example.org"}),
+ ], reason="Should not be overwritten"),
+ # rest is taken from https://github.com/pydantic/pydantic/blob/main/tests/test_networks.py
+ # valid
+ *unchanged(
+ # Those are valid but not sure how they will output with pydantic
+ 'http://example.org',
+ 'http://test',
+ 'http://localhost',
+ 'https://example.org/whatever/next/',
+ 'https://example.org',
+ 'http://localhost',
+ 'http://localhost/',
+ 'http://localhost:8000',
+ 'http://localhost:8000/',
+ 'https://foo_bar.example.com/',
+ 'http://example.co.jp',
+ 'http://www.example.com/a%C2%B1b',
+ 'http://www.example.com/~username/',
+ 'http://info.example.com?fred',
+ 'http://info.example.com/?fred',
+ 'http://xn--mgbh0fb.xn--kgbechtv/',
+ 'http://example.com/blue/red%3Fand+green',
+ 'http://www.example.com/?array%5Bkey%5D=value',
+ 'http://xn--rsum-bpad.example.org/',
+ 'http://123.45.67.8/',
+ 'http://123.45.67.8:8329/',
+ 'http://[2001:db8::ff00:42]:8329',
+ 'http://[2001::1]:8329',
+ 'http://[2001:db8::1]/',
+ 'http://www.example.com:8000/foo',
+ 'http://www.cwi.nl:80/%7Eguido/Python.html',
+ 'https://www.python.org/путь',
+ 'http://андрей@example.com',
+ 'https://exam_ple.com/',
+ 'http://twitter.com/@handle/',
+ 'http://11.11.11.11.example.com/action',
+ 'http://abc.11.11.11.11.example.com/action',
+ 'http://example#',
+ 'http://example/#',
+ 'http://example/#fragment',
+ 'http://example/?#',
+ 'http://example.org/path#',
+ 'http://example.org/path#fragment',
+ 'http://example.org/path?query#',
+ 'http://example.org/path?query#fragment',
+ ),
+ # Pydantic default parsing add a final `/`
+ ('https://foo_bar.example.com/', 'https://foo_bar.example.com/'),
+ ('https://exam_ple.com/', 'https://exam_ple.com/'),
+ *xfail(scenarios=[
+ (' https://www.example.com \n', 'https://www.example.com/'),
+ ('HTTP://EXAMPLE.ORG', 'http://example.org/'),
+ ('https://example.org', 'https://example.org/'),
+ ('https://example.org?a=1&b=2', 'https://example.org/?a=1&b=2'),
+ ('https://example.org#a=3;b=3', 'https://example.org/#a=3;b=3'),
+ ('https://example.xn--p1ai', 'https://example.xn--p1ai/'),
+ ('https://example.xn--vermgensberatung-pwb', 'https://example.xn--vermgensberatung-pwb/'),
+ ('https://example.xn--zfr164b', 'https://example.xn--zfr164b/'),
+ ], reason="pydantic default behavior would append a final `/`"),
+
+ # invalid
+ *all_fails(
+ 'ftp://example.com/',
+ "$https://example.org",
+ "../icons/logo.gif",
+ "abc",
+ "..",
+ "/",
+ "+http://example.com/",
+ "ht*tp://example.com/",
+ ),
+ *xpass(scenarios=[
+ ("http:///", "http:///"),
+ ("http://??", "http://??"),
+ ("https://example.org more", "https://example.org more"),
+ ("http://2001:db8::ff00:42:8329", "http://2001:db8::ff00:42:8329"),
+ ("http://[192.168.1.1]:8329", "http://[192.168.1.1]:8329"),
+ ("http://example.com:99999", "http://example.com:99999"),
+ ], reason="Should fail"),
+ ]
+ # fmt: on
+
+
+# ╭───────────────────────────────────────────────────────╮
+# │ FILE │
+# ╰───────────────────────────────────────────────────────╯
+
+
+@pytest.fixture
+def file_clean():
+ FileOption.clean_upload_dirs()
+ yield
+ FileOption.clean_upload_dirs()
+
+
+@contextmanager
+def patch_file_cli(intake):
+ upload_dir = tempfile.mkdtemp(prefix="ynh_test_option_file")
+ _, filename = tempfile.mkstemp(dir=upload_dir)
+ with open(filename, "w") as f:
+ f.write(intake)
+
+ yield filename
+ os.system(f"rm -f {filename}")
+
+
+@contextmanager
+def patch_file_api(intake):
+ from base64 import b64encode
+
+ with patch_interface("api"):
+ yield b64encode(intake.encode())
+
+
+def _test_file_intake_may_fail(raw_option, intake, expected_output):
+ if inspect.isclass(expected_output) and issubclass(expected_output, Exception):
+ with pytest.raises(expected_output):
+ _fill_or_prompt_one_option(raw_option, intake)
+
+ option, value = _fill_or_prompt_one_option(raw_option, intake)
+
+ # The file is supposed to be copied somewhere else
+ assert value != intake
+ assert value.startswith("/tmp/ynh_filequestion_")
+ assert os.path.exists(value)
+ with open(value) as f:
+ assert f.read() == expected_output
+
+ FileOption.clean_upload_dirs()
+
+ assert not os.path.exists(value)
+
+
+file_content1 = "helloworld"
+file_content2 = """
+{
+ "testy": true,
+ "test": ["one"]
+}
+"""
+
+
+class TestFile(BaseTest):
+ raw_option = {"type": "file", "id": "file_id"}
+ # Prefill data is generated in `cls.test_options_prompted_with_ask_help`
+ # fmt: off
+ scenarios = [
+ *nones(None, "", output=""),
+ *unchanged(file_content1, file_content2),
+ # other type checks are done in `test_wrong_intake`
+ ]
+ # fmt: on
+ # TODO test readonly
+ # TODO test accept
+
+ @pytest.mark.usefixtures("patch_no_tty")
+ def test_basic_attrs(self):
+ raw_option, option, value = self._test_basic_attrs()
+
+ accept = raw_option.get("accept", "") # accept default
+ assert option.accept == accept
+
+ def test_options_prompted_with_ask_help(self):
+ with patch_file_cli(file_content1) as default_filename:
+ super().test_options_prompted_with_ask_help(
+ prefill_data={
+ "raw_option": {
+ "default": default_filename,
+ },
+ "prefill": default_filename,
+ }
+ )
+
+ @pytest.mark.usefixtures("file_clean")
+ def test_scenarios(self, intake, expected_output, raw_option, data):
+ if intake in (None, ""):
+ with patch_prompt(intake):
+ _test_intake_may_fail(raw_option, None, expected_output)
+ with patch_isatty(False):
+ _test_intake_may_fail(raw_option, intake, expected_output)
+ else:
+ with patch_file_cli(intake) as filename:
+ with patch_prompt(filename):
+ _test_file_intake_may_fail(raw_option, None, expected_output)
+ with patch_file_api(intake) as b64content:
+ with patch_isatty(False):
+ _test_file_intake_may_fail(raw_option, b64content, expected_output)
+
+ @pytest.mark.parametrize(
+ "path",
+ [
+ "/tmp/inexistant_file.txt",
+ "/tmp",
+ "/tmp/",
+ ],
+ )
+ def test_wrong_cli_filename(self, path):
+ with patch_prompt(path):
+ with pytest.raises(YunohostValidationError):
+ _fill_or_prompt_one_option(self.raw_option, None)
+
+ @pytest.mark.parametrize(
+ "intake",
+ [
+ # fmt: off
+ False, True, 0, 1, -1, 1337, 13.37, [], ["one"], {},
+ "none", "_none", "False", "True", "0", "1", "-1", "1337", "13.37", "[]", ",", "['one']", "one,two", r"{}", "value", "value\n"
+ # fmt: on
+ ],
+ )
+ def test_wrong_intake(self, intake):
+ with pytest.raises(YunohostValidationError):
+ with patch_prompt(intake):
+ _fill_or_prompt_one_option(self.raw_option, None)
+ with patch_isatty(False):
+ _fill_or_prompt_one_option(self.raw_option, intake)
+
+
+# ╭───────────────────────────────────────────────────────╮
+# │ SELECT │
+# ╰───────────────────────────────────────────────────────╯
+
+
+class TestSelect(BaseTest):
+ raw_option = {"type": "select", "id": "select_id"}
+ prefill = {
+ "raw_option": {"default": "one", "choices": ["one", "two"]},
+ "prefill": "one",
+ }
+ # fmt: off
+ scenarios = [
+ {
+ # ["one", "two"]
+ "raw_options": [
+ {"choices": ["one", "two"]},
+ {"choices": {"one": "verbose one", "two": "verbose two"}},
+ ],
+ "scenarios": [
+ *nones(None, "", output=""),
+ *unchanged("one", "two"),
+ ("three", FAIL),
+ ]
+ },
+ # custom bash style list as choices (only strings for now)
+ ("one", "one", {"choices": "one,two"}),
+ {
+ # [-1, 0, 1]
+ "raw_options": [
+ {"choices": [-1, 0, 1, 10]},
+ {"choices": {-1: "verbose -one", 0: "verbose zero", 1: "verbose one", 10: "verbose ten"}},
+ ],
+ "scenarios": [
+ *nones(None, "", output=""),
+ *unchanged(-1, 0, 1, 10),
+ *xfail(scenarios=[
+ ("-1", -1),
+ ("0", 0),
+ ("1", 1),
+ ("10", 10),
+ ], reason="str -> int not handled"),
+ *all_fails("100", 100),
+ ]
+ },
+ # [True, False, None]
+ *unchanged(True, False, raw_option={"choices": [True, False, None]}), # FIXME we should probably forbid None in choices
+ (None, FAIL, {"choices": [True, False, None]}),
+ {
+ # mixed types
+ "raw_options": [{"choices": ["one", 2, True]}],
+ "scenarios": [
+ *xpass(scenarios=[
+ ("one", "one"),
+ (2, 2),
+ (True, True),
+ ], reason="mixed choices, should fail"),
+ *all_fails("2", "True", "y"),
+ ]
+ },
+ {
+ "raw_options": [{"choices": ""}, {"choices": []}],
+ "scenarios": [
+ # FIXME those should fail at option level (wrong default, dev error)
+ *all_fails(None, ""),
+ *xpass(scenarios=[
+ ("", "", {"optional": True}),
+ (None, "", {"optional": True}),
+ ], reason="empty choices, should fail at option instantiation"),
+ ]
+ },
+ # readonly
+ *xfail(scenarios=[
+ ("one", "two", {"readonly": True, "choices": ["one", "two"], "default": "two"}),
+ ], reason="Should not be overwritten"),
+ ]
+ # fmt: on
+
+
+# ╭───────────────────────────────────────────────────────╮
+# │ TAGS │
+# ╰───────────────────────────────────────────────────────╯
+
+
+class TestTags(BaseTest):
+ raw_option = {"type": "tags", "id": "tags_id"}
+ prefill = {
+ "raw_option": {"default": ["one", "two"]},
+ "prefill": "one,two",
+ }
+ # fmt: off
+ scenarios = [
+ *nones(None, [], "", output=""),
+ # FIXME `","` could be considered a none value which kinda already is since it fail when required
+ (",", FAIL),
+ *xpass(scenarios=[
+ (",", ",", {"optional": True})
+ ], reason="Should output as `''`? ie: None"),
+ {
+ "raw_options": [
+ {},
+ {"choices": ["one", "two"]}
+ ],
+ "scenarios": [
+ *unchanged("one", "one,two"),
+ (["one"], "one"),
+ (["one", "two"], "one,two"),
+ ]
+ },
+ ("three", FAIL, {"choices": ["one", "two"]}),
+ *unchanged("none", "_none", "False", "True", "0", "1", "-1", "1337", "13.37", "[]", "['one']", "one,two", r"{}", "value"),
+ (" value\n", "value"),
+ ([False, True, -1, 0, 1, 1337, 13.37, [], ["one"], {}], "False,True,-1,0,1,1337,13.37,[],['one'],{}"),
+ *(([t], str(t)) for t in (False, True, -1, 0, 1, 1337, 13.37, [], ["one"], {})),
+ # basic types (not in a list) should fail
+ *all_fails(True, False, -1, 0, 1, 1337, 13.37, {}),
+ # Mixed choices should fail
+ ([False, True, -1, 0, 1, 1337, 13.37, [], ["one"], {}], FAIL, {"choices": [False, True, -1, 0, 1, 1337, 13.37, [], ["one"], {}]}),
+ ("False,True,-1,0,1,1337,13.37,[],['one'],{}", FAIL, {"choices": [False, True, -1, 0, 1, 1337, 13.37, [], ["one"], {}]}),
+ *all_fails(*([t] for t in [False, True, -1, 0, 1, 1337, 13.37, [], ["one"], {}]), raw_option={"choices": [False, True, -1, 0, 1, 1337, 13.37, [], ["one"], {}]}),
+ *all_fails(*([str(t)] for t in [False, True, -1, 0, 1, 1337, 13.37, [], ["one"], {}]), raw_option={"choices": [False, True, -1, 0, 1, 1337, 13.37, [], ["one"], {}]}),
+ # readonly
+ *xfail(scenarios=[
+ ("one", "one,two", {"readonly": True, "choices": ["one", "two"], "default": "one,two"}),
+ ], reason="Should not be overwritten"),
+ ]
+ # fmt: on
+
+
+# ╭───────────────────────────────────────────────────────╮
+# │ DOMAIN │
+# ╰───────────────────────────────────────────────────────╯
+
+main_domain = "ynh.local"
+domains1 = ["ynh.local"]
+domains2 = ["another.org", "ynh.local", "yet.another.org"]
+
+
+@contextmanager
+def patch_domains(*, domains, main_domain):
+ """
+ Data mocking for DomainOption:
+ - yunohost.domain.domain_list
+ """
+ with patch.object(
+ domain,
+ "domain_list",
+ return_value={"domains": domains, "main": main_domain},
+ ), patch.object(domain, "_get_maindomain", return_value=main_domain):
+ yield
+
+
+class TestDomain(BaseTest):
+ raw_option = {"type": "domain", "id": "domain_id"}
+ prefill = {
+ "raw_option": {
+ "default": None,
+ },
+ "prefill": main_domain,
+ }
+ # fmt: off
+ scenarios = [
+ # Probably not needed to test common types since those are not available as choices
+ # Also no scenarios with no domains since it should not be possible
+ {
+ "data": [{"main_domain": domains1[0], "domains": domains1}],
+ "scenarios": [
+ *nones(None, "", output=domains1[0], fail_if_required=False),
+ (domains1[0], domains1[0], {}),
+ ("doesnt_exist.pouet", FAIL, {}),
+ ("fake.com", FAIL, {"choices": ["fake.com"]}),
+ # readonly
+ *xpass(scenarios=[
+ (domains1[0], domains1[0], {"readonly": True}),
+ ], reason="Should fail since readonly is forbidden"),
+ ]
+ },
+ {
+ "data": [{"main_domain": domains2[1], "domains": domains2}],
+ "scenarios": [
+ *nones(None, "", output=domains2[1], fail_if_required=False),
+ (domains2[1], domains2[1], {}),
+ (domains2[0], domains2[0], {}),
+ ("doesnt_exist.pouet", FAIL, {}),
+ ("fake.com", FAIL, {"choices": ["fake.com"]}),
+ ]
+ },
+
+ ]
+ # fmt: on
+
+ def test_options_prompted_with_ask_help(self, prefill_data=None):
+ with patch_domains(domains=[main_domain], main_domain=main_domain):
+ super().test_options_prompted_with_ask_help(prefill_data=prefill_data)
+
+ def test_scenarios(self, intake, expected_output, raw_option, data):
+ with patch_domains(**data):
+ super().test_scenarios(intake, expected_output, raw_option, data)
+
+
+# ╭───────────────────────────────────────────────────────╮
+# │ APP │
+# ╰───────────────────────────────────────────────────────╯
+
+installed_webapp = {
+ "is_webapp": True,
+ "is_default": True,
+ "label": "My webapp",
+ "id": "my_webapp",
+ "domain_path": "/ynh-dev",
+}
+installed_non_webapp = {
+ "is_webapp": False,
+ "is_default": False,
+ "label": "My non webapp",
+ "id": "my_non_webapp",
+}
+
+
+@contextmanager
+def patch_apps(*, apps):
+ """
+ Data mocking for AppOption:
+ - yunohost.app.app_list
+ """
+ with patch.object(app, "app_list", return_value={"apps": apps}):
+ yield
+
+
+class TestApp(BaseTest):
+ raw_option = {"type": "app", "id": "app_id"}
+ # fmt: off
+ scenarios = [
+ # Probably not needed to test common types since those are not available as choices
+ {
+ "data": [
+ {"apps": []},
+ {"apps": [installed_webapp]},
+ {"apps": [installed_webapp, installed_non_webapp]},
+ ],
+ "scenarios": [
+ # FIXME there are currently 3 different nones (`None`, `""` and `_none`), choose one?
+ *nones(None, output=None), # FIXME Should return chosen none?
+ *nones("", output=""), # FIXME Should return chosen none?
+ *xpass(scenarios=[
+ ("_none", "_none"),
+ ("_none", "_none", {"default": "_none"}),
+ ], reason="should fail; is required"),
+ *xpass(scenarios=[
+ ("_none", "_none", {"optional": True}),
+ ("_none", "_none", {"optional": True, "default": "_none"})
+ ], reason="Should output chosen none value"),
+ ("fake_app", FAIL),
+ ("fake_app", FAIL, {"choices": ["fake_app"]}),
+ ]
+ },
+ {
+ "data": [
+ {"apps": [installed_webapp]},
+ {"apps": [installed_webapp, installed_non_webapp]},
+ ],
+ "scenarios": [
+ (installed_webapp["id"], installed_webapp["id"]),
+ (installed_webapp["id"], installed_webapp["id"], {"filter": "is_webapp"}),
+ (installed_webapp["id"], FAIL, {"filter": "is_webapp == false"}),
+ (installed_webapp["id"], FAIL, {"filter": "id != 'my_webapp'"}),
+ (None, None, {"filter": "id == 'fake_app'", "optional": True}),
+ ]
+ },
+ {
+ "data": [{"apps": [installed_webapp, installed_non_webapp]}],
+ "scenarios": [
+ (installed_non_webapp["id"], installed_non_webapp["id"]),
+ (installed_non_webapp["id"], FAIL, {"filter": "is_webapp"}),
+ # readonly
+ *xpass(scenarios=[
+ (installed_non_webapp["id"], installed_non_webapp["id"], {"readonly": True}),
+ ], reason="Should fail since readonly is forbidden"),
+ ]
+ },
+ ]
+ # fmt: on
+
+ @pytest.mark.usefixtures("patch_no_tty")
+ def test_basic_attrs(self):
+ with patch_apps(apps=[]):
+ raw_option, option, value = self._test_basic_attrs()
+
+ assert option.choices == {"_none": "---"}
+ assert option.filter is None
+
+ with patch_apps(apps=[installed_webapp, installed_non_webapp]):
+ raw_option, option, value = self._test_basic_attrs()
+
+ assert option.choices == {
+ "_none": "---",
+ "my_webapp": "My webapp (/ynh-dev)",
+ "my_non_webapp": "My non webapp (my_non_webapp)",
+ }
+ assert option.filter is None
+
+ def test_options_prompted_with_ask_help(self, prefill_data=None):
+ with patch_apps(apps=[installed_webapp, installed_non_webapp]):
+ super().test_options_prompted_with_ask_help(
+ prefill_data={
+ "raw_option": {"default": installed_webapp["id"]},
+ "prefill": installed_webapp["id"],
+ }
+ )
+ super().test_options_prompted_with_ask_help(
+ prefill_data={"raw_option": {"optional": True}, "prefill": ""}
+ )
+
+ def test_scenarios(self, intake, expected_output, raw_option, data):
+ with patch_apps(**data):
+ super().test_scenarios(intake, expected_output, raw_option, data)
+
+
+# ╭───────────────────────────────────────────────────────╮
+# │ USER │
+# ╰───────────────────────────────────────────────────────╯
+
+admin_username = "admin_user"
+admin_user = {
+ "ssh_allowed": False,
+ "username": admin_username,
+ "mailbox-quota": "0",
+ "mail": "a@ynh.local",
+ "mail-aliases": [f"root@{main_domain}"], # Faking "admin"
+ "fullname": "john doe",
+ "group": [],
+}
+regular_username = "normal_user"
+regular_user = {
+ "ssh_allowed": False,
+ "username": regular_username,
+ "mailbox-quota": "0",
+ "mail": "z@ynh.local",
+ "fullname": "john doe",
+ "group": [],
+}
+
+
+@contextmanager
+def patch_users(
+ *,
+ users,
+ admin_username,
+ main_domain,
+):
+ """
+ Data mocking for UserOption:
+ - yunohost.user.user_list
+ - yunohost.user.user_info
+ - yunohost.domain._get_maindomain
+ """
+ admin_info = next(
+ (user for user in users.values() if user["username"] == admin_username),
+ {"mail-aliases": []},
+ )
+ with patch.object(user, "user_list", return_value={"users": users}), patch.object(
+ user,
+ "user_info",
+ return_value=admin_info, # Faking admin user
+ ), patch.object(domain, "_get_maindomain", return_value=main_domain):
+ yield
+
+
+class TestUser(BaseTest):
+ raw_option = {"type": "user", "id": "user_id"}
+ # fmt: off
+ scenarios = [
+ # No tests for empty users since it should not happens
+ {
+ "data": [
+ {"users": {admin_username: admin_user}, "admin_username": admin_username, "main_domain": main_domain},
+ {"users": {admin_username: admin_user, regular_username: regular_user}, "admin_username": admin_username, "main_domain": main_domain},
+ ],
+ "scenarios": [
+ # FIXME User option is not really nullable, even if optional
+ *nones(None, "", output=admin_username, fail_if_required=False),
+ ("fake_user", FAIL),
+ ("fake_user", FAIL, {"choices": ["fake_user"]}),
+ ]
+ },
+ {
+ "data": [
+ {"users": {admin_username: admin_user, regular_username: regular_user}, "admin_username": admin_username, "main_domain": main_domain},
+ ],
+ "scenarios": [
+ *xpass(scenarios=[
+ ("", regular_username, {"default": regular_username})
+ ], reason="Should throw 'no default allowed'"),
+ # readonly
+ *xpass(scenarios=[
+ (admin_username, admin_username, {"readonly": True}),
+ ], reason="Should fail since readonly is forbidden"),
+ ]
+ },
+ ]
+ # fmt: on
+
+ @pytest.mark.usefixtures("patch_no_tty")
+ def test_basic_attrs(self):
+ with patch_users(
+ users={admin_username: admin_user},
+ admin_username=admin_username,
+ main_domain=main_domain,
+ ):
+ self._test_basic_attrs()
+
+ def test_options_prompted_with_ask_help(self, prefill_data=None):
+ with patch_users(
+ users={admin_username: admin_user, regular_username: regular_user},
+ admin_username=admin_username,
+ main_domain=main_domain,
+ ):
+ super().test_options_prompted_with_ask_help(
+ prefill_data={"raw_option": {}, "prefill": admin_username}
+ )
+ # FIXME This should fail, not allowed to set a default
+ super().test_options_prompted_with_ask_help(
+ prefill_data={
+ "raw_option": {"default": regular_username},
+ "prefill": regular_username,
+ }
+ )
+
+ def test_scenarios(self, intake, expected_output, raw_option, data):
+ with patch_users(**data):
+ super().test_scenarios(intake, expected_output, raw_option, data)
+
+
+# ╭───────────────────────────────────────────────────────╮
+# │ GROUP │
+# ╰───────────────────────────────────────────────────────╯
+
+groups1 = ["all_users", "visitors", "admins"]
+groups2 = ["all_users", "visitors", "admins", "custom_group"]
+
+
+@contextmanager
+def patch_groups(*, groups):
+ """
+ Data mocking for GroupOption:
+ - yunohost.user.user_group_list
+ """
+ with patch.object(user, "user_group_list", return_value={"groups": groups}):
+ yield
+
+
+class TestGroup(BaseTest):
+ raw_option = {"type": "group", "id": "group_id"}
+ # fmt: off
+ scenarios = [
+ # No tests for empty groups since it should not happens
+ {
+ "data": [
+ {"groups": groups1},
+ {"groups": groups2},
+ ],
+ "scenarios": [
+ # FIXME Group option is not really nullable, even if optional
+ *nones(None, "", output="all_users", fail_if_required=False),
+ ("admins", "admins"),
+ ("fake_group", FAIL),
+ ("fake_group", FAIL, {"choices": ["fake_group"]}),
+ ]
+ },
+ {
+ "data": [
+ {"groups": groups2},
+ ],
+ "scenarios": [
+ ("custom_group", "custom_group"),
+ *all_as("", None, output="visitors", raw_option={"default": "visitors"}),
+ *xpass(scenarios=[
+ ("", "custom_group", {"default": "custom_group"}),
+ ], reason="Should throw 'default must be in (None, 'all_users', 'visitors', 'admins')"),
+ # readonly
+ *xpass(scenarios=[
+ ("admins", "admins", {"readonly": True}),
+ ], reason="Should fail since readonly is forbidden"),
+ ]
+ },
+ ]
+ # fmt: on
+
+ def test_options_prompted_with_ask_help(self, prefill_data=None):
+ with patch_groups(groups=groups2):
+ super().test_options_prompted_with_ask_help(
+ prefill_data={"raw_option": {}, "prefill": "all_users"}
+ )
+ super().test_options_prompted_with_ask_help(
+ prefill_data={
+ "raw_option": {"default": "admins"},
+ "prefill": "admins",
+ }
+ )
+ # FIXME This should fail, not allowed to set a default which is not a default group
+ super().test_options_prompted_with_ask_help(
+ prefill_data={
+ "raw_option": {"default": "custom_group"},
+ "prefill": "custom_group",
+ }
+ )
+
+ def test_scenarios(self, intake, expected_output, raw_option, data):
+ with patch_groups(**data):
+ super().test_scenarios(intake, expected_output, raw_option, data)
+
+
+# ╭───────────────────────────────────────────────────────╮
+# │ MULTIPLE │
+# ╰───────────────────────────────────────────────────────╯
+
+
+@pytest.fixture
+def patch_entities():
+ with patch_domains(domains=domains2, main_domain=main_domain), patch_apps(
+ apps=[installed_webapp, installed_non_webapp]
+ ), patch_users(
+ users={admin_username: admin_user, regular_username: regular_user},
+ admin_username=admin_username,
+ main_domain=main_domain,
+ ), patch_groups(
+ groups=groups2
+ ):
+ yield
+
+
+def test_options_empty():
+ ask_questions_and_parse_answers({}, {}) == []
+
+
+@pytest.mark.usefixtures("patch_entities", "file_clean")
+def test_options_query_string():
+ raw_options = {
+ "string_id": {"type": "string"},
+ "text_id": {"type": "text"},
+ "password_id": {"type": "password"},
+ "color_id": {"type": "color"},
+ "number_id": {"type": "number"},
+ "boolean_id": {"type": "boolean"},
+ "date_id": {"type": "date"},
+ "time_id": {"type": "time"},
+ "email_id": {"type": "email"},
+ "path_id": {"type": "path"},
+ "url_id": {"type": "url"},
+ "file_id": {"type": "file"},
+ "select_id": {"type": "select", "choices": ["one", "two"]},
+ "tags_id": {"type": "tags", "choices": ["one", "two"]},
+ "domain_id": {"type": "domain"},
+ "app_id": {"type": "app"},
+ "user_id": {"type": "user"},
+ "group_id": {"type": "group"},
+ }
+
+ results = {
+ "string_id": "string",
+ "text_id": "text\ntext",
+ "password_id": "sUpRSCRT",
+ "color_id": "#ffff00",
+ "number_id": 10,
+ "boolean_id": 1,
+ "date_id": "2030-03-06",
+ "time_id": "20:55",
+ "email_id": "coucou@ynh.local",
+ "path_id": "/ynh-dev",
+ "url_id": "https://yunohost.org",
+ "file_id": file_content1,
+ "select_id": "one",
+ "tags_id": "one,two",
+ "domain_id": main_domain,
+ "app_id": installed_webapp["id"],
+ "user_id": regular_username,
+ "group_id": "admins",
+ }
+
+ @contextmanager
+ def patch_query_string(file_repr):
+ yield (
+ "string_id= string"
+ "&text_id=text\ntext"
+ "&password_id=sUpRSCRT"
+ "&color_id=#ffff00"
+ "&number_id=10"
+ "&boolean_id=y"
+ "&date_id=2030-03-06"
+ "&time_id=20:55"
+ "&email_id=coucou@ynh.local"
+ "&path_id=ynh-dev/"
+ "&url_id=https://yunohost.org"
+ f"&file_id={file_repr}"
+ "&select_id=one"
+ "&tags_id=one,two"
+ # FIXME We can't test with parse.qs for now, next syntax is available only with config panels
+ # "&tags_id=one"
+ # "&tags_id=two"
+ f"&domain_id={main_domain}"
+ f"&app_id={installed_webapp['id']}"
+ f"&user_id={regular_username}"
+ "&group_id=admins"
+ # not defined extra values are silently ignored
+ "&fake_id=fake_value"
+ )
+
+ def _assert_correct_values(options, raw_options):
+ form = {option.name: option.value for option in options}
+
+ for k, v in results.items():
+ if k == "file_id":
+ assert os.path.exists(form["file_id"]) and os.path.isfile(
+ form["file_id"]
+ )
+ with open(form["file_id"], "r") as f:
+ assert f.read() == file_content1
+ else:
+ assert form[k] == results[k]
+
+ assert len(options) == len(raw_options.keys())
+ assert "fake_id" not in form
+
+ with patch_interface("api"), patch_file_api(file_content1) as b64content:
+ with patch_query_string(b64content.decode("utf-8")) as query_string:
+ options = ask_questions_and_parse_answers(raw_options, query_string)
+ _assert_correct_values(options, raw_options)
+
+ with patch_interface("cli"), patch_file_cli(file_content1) as filepath:
+ with patch_query_string(filepath) as query_string:
+ options = ask_questions_and_parse_answers(raw_options, query_string)
+ _assert_correct_values(options, raw_options)
def test_question_string_default_type():
- questions = [
- {
- "name": "some_string",
- }
- ]
+ questions = {"some_string": {}}
answers = {"some_string": "some_value"}
out = ask_questions_and_parse_answers(questions, answers)[0]
@@ -95,210 +1998,16 @@ def test_question_string_default_type():
assert out.value == "some_value"
-def test_question_string_no_input():
- questions = [
- {
- "name": "some_string",
- }
- ]
- answers = {}
-
- with pytest.raises(YunohostError), patch.object(os, "isatty", return_value=False):
- ask_questions_and_parse_answers(questions, answers)
-
-
-def test_question_string_input():
- questions = [
- {
- "name": "some_string",
- "ask": "some question",
- }
- ]
- answers = {}
-
- with patch.object(Moulinette, "prompt", return_value="some_value"), patch.object(
- os, "isatty", return_value=True
- ):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_string"
- assert out.type == "string"
- assert out.value == "some_value"
-
-
-def test_question_string_input_no_ask():
- questions = [
- {
- "name": "some_string",
- }
- ]
- answers = {}
-
- with patch.object(Moulinette, "prompt", return_value="some_value"), patch.object(
- os, "isatty", return_value=True
- ):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_string"
- assert out.type == "string"
- assert out.value == "some_value"
-
-
-def test_question_string_no_input_optional():
- questions = [
- {
- "name": "some_string",
- "optional": True,
- }
- ]
- answers = {}
- with patch.object(os, "isatty", return_value=False):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_string"
- assert out.type == "string"
- assert out.value == ""
-
-
-def test_question_string_optional_with_input():
- questions = [
- {
- "name": "some_string",
- "ask": "some question",
- "optional": True,
- }
- ]
- answers = {}
-
- with patch.object(Moulinette, "prompt", return_value="some_value"), patch.object(
- os, "isatty", return_value=True
- ):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_string"
- assert out.type == "string"
- assert out.value == "some_value"
-
-
-def test_question_string_optional_with_empty_input():
- questions = [
- {
- "name": "some_string",
- "ask": "some question",
- "optional": True,
- }
- ]
- answers = {}
-
- with patch.object(Moulinette, "prompt", return_value=""), patch.object(
- os, "isatty", return_value=True
- ):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_string"
- assert out.type == "string"
- assert out.value == ""
-
-
-def test_question_string_optional_with_input_without_ask():
- questions = [
- {
- "name": "some_string",
- "optional": True,
- }
- ]
- answers = {}
-
- with patch.object(Moulinette, "prompt", return_value="some_value"), patch.object(
- os, "isatty", return_value=True
- ):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_string"
- assert out.type == "string"
- assert out.value == "some_value"
-
-
-def test_question_string_no_input_default():
- questions = [
- {
- "name": "some_string",
- "ask": "some question",
- "default": "some_value",
- }
- ]
- answers = {}
- with patch.object(os, "isatty", return_value=False):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_string"
- assert out.type == "string"
- assert out.value == "some_value"
-
-
-def test_question_string_input_test_ask():
- ask_text = "some question"
- questions = [
- {
- "name": "some_string",
- "ask": ask_text,
- }
- ]
- answers = {}
-
- with patch.object(
- Moulinette, "prompt", return_value="some_value"
- ) as prompt, patch.object(os, "isatty", return_value=True):
- ask_questions_and_parse_answers(questions, answers)
- prompt.assert_called_with(
- message=ask_text,
- is_password=False,
- confirm=False,
- prefill="",
- is_multiline=False,
- autocomplete=[],
- help=None,
- )
-
-
-def test_question_string_input_test_ask_with_default():
- ask_text = "some question"
- default_text = "some example"
- questions = [
- {
- "name": "some_string",
- "ask": ask_text,
- "default": default_text,
- }
- ]
- answers = {}
-
- with patch.object(
- Moulinette, "prompt", return_value="some_value"
- ) as prompt, patch.object(os, "isatty", return_value=True):
- ask_questions_and_parse_answers(questions, answers)
- prompt.assert_called_with(
- message=ask_text,
- is_password=False,
- confirm=False,
- prefill=default_text,
- is_multiline=False,
- autocomplete=[],
- help=None,
- )
-
-
@pytest.mark.skip # we should do something with this example
def test_question_string_input_test_ask_with_example():
ask_text = "some question"
example_text = "some example"
- questions = [
- {
- "name": "some_string",
+ questions = {
+ "some_string": {
"ask": ask_text,
"example": example_text,
}
- ]
+ }
answers = {}
with patch.object(
@@ -309,29 +2018,8 @@ def test_question_string_input_test_ask_with_example():
assert example_text in prompt.call_args[1]["message"]
-@pytest.mark.skip # we should do something with this help
-def test_question_string_input_test_ask_with_help():
- ask_text = "some question"
- help_text = "some_help"
- questions = [
- {
- "name": "some_string",
- "ask": ask_text,
- "help": help_text,
- }
- ]
- answers = {}
-
- with patch.object(
- Moulinette, "prompt", return_value="some_value"
- ) as prompt, patch.object(os, "isatty", return_value=True):
- ask_questions_and_parse_answers(questions, answers)
- assert ask_text in prompt.call_args[1]["message"]
- assert help_text in prompt.call_args[1]["message"]
-
-
def test_question_string_with_choice():
- questions = [{"name": "some_string", "type": "string", "choices": ["fr", "en"]}]
+ questions = {"some_string": {"type": "string", "choices": ["fr", "en"]}}
answers = {"some_string": "fr"}
out = ask_questions_and_parse_answers(questions, answers)[0]
@@ -341,7 +2029,7 @@ def test_question_string_with_choice():
def test_question_string_with_choice_prompt():
- questions = [{"name": "some_string", "type": "string", "choices": ["fr", "en"]}]
+ questions = {"some_string": {"type": "string", "choices": ["fr", "en"]}}
answers = {"some_string": "fr"}
with patch.object(Moulinette, "prompt", return_value="fr"), patch.object(
os, "isatty", return_value=True
@@ -354,7 +2042,7 @@ def test_question_string_with_choice_prompt():
def test_question_string_with_choice_bad():
- questions = [{"name": "some_string", "type": "string", "choices": ["fr", "en"]}]
+ questions = {"some_string": {"type": "string", "choices": ["fr", "en"]}}
answers = {"some_string": "bad"}
with pytest.raises(YunohostError), patch.object(os, "isatty", return_value=False):
@@ -364,13 +2052,12 @@ def test_question_string_with_choice_bad():
def test_question_string_with_choice_ask():
ask_text = "some question"
choices = ["fr", "en", "es", "it", "ru"]
- questions = [
- {
- "name": "some_string",
+ questions = {
+ "some_string": {
"ask": ask_text,
"choices": choices,
}
- ]
+ }
answers = {}
with patch.object(Moulinette, "prompt", return_value="ru") as prompt, patch.object(
@@ -384,14 +2071,13 @@ def test_question_string_with_choice_ask():
def test_question_string_with_choice_default():
- questions = [
- {
- "name": "some_string",
+ questions = {
+ "some_string": {
"type": "string",
"choices": ["fr", "en"],
"default": "en",
}
- ]
+ }
answers = {}
with patch.object(os, "isatty", return_value=False):
out = ask_questions_and_parse_answers(questions, answers)[0]
@@ -401,235 +2087,17 @@ def test_question_string_with_choice_default():
assert out.value == "en"
-def test_question_password():
- questions = [
- {
- "name": "some_password",
- "type": "password",
- }
- ]
- answers = {"some_password": "some_value"}
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_password"
- assert out.type == "password"
- assert out.value == "some_value"
-
-
-def test_question_password_no_input():
- questions = [
- {
- "name": "some_password",
- "type": "password",
- }
- ]
- answers = {}
-
- with pytest.raises(YunohostError), patch.object(os, "isatty", return_value=False):
- ask_questions_and_parse_answers(questions, answers)
-
-
-def test_question_password_input():
- questions = [
- {
- "name": "some_password",
- "type": "password",
- "ask": "some question",
- }
- ]
- answers = {}
-
- with patch.object(Moulinette, "prompt", return_value="some_value"), patch.object(
- os, "isatty", return_value=True
- ):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_password"
- assert out.type == "password"
- assert out.value == "some_value"
-
-
-def test_question_password_input_no_ask():
- questions = [
- {
- "name": "some_password",
- "type": "password",
- }
- ]
- answers = {}
-
- with patch.object(Moulinette, "prompt", return_value="some_value"), patch.object(
- os, "isatty", return_value=True
- ):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_password"
- assert out.type == "password"
- assert out.value == "some_value"
-
-
-def test_question_password_no_input_optional():
- questions = [
- {
- "name": "some_password",
- "type": "password",
- "optional": True,
- }
- ]
- answers = {}
-
- with patch.object(os, "isatty", return_value=False):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_password"
- assert out.type == "password"
- assert out.value == ""
-
- questions = [
- {"name": "some_password", "type": "password", "optional": True, "default": ""}
- ]
-
- with patch.object(os, "isatty", return_value=False):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_password"
- assert out.type == "password"
- assert out.value == ""
-
-
-def test_question_password_optional_with_input():
- questions = [
- {
- "name": "some_password",
- "ask": "some question",
- "type": "password",
- "optional": True,
- }
- ]
- answers = {}
-
- with patch.object(Moulinette, "prompt", return_value="some_value"), patch.object(
- os, "isatty", return_value=True
- ):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_password"
- assert out.type == "password"
- assert out.value == "some_value"
-
-
-def test_question_password_optional_with_empty_input():
- questions = [
- {
- "name": "some_password",
- "ask": "some question",
- "type": "password",
- "optional": True,
- }
- ]
- answers = {}
-
- with patch.object(Moulinette, "prompt", return_value=""), patch.object(
- os, "isatty", return_value=True
- ):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_password"
- assert out.type == "password"
- assert out.value == ""
-
-
-def test_question_password_optional_with_input_without_ask():
- questions = [
- {
- "name": "some_password",
- "type": "password",
- "optional": True,
- }
- ]
- answers = {}
-
- with patch.object(Moulinette, "prompt", return_value="some_value"), patch.object(
- os, "isatty", return_value=True
- ):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_password"
- assert out.type == "password"
- assert out.value == "some_value"
-
-
-def test_question_password_no_input_default():
- questions = [
- {
- "name": "some_password",
- "type": "password",
- "ask": "some question",
- "default": "some_value",
- }
- ]
- answers = {}
-
- # no default for password!
- with pytest.raises(YunohostError), patch.object(os, "isatty", return_value=False):
- ask_questions_and_parse_answers(questions, answers)
-
-
-@pytest.mark.skip # this should raises
-def test_question_password_no_input_example():
- questions = [
- {
- "name": "some_password",
- "type": "password",
- "ask": "some question",
- "example": "some_value",
- }
- ]
- answers = {"some_password": "some_value"}
-
- # no example for password!
- with pytest.raises(YunohostError), patch.object(os, "isatty", return_value=False):
- ask_questions_and_parse_answers(questions, answers)
-
-
-def test_question_password_input_test_ask():
- ask_text = "some question"
- questions = [
- {
- "name": "some_password",
- "type": "password",
- "ask": ask_text,
- }
- ]
- answers = {}
-
- with patch.object(
- Moulinette, "prompt", return_value="some_value"
- ) as prompt, patch.object(os, "isatty", return_value=True):
- ask_questions_and_parse_answers(questions, answers)
- prompt.assert_called_with(
- message=ask_text,
- is_password=True,
- confirm=False,
- prefill="",
- is_multiline=False,
- autocomplete=[],
- help=None,
- )
-
-
@pytest.mark.skip # we should do something with this example
def test_question_password_input_test_ask_with_example():
ask_text = "some question"
example_text = "some example"
- questions = [
- {
- "name": "some_password",
+ questions = {
+ "some_password": {
"type": "password",
"ask": ask_text,
"example": example_text,
}
- ]
+ }
answers = {}
with patch.object(
@@ -640,311 +2108,17 @@ def test_question_password_input_test_ask_with_example():
assert example_text in prompt.call_args[1]["message"]
-@pytest.mark.skip # we should do something with this help
-def test_question_password_input_test_ask_with_help():
- ask_text = "some question"
- help_text = "some_help"
- questions = [
- {
- "name": "some_password",
- "type": "password",
- "ask": ask_text,
- "help": help_text,
- }
- ]
- answers = {}
-
- with patch.object(
- Moulinette, "prompt", return_value="some_value"
- ) as prompt, patch.object(os, "isatty", return_value=True):
- ask_questions_and_parse_answers(questions, answers)
- assert ask_text in prompt.call_args[1]["message"]
- assert help_text in prompt.call_args[1]["message"]
-
-
-def test_question_password_bad_chars():
- questions = [
- {
- "name": "some_password",
- "type": "password",
- "ask": "some question",
- "example": "some_value",
- }
- ]
-
- for i in PasswordQuestion.forbidden_chars:
- with pytest.raises(YunohostError), patch.object(
- os, "isatty", return_value=False
- ):
- ask_questions_and_parse_answers(questions, {"some_password": i * 8})
-
-
-def test_question_password_strong_enough():
- questions = [
- {
- "name": "some_password",
- "type": "password",
- "ask": "some question",
- "example": "some_value",
- }
- ]
-
- with pytest.raises(YunohostError), patch.object(os, "isatty", return_value=False):
- # too short
- ask_questions_and_parse_answers(questions, {"some_password": "a"})
-
- with pytest.raises(YunohostError), patch.object(os, "isatty", return_value=False):
- ask_questions_and_parse_answers(questions, {"some_password": "password"})
-
-
-def test_question_password_optional_strong_enough():
- questions = [
- {
- "name": "some_password",
- "ask": "some question",
- "type": "password",
- "optional": True,
- }
- ]
-
- with pytest.raises(YunohostError), patch.object(os, "isatty", return_value=False):
- # too short
- ask_questions_and_parse_answers(questions, {"some_password": "a"})
-
- with pytest.raises(YunohostError), patch.object(os, "isatty", return_value=False):
- ask_questions_and_parse_answers(questions, {"some_password": "password"})
-
-
-def test_question_path():
- questions = [
- {
- "name": "some_path",
- "type": "path",
- }
- ]
- answers = {"some_path": "/some_value"}
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_path"
- assert out.type == "path"
- assert out.value == "/some_value"
-
-
-def test_question_path_no_input():
- questions = [
- {
- "name": "some_path",
- "type": "path",
- }
- ]
- answers = {}
-
- with pytest.raises(YunohostError), patch.object(os, "isatty", return_value=False):
- ask_questions_and_parse_answers(questions, answers)
-
-
-def test_question_path_input():
- questions = [
- {
- "name": "some_path",
- "type": "path",
- "ask": "some question",
- }
- ]
- answers = {}
-
- with patch.object(Moulinette, "prompt", return_value="/some_value"), patch.object(
- os, "isatty", return_value=True
- ):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_path"
- assert out.type == "path"
- assert out.value == "/some_value"
-
-
-def test_question_path_input_no_ask():
- questions = [
- {
- "name": "some_path",
- "type": "path",
- }
- ]
- answers = {}
-
- with patch.object(Moulinette, "prompt", return_value="/some_value"), patch.object(
- os, "isatty", return_value=True
- ):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_path"
- assert out.type == "path"
- assert out.value == "/some_value"
-
-
-def test_question_path_no_input_optional():
- questions = [
- {
- "name": "some_path",
- "type": "path",
- "optional": True,
- }
- ]
- answers = {}
- with patch.object(os, "isatty", return_value=False):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_path"
- assert out.type == "path"
- assert out.value == ""
-
-
-def test_question_path_optional_with_input():
- questions = [
- {
- "name": "some_path",
- "ask": "some question",
- "type": "path",
- "optional": True,
- }
- ]
- answers = {}
-
- with patch.object(Moulinette, "prompt", return_value="/some_value"), patch.object(
- os, "isatty", return_value=True
- ):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_path"
- assert out.type == "path"
- assert out.value == "/some_value"
-
-
-def test_question_path_optional_with_empty_input():
- questions = [
- {
- "name": "some_path",
- "ask": "some question",
- "type": "path",
- "optional": True,
- }
- ]
- answers = {}
-
- with patch.object(Moulinette, "prompt", return_value=""), patch.object(
- os, "isatty", return_value=True
- ):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_path"
- assert out.type == "path"
- assert out.value == ""
-
-
-def test_question_path_optional_with_input_without_ask():
- questions = [
- {
- "name": "some_path",
- "type": "path",
- "optional": True,
- }
- ]
- answers = {}
-
- with patch.object(Moulinette, "prompt", return_value="/some_value"), patch.object(
- os, "isatty", return_value=True
- ):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_path"
- assert out.type == "path"
- assert out.value == "/some_value"
-
-
-def test_question_path_no_input_default():
- questions = [
- {
- "name": "some_path",
- "ask": "some question",
- "type": "path",
- "default": "some_value",
- }
- ]
- answers = {}
- with patch.object(os, "isatty", return_value=False):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_path"
- assert out.type == "path"
- assert out.value == "/some_value"
-
-
-def test_question_path_input_test_ask():
- ask_text = "some question"
- questions = [
- {
- "name": "some_path",
- "type": "path",
- "ask": ask_text,
- }
- ]
- answers = {}
-
- with patch.object(
- Moulinette, "prompt", return_value="some_value"
- ) as prompt, patch.object(os, "isatty", return_value=True):
- ask_questions_and_parse_answers(questions, answers)
- prompt.assert_called_with(
- message=ask_text,
- is_password=False,
- confirm=False,
- prefill="",
- is_multiline=False,
- autocomplete=[],
- help=None,
- )
-
-
-def test_question_path_input_test_ask_with_default():
- ask_text = "some question"
- default_text = "someexample"
- questions = [
- {
- "name": "some_path",
- "type": "path",
- "ask": ask_text,
- "default": default_text,
- }
- ]
- answers = {}
-
- with patch.object(
- Moulinette, "prompt", return_value="some_value"
- ) as prompt, patch.object(os, "isatty", return_value=True):
- ask_questions_and_parse_answers(questions, answers)
- prompt.assert_called_with(
- message=ask_text,
- is_password=False,
- confirm=False,
- prefill=default_text,
- is_multiline=False,
- autocomplete=[],
- help=None,
- )
-
-
@pytest.mark.skip # we should do something with this example
def test_question_path_input_test_ask_with_example():
ask_text = "some question"
example_text = "some example"
- questions = [
- {
- "name": "some_path",
+ questions = {
+ "some_path": {
"type": "path",
"ask": ask_text,
"example": example_text,
}
- ]
+ }
answers = {}
with patch.object(
@@ -955,947 +2129,17 @@ def test_question_path_input_test_ask_with_example():
assert example_text in prompt.call_args[1]["message"]
-@pytest.mark.skip # we should do something with this help
-def test_question_path_input_test_ask_with_help():
- ask_text = "some question"
- help_text = "some_help"
- questions = [
- {
- "name": "some_path",
- "type": "path",
- "ask": ask_text,
- "help": help_text,
- }
- ]
- answers = {}
-
- with patch.object(
- Moulinette, "prompt", return_value="some_value"
- ) as prompt, patch.object(os, "isatty", return_value=True):
- ask_questions_and_parse_answers(questions, answers)
- assert ask_text in prompt.call_args[1]["message"]
- assert help_text in prompt.call_args[1]["message"]
-
-
-def test_question_boolean():
- questions = [
- {
- "name": "some_boolean",
- "type": "boolean",
- }
- ]
- answers = {"some_boolean": "y"}
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_boolean"
- assert out.type == "boolean"
- assert out.value == 1
-
-
-def test_question_boolean_all_yes():
- questions = [
- {
- "name": "some_boolean",
- "type": "boolean",
- }
- ]
-
- for value in ["Y", "yes", "Yes", "YES", "1", 1, True, "True", "TRUE", "true"]:
- out = ask_questions_and_parse_answers(questions, {"some_boolean": value})[0]
- assert out.name == "some_boolean"
- assert out.type == "boolean"
- assert out.value == 1
-
-
-def test_question_boolean_all_no():
- questions = [
- {
- "name": "some_boolean",
- "type": "boolean",
- }
- ]
-
- for value in ["n", "N", "no", "No", "No", "0", 0, False, "False", "FALSE", "false"]:
- out = ask_questions_and_parse_answers(questions, {"some_boolean": value})[0]
- assert out.name == "some_boolean"
- assert out.type == "boolean"
- assert out.value == 0
-
-
-# XXX apparently boolean are always False (0) by default, I'm not sure what to think about that
-def test_question_boolean_no_input():
- questions = [
- {
- "name": "some_boolean",
- "type": "boolean",
- }
- ]
- answers = {}
-
- with patch.object(os, "isatty", return_value=False):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.value == 0
-
-
-def test_question_boolean_bad_input():
- questions = [
- {
- "name": "some_boolean",
- "type": "boolean",
- }
- ]
- answers = {"some_boolean": "stuff"}
-
- with pytest.raises(YunohostError), patch.object(os, "isatty", return_value=False):
- ask_questions_and_parse_answers(questions, answers)
-
-
-def test_question_boolean_input():
- questions = [
- {
- "name": "some_boolean",
- "type": "boolean",
- "ask": "some question",
- }
- ]
- answers = {}
-
- with patch.object(Moulinette, "prompt", return_value="y"), patch.object(
- os, "isatty", return_value=True
- ):
- out = ask_questions_and_parse_answers(questions, answers)[0]
- assert out.value == 1
-
- with patch.object(Moulinette, "prompt", return_value="n"), patch.object(
- os, "isatty", return_value=True
- ):
- out = ask_questions_and_parse_answers(questions, answers)[0]
- assert out.value == 0
-
-
-def test_question_boolean_input_no_ask():
- questions = [
- {
- "name": "some_boolean",
- "type": "boolean",
- }
- ]
- answers = {}
-
- with patch.object(Moulinette, "prompt", return_value="y"), patch.object(
- os, "isatty", return_value=True
- ):
- out = ask_questions_and_parse_answers(questions, answers)[0]
- assert out.value == 1
-
-
-def test_question_boolean_no_input_optional():
- questions = [
- {
- "name": "some_boolean",
- "type": "boolean",
- "optional": True,
- }
- ]
- answers = {}
- with patch.object(os, "isatty", return_value=False):
- out = ask_questions_and_parse_answers(questions, answers)[0]
- assert out.value == 0
-
-
-def test_question_boolean_optional_with_input():
- questions = [
- {
- "name": "some_boolean",
- "ask": "some question",
- "type": "boolean",
- "optional": True,
- }
- ]
- answers = {}
-
- with patch.object(Moulinette, "prompt", return_value="y"), patch.object(
- os, "isatty", return_value=True
- ):
- out = ask_questions_and_parse_answers(questions, answers)[0]
- assert out.value == 1
-
-
-def test_question_boolean_optional_with_empty_input():
- questions = [
- {
- "name": "some_boolean",
- "ask": "some question",
- "type": "boolean",
- "optional": True,
- }
- ]
- answers = {}
-
- with patch.object(Moulinette, "prompt", return_value=""), patch.object(
- os, "isatty", return_value=True
- ):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.value == 0
-
-
-def test_question_boolean_optional_with_input_without_ask():
- questions = [
- {
- "name": "some_boolean",
- "type": "boolean",
- "optional": True,
- }
- ]
- answers = {}
-
- with patch.object(Moulinette, "prompt", return_value="n"), patch.object(
- os, "isatty", return_value=True
- ):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.value == 0
-
-
-def test_question_boolean_no_input_default():
- questions = [
- {
- "name": "some_boolean",
- "ask": "some question",
- "type": "boolean",
- "default": 0,
- }
- ]
- answers = {}
-
- with patch.object(os, "isatty", return_value=False):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.value == 0
-
-
-def test_question_boolean_bad_default():
- questions = [
- {
- "name": "some_boolean",
- "ask": "some question",
- "type": "boolean",
- "default": "bad default",
- }
- ]
- answers = {}
- with pytest.raises(YunohostError):
- ask_questions_and_parse_answers(questions, answers)
-
-
-def test_question_boolean_input_test_ask():
- ask_text = "some question"
- questions = [
- {
- "name": "some_boolean",
- "type": "boolean",
- "ask": ask_text,
- }
- ]
- answers = {}
-
- with patch.object(Moulinette, "prompt", return_value=0) as prompt, patch.object(
- os, "isatty", return_value=True
- ):
- ask_questions_and_parse_answers(questions, answers)
- prompt.assert_called_with(
- message=ask_text + " [yes | no]",
- is_password=False,
- confirm=False,
- prefill="no",
- is_multiline=False,
- autocomplete=[],
- help=None,
- )
-
-
-def test_question_boolean_input_test_ask_with_default():
- ask_text = "some question"
- default_text = 1
- questions = [
- {
- "name": "some_boolean",
- "type": "boolean",
- "ask": ask_text,
- "default": default_text,
- }
- ]
- answers = {}
-
- with patch.object(Moulinette, "prompt", return_value=1) as prompt, patch.object(
- os, "isatty", return_value=True
- ):
- ask_questions_and_parse_answers(questions, answers)
- prompt.assert_called_with(
- message=ask_text + " [yes | no]",
- is_password=False,
- confirm=False,
- prefill="yes",
- is_multiline=False,
- autocomplete=[],
- help=None,
- )
-
-
-def test_question_domain_empty():
- questions = [
- {
- "name": "some_domain",
- "type": "domain",
- }
- ]
- main_domain = "my_main_domain.com"
- answers = {}
-
- with patch.object(
- domain, "_get_maindomain", return_value="my_main_domain.com"
- ), patch.object(
- domain, "domain_list", return_value={"domains": [main_domain]}
- ), patch.object(
- os, "isatty", return_value=False
- ):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_domain"
- assert out.type == "domain"
- assert out.value == main_domain
-
-
-def test_question_domain():
- main_domain = "my_main_domain.com"
- domains = [main_domain]
- questions = [
- {
- "name": "some_domain",
- "type": "domain",
- }
- ]
-
- answers = {"some_domain": main_domain}
-
- with patch.object(
- domain, "_get_maindomain", return_value=main_domain
- ), patch.object(domain, "domain_list", return_value={"domains": domains}):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_domain"
- assert out.type == "domain"
- assert out.value == main_domain
-
-
-def test_question_domain_two_domains():
- main_domain = "my_main_domain.com"
- other_domain = "some_other_domain.tld"
- domains = [main_domain, other_domain]
-
- questions = [
- {
- "name": "some_domain",
- "type": "domain",
- }
- ]
- answers = {"some_domain": other_domain}
-
- with patch.object(
- domain, "_get_maindomain", return_value=main_domain
- ), patch.object(domain, "domain_list", return_value={"domains": domains}):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_domain"
- assert out.type == "domain"
- assert out.value == other_domain
-
- answers = {"some_domain": main_domain}
-
- with patch.object(
- domain, "_get_maindomain", return_value=main_domain
- ), patch.object(domain, "domain_list", return_value={"domains": domains}):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_domain"
- assert out.type == "domain"
- assert out.value == main_domain
-
-
-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]
-
- questions = [
- {
- "name": "some_domain",
- "type": "domain",
- }
- ]
- answers = {"some_domain": "doesnt_exist.pouet"}
-
- with patch.object(
- domain, "_get_maindomain", return_value=main_domain
- ), patch.object(domain, "domain_list", return_value={"domains": domains}):
- with pytest.raises(YunohostError), patch.object(
- os, "isatty", return_value=False
- ):
- ask_questions_and_parse_answers(questions, answers)
-
-
-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]
-
- questions = [
- {
- "name": "some_domain",
- "type": "domain",
- }
- ]
- answers = {}
-
- with patch.object(
- domain, "_get_maindomain", return_value=main_domain
- ), patch.object(
- domain, "domain_list", return_value={"domains": domains}
- ), patch.object(
- os, "isatty", return_value=False
- ):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_domain"
- assert out.type == "domain"
- assert out.value == main_domain
-
-
-def test_question_domain_two_domains_default():
- main_domain = "my_main_domain.com"
- other_domain = "some_other_domain.tld"
- domains = [main_domain, other_domain]
-
- questions = [{"name": "some_domain", "type": "domain", "ask": "choose a domain"}]
- answers = {}
-
- with patch.object(
- domain, "_get_maindomain", return_value=main_domain
- ), patch.object(
- domain, "domain_list", return_value={"domains": domains}
- ), patch.object(
- os, "isatty", return_value=False
- ):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_domain"
- assert out.type == "domain"
- assert out.value == main_domain
-
-
-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]
-
- questions = [{"name": "some_domain", "type": "domain", "ask": "choose a domain"}]
- answers = {}
-
- with patch.object(
- domain, "_get_maindomain", return_value=main_domain
- ), patch.object(
- domain, "domain_list", return_value={"domains": domains}
- ), patch.object(
- os, "isatty", return_value=True
- ):
- with patch.object(Moulinette, "prompt", return_value=main_domain):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_domain"
- assert out.type == "domain"
- assert out.value == main_domain
-
- with patch.object(Moulinette, "prompt", return_value=other_domain):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_domain"
- assert out.type == "domain"
- assert out.value == other_domain
-
-
-def test_question_user_empty():
- users = {
- "some_user": {
- "ssh_allowed": False,
- "username": "some_user",
- "mailbox-quota": "0",
- "mail": "p@ynh.local",
- "fullname": "the first name the last name",
- }
- }
-
- questions = [
- {
- "name": "some_user",
- "type": "user",
- }
- ]
- answers = {}
-
- with patch.object(user, "user_list", return_value={"users": users}):
- with pytest.raises(YunohostError), patch.object(
- os, "isatty", return_value=False
- ):
- ask_questions_and_parse_answers(questions, answers)
-
-
-def test_question_user():
- username = "some_user"
- users = {
- username: {
- "ssh_allowed": False,
- "username": "some_user",
- "mailbox-quota": "0",
- "mail": "p@ynh.local",
- "fullname": "the first name the last name",
- }
- }
-
- questions = [
- {
- "name": "some_user",
- "type": "user",
- }
- ]
- answers = {"some_user": username}
-
- with patch.object(user, "user_list", return_value={"users": users}), patch.object(
- user, "user_info", return_value={}
- ):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_user"
- assert out.type == "user"
- assert out.value == username
-
-
-def test_question_user_two_users():
- username = "some_user"
- other_user = "some_other_user"
- users = {
- username: {
- "ssh_allowed": False,
- "username": "some_user",
- "mailbox-quota": "0",
- "mail": "p@ynh.local",
- "fullname": "the first name the last name",
- },
- other_user: {
- "ssh_allowed": False,
- "username": "some_user",
- "mailbox-quota": "0",
- "mail": "z@ynh.local",
- "fullname": "john doe",
- },
- }
-
- questions = [
- {
- "name": "some_user",
- "type": "user",
- }
- ]
- answers = {"some_user": other_user}
-
- with patch.object(user, "user_list", return_value={"users": users}), patch.object(
- user, "user_info", return_value={}
- ):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_user"
- assert out.type == "user"
- assert out.value == other_user
-
- answers = {"some_user": username}
-
- with patch.object(user, "user_list", return_value={"users": users}), patch.object(
- user, "user_info", return_value={}
- ):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_user"
- assert out.type == "user"
- assert out.value == username
-
-
-def test_question_user_two_users_wrong_answer():
- username = "my_username.com"
- other_user = "some_other_user"
- users = {
- username: {
- "ssh_allowed": False,
- "username": "some_user",
- "mailbox-quota": "0",
- "mail": "p@ynh.local",
- "fullname": "the first name the last name",
- },
- other_user: {
- "ssh_allowed": False,
- "username": "some_user",
- "mailbox-quota": "0",
- "mail": "z@ynh.local",
- "fullname": "john doe",
- },
- }
-
- questions = [
- {
- "name": "some_user",
- "type": "user",
- }
- ]
- answers = {"some_user": "doesnt_exist.pouet"}
-
- with patch.object(user, "user_list", return_value={"users": users}):
- with pytest.raises(YunohostError), patch.object(
- os, "isatty", return_value=False
- ):
- ask_questions_and_parse_answers(questions, answers)
-
-
-def test_question_user_two_users_no_default():
- username = "my_username.com"
- other_user = "some_other_user.tld"
- users = {
- username: {
- "ssh_allowed": False,
- "username": "some_user",
- "mailbox-quota": "0",
- "mail": "p@ynh.local",
- "fullname": "the first name the last name",
- },
- other_user: {
- "ssh_allowed": False,
- "username": "some_user",
- "mailbox-quota": "0",
- "mail": "z@ynh.local",
- "fullname": "john doe",
- },
- }
-
- questions = [{"name": "some_user", "type": "user", "ask": "choose a user"}]
- answers = {}
-
- with patch.object(user, "user_list", return_value={"users": users}):
- with pytest.raises(YunohostError), patch.object(
- os, "isatty", return_value=False
- ):
- ask_questions_and_parse_answers(questions, answers)
-
-
-def test_question_user_two_users_default_input():
- username = "my_username.com"
- other_user = "some_other_user.tld"
- users = {
- username: {
- "ssh_allowed": False,
- "username": "some_user",
- "mailbox-quota": "0",
- "mail": "p@ynh.local",
- "fullname": "the first name the last name",
- },
- other_user: {
- "ssh_allowed": False,
- "username": "some_user",
- "mailbox-quota": "0",
- "mail": "z@ynh.local",
- "fullname": "john doe",
- },
- }
-
- questions = [{"name": "some_user", "type": "user", "ask": "choose a user"}]
- answers = {}
-
- 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={}):
-
- with patch.object(Moulinette, "prompt", return_value=username):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_user"
- assert out.type == "user"
- assert out.value == username
-
- with patch.object(Moulinette, "prompt", return_value=other_user):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_user"
- assert out.type == "user"
- assert out.value == other_user
-
-
-def test_question_number():
- questions = [
- {
- "name": "some_number",
- "type": "number",
- }
- ]
- answers = {"some_number": 1337}
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_number"
- assert out.type == "number"
- assert out.value == 1337
-
-
-def test_question_number_no_input():
- questions = [
- {
- "name": "some_number",
- "type": "number",
- }
- ]
- answers = {}
-
- with pytest.raises(YunohostError), patch.object(os, "isatty", return_value=False):
- ask_questions_and_parse_answers(questions, answers)
-
-
-def test_question_number_bad_input():
- questions = [
- {
- "name": "some_number",
- "type": "number",
- }
- ]
- answers = {"some_number": "stuff"}
-
- with pytest.raises(YunohostError), patch.object(os, "isatty", return_value=False):
- ask_questions_and_parse_answers(questions, answers)
-
- answers = {"some_number": 1.5}
- with pytest.raises(YunohostError), patch.object(os, "isatty", return_value=False):
- ask_questions_and_parse_answers(questions, answers)
-
-
-def test_question_number_input():
- questions = [
- {
- "name": "some_number",
- "type": "number",
- "ask": "some question",
- }
- ]
- answers = {}
-
- with patch.object(Moulinette, "prompt", return_value="1337"), patch.object(
- os, "isatty", return_value=True
- ):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_number"
- assert out.type == "number"
- assert out.value == 1337
-
- with patch.object(Moulinette, "prompt", return_value=1337), patch.object(
- os, "isatty", return_value=True
- ):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_number"
- assert out.type == "number"
- assert out.value == 1337
-
- with patch.object(Moulinette, "prompt", return_value="0"), patch.object(
- os, "isatty", return_value=True
- ):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_number"
- assert out.type == "number"
- assert out.value == 0
-
-
-def test_question_number_input_no_ask():
- questions = [
- {
- "name": "some_number",
- "type": "number",
- }
- ]
- answers = {}
-
- with patch.object(Moulinette, "prompt", return_value="1337"), patch.object(
- os, "isatty", return_value=True
- ):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_number"
- assert out.type == "number"
- assert out.value == 1337
-
-
-def test_question_number_no_input_optional():
- questions = [
- {
- "name": "some_number",
- "type": "number",
- "optional": True,
- }
- ]
- answers = {}
- with patch.object(os, "isatty", return_value=False):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_number"
- assert out.type == "number"
- assert out.value is None
-
-
-def test_question_number_optional_with_input():
- questions = [
- {
- "name": "some_number",
- "ask": "some question",
- "type": "number",
- "optional": True,
- }
- ]
- answers = {}
-
- with patch.object(Moulinette, "prompt", return_value="1337"), patch.object(
- os, "isatty", return_value=True
- ):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_number"
- assert out.type == "number"
- assert out.value == 1337
-
-
-def test_question_number_optional_with_input_without_ask():
- questions = [
- {
- "name": "some_number",
- "type": "number",
- "optional": True,
- }
- ]
- answers = {}
-
- with patch.object(Moulinette, "prompt", return_value="0"), patch.object(
- os, "isatty", return_value=True
- ):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_number"
- assert out.type == "number"
- assert out.value == 0
-
-
-def test_question_number_no_input_default():
- questions = [
- {
- "name": "some_number",
- "ask": "some question",
- "type": "number",
- "default": 1337,
- }
- ]
- answers = {}
- with patch.object(os, "isatty", return_value=False):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_number"
- assert out.type == "number"
- assert out.value == 1337
-
-
-def test_question_number_bad_default():
- questions = [
- {
- "name": "some_number",
- "ask": "some question",
- "type": "number",
- "default": "bad default",
- }
- ]
- answers = {}
- with pytest.raises(YunohostError), patch.object(os, "isatty", return_value=False):
- ask_questions_and_parse_answers(questions, answers)
-
-
-def test_question_number_input_test_ask():
- ask_text = "some question"
- questions = [
- {
- "name": "some_number",
- "type": "number",
- "ask": ask_text,
- }
- ]
- answers = {}
-
- with patch.object(
- Moulinette, "prompt", return_value="1111"
- ) as prompt, patch.object(os, "isatty", return_value=True):
- ask_questions_and_parse_answers(questions, answers)
- prompt.assert_called_with(
- message=ask_text,
- is_password=False,
- confirm=False,
- prefill="",
- is_multiline=False,
- autocomplete=[],
- help=None,
- )
-
-
-def test_question_number_input_test_ask_with_default():
- ask_text = "some question"
- default_value = 1337
- questions = [
- {
- "name": "some_number",
- "type": "number",
- "ask": ask_text,
- "default": default_value,
- }
- ]
- answers = {}
-
- with patch.object(
- Moulinette, "prompt", return_value="1111"
- ) as prompt, patch.object(os, "isatty", return_value=True):
- ask_questions_and_parse_answers(questions, answers)
- prompt.assert_called_with(
- message=ask_text,
- is_password=False,
- confirm=False,
- prefill=str(default_value),
- is_multiline=False,
- autocomplete=[],
- help=None,
- )
-
-
@pytest.mark.skip # we should do something with this example
def test_question_number_input_test_ask_with_example():
ask_text = "some question"
example_value = 1337
- questions = [
- {
- "name": "some_number",
+ questions = {
+ "some_number": {
"type": "number",
"ask": ask_text,
"example": example_value,
}
- ]
+ }
answers = {}
with patch.object(
@@ -1906,194 +2150,89 @@ def test_question_number_input_test_ask_with_example():
assert example_value in prompt.call_args[1]["message"]
-@pytest.mark.skip # we should do something with this help
-def test_question_number_input_test_ask_with_help():
- ask_text = "some question"
- help_value = 1337
- questions = [
- {
- "name": "some_number",
- "type": "number",
- "ask": ask_text,
- "help": help_value,
- }
- ]
- answers = {}
-
- with patch.object(
- Moulinette, "prompt", return_value="1111"
- ) as prompt, patch.object(os, "isatty", return_value=True):
- ask_questions_and_parse_answers(questions, answers)
- assert ask_text in prompt.call_args[1]["message"]
- assert help_value in prompt.call_args[1]["message"]
-
-
-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, patch.object(
- os, "isatty", return_value=True
- ):
- ask_questions_and_parse_answers(questions, answers)
- assert "foobar" in stdout.getvalue()
-
-
-def test_question_file_from_cli():
-
- FileQuestion.clean_upload_dirs()
-
- filename = "/tmp/ynh_test_question_file"
- os.system(f"rm -f {filename}")
- os.system(f"echo helloworld > {filename}")
-
- questions = [
- {
- "name": "some_file",
- "type": "file",
- }
- ]
- answers = {"some_file": filename}
-
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_file"
- assert out.type == "file"
-
- # The file is supposed to be copied somewhere else
- assert out.value != filename
- assert out.value.startswith("/tmp/")
- assert os.path.exists(out.value)
- assert "helloworld" in open(out.value).read().strip()
-
- FileQuestion.clean_upload_dirs()
-
- assert not os.path.exists(out.value)
-
-
-def test_question_file_from_api():
-
- FileQuestion.clean_upload_dirs()
-
- from base64 import b64encode
-
- b64content = b64encode(b"helloworld")
- questions = [
- {
- "name": "some_file",
- "type": "file",
- }
- ]
- answers = {"some_file": b64content}
-
- interface_type_bkp = Moulinette.interface.type
- try:
- Moulinette.interface.type = "api"
- out = ask_questions_and_parse_answers(questions, answers)[0]
- finally:
- Moulinette.interface.type = interface_type_bkp
-
- assert out.name == "some_file"
- assert out.type == "file"
-
- assert out.value.startswith("/tmp/")
- assert os.path.exists(out.value)
- assert "helloworld" in open(out.value).read().strip()
-
- FileQuestion.clean_upload_dirs()
-
- assert not os.path.exists(out.value)
-
-
def test_normalize_boolean_nominal():
+ assert BooleanOption.normalize("yes") == 1
+ assert BooleanOption.normalize("Yes") == 1
+ assert BooleanOption.normalize(" yes ") == 1
+ assert BooleanOption.normalize("y") == 1
+ assert BooleanOption.normalize("true") == 1
+ assert BooleanOption.normalize("True") == 1
+ assert BooleanOption.normalize("on") == 1
+ assert BooleanOption.normalize("1") == 1
+ assert BooleanOption.normalize(1) == 1
- assert BooleanQuestion.normalize("yes") == 1
- assert BooleanQuestion.normalize("Yes") == 1
- assert BooleanQuestion.normalize(" yes ") == 1
- assert BooleanQuestion.normalize("y") == 1
- assert BooleanQuestion.normalize("true") == 1
- assert BooleanQuestion.normalize("True") == 1
- assert BooleanQuestion.normalize("on") == 1
- assert BooleanQuestion.normalize("1") == 1
- assert BooleanQuestion.normalize(1) == 1
+ assert BooleanOption.normalize("no") == 0
+ assert BooleanOption.normalize("No") == 0
+ assert BooleanOption.normalize(" no ") == 0
+ assert BooleanOption.normalize("n") == 0
+ assert BooleanOption.normalize("false") == 0
+ assert BooleanOption.normalize("False") == 0
+ assert BooleanOption.normalize("off") == 0
+ assert BooleanOption.normalize("0") == 0
+ assert BooleanOption.normalize(0) == 0
- assert BooleanQuestion.normalize("no") == 0
- assert BooleanQuestion.normalize("No") == 0
- assert BooleanQuestion.normalize(" no ") == 0
- assert BooleanQuestion.normalize("n") == 0
- assert BooleanQuestion.normalize("false") == 0
- assert BooleanQuestion.normalize("False") == 0
- assert BooleanQuestion.normalize("off") == 0
- assert BooleanQuestion.normalize("0") == 0
- assert BooleanQuestion.normalize(0) == 0
-
- assert BooleanQuestion.normalize("") is None
- assert BooleanQuestion.normalize(" ") is None
- assert BooleanQuestion.normalize(" none ") is None
- assert BooleanQuestion.normalize("None") is None
- assert BooleanQuestion.normalize("noNe") is None
- assert BooleanQuestion.normalize(None) is None
+ assert BooleanOption.normalize("") is None
+ assert BooleanOption.normalize(" ") is None
+ assert BooleanOption.normalize(" none ") is None
+ assert BooleanOption.normalize("None") is None
+ assert BooleanOption.normalize("noNe") is None
+ assert BooleanOption.normalize(None) is None
def test_normalize_boolean_humanize():
+ assert BooleanOption.humanize("yes") == "yes"
+ assert BooleanOption.humanize("true") == "yes"
+ assert BooleanOption.humanize("on") == "yes"
- assert BooleanQuestion.humanize("yes") == "yes"
- assert BooleanQuestion.humanize("true") == "yes"
- assert BooleanQuestion.humanize("on") == "yes"
-
- assert BooleanQuestion.humanize("no") == "no"
- assert BooleanQuestion.humanize("false") == "no"
- assert BooleanQuestion.humanize("off") == "no"
+ assert BooleanOption.humanize("no") == "no"
+ assert BooleanOption.humanize("false") == "no"
+ assert BooleanOption.humanize("off") == "no"
def test_normalize_boolean_invalid():
-
with pytest.raises(YunohostValidationError):
- BooleanQuestion.normalize("yesno")
+ BooleanOption.normalize("yesno")
with pytest.raises(YunohostValidationError):
- BooleanQuestion.normalize("foobar")
+ BooleanOption.normalize("foobar")
with pytest.raises(YunohostValidationError):
- BooleanQuestion.normalize("enabled")
+ BooleanOption.normalize("enabled")
def test_normalize_boolean_special_yesno():
-
customyesno = {"yes": "enabled", "no": "disabled"}
- assert BooleanQuestion.normalize("yes", customyesno) == "enabled"
- assert BooleanQuestion.normalize("true", customyesno) == "enabled"
- assert BooleanQuestion.normalize("enabled", customyesno) == "enabled"
- assert BooleanQuestion.humanize("yes", customyesno) == "yes"
- assert BooleanQuestion.humanize("true", customyesno) == "yes"
- assert BooleanQuestion.humanize("enabled", customyesno) == "yes"
+ assert BooleanOption.normalize("yes", customyesno) == "enabled"
+ assert BooleanOption.normalize("true", customyesno) == "enabled"
+ assert BooleanOption.normalize("enabled", customyesno) == "enabled"
+ assert BooleanOption.humanize("yes", customyesno) == "yes"
+ assert BooleanOption.humanize("true", customyesno) == "yes"
+ assert BooleanOption.humanize("enabled", customyesno) == "yes"
- assert BooleanQuestion.normalize("no", customyesno) == "disabled"
- assert BooleanQuestion.normalize("false", customyesno) == "disabled"
- assert BooleanQuestion.normalize("disabled", customyesno) == "disabled"
- assert BooleanQuestion.humanize("no", customyesno) == "no"
- assert BooleanQuestion.humanize("false", customyesno) == "no"
- assert BooleanQuestion.humanize("disabled", customyesno) == "no"
+ assert BooleanOption.normalize("no", customyesno) == "disabled"
+ assert BooleanOption.normalize("false", customyesno) == "disabled"
+ assert BooleanOption.normalize("disabled", customyesno) == "disabled"
+ assert BooleanOption.humanize("no", customyesno) == "no"
+ assert BooleanOption.humanize("false", customyesno) == "no"
+ assert BooleanOption.humanize("disabled", customyesno) == "no"
def test_normalize_domain():
-
- assert DomainQuestion.normalize("https://yolo.swag/") == "yolo.swag"
- assert DomainQuestion.normalize("http://yolo.swag") == "yolo.swag"
- assert DomainQuestion.normalize("yolo.swag/") == "yolo.swag"
+ assert DomainOption.normalize("https://yolo.swag/") == "yolo.swag"
+ assert DomainOption.normalize("http://yolo.swag") == "yolo.swag"
+ assert DomainOption.normalize("yolo.swag/") == "yolo.swag"
def test_normalize_path():
-
- assert PathQuestion.normalize("") == "/"
- assert PathQuestion.normalize("") == "/"
- assert PathQuestion.normalize("macnuggets") == "/macnuggets"
- assert PathQuestion.normalize("/macnuggets") == "/macnuggets"
- assert PathQuestion.normalize(" /macnuggets ") == "/macnuggets"
- assert PathQuestion.normalize("/macnuggets") == "/macnuggets"
- assert PathQuestion.normalize("mac/nuggets") == "/mac/nuggets"
- assert PathQuestion.normalize("/macnuggets/") == "/macnuggets"
- assert PathQuestion.normalize("macnuggets/") == "/macnuggets"
- assert PathQuestion.normalize("////macnuggets///") == "/macnuggets"
+ assert WebPathOption.normalize("") == "/"
+ assert WebPathOption.normalize("") == "/"
+ assert WebPathOption.normalize("macnuggets") == "/macnuggets"
+ assert WebPathOption.normalize("/macnuggets") == "/macnuggets"
+ assert WebPathOption.normalize(" /macnuggets ") == "/macnuggets"
+ assert WebPathOption.normalize("/macnuggets") == "/macnuggets"
+ assert WebPathOption.normalize("mac/nuggets") == "/mac/nuggets"
+ assert WebPathOption.normalize("/macnuggets/") == "/macnuggets"
+ assert WebPathOption.normalize("macnuggets/") == "/macnuggets"
+ assert WebPathOption.normalize("////macnuggets///") == "/macnuggets"
def test_simple_evaluate():
diff --git a/src/tests/test_regenconf.py b/src/tests/test_regenconf.py
index f454f33e3..8dda1a7f2 100644
--- a/src/tests/test_regenconf.py
+++ b/src/tests/test_regenconf.py
@@ -16,19 +16,16 @@ SSHD_CONFIG = "/etc/ssh/sshd_config"
def setup_function(function):
-
_force_clear_hashes([TEST_DOMAIN_NGINX_CONFIG])
clean()
def teardown_function(function):
-
clean()
_force_clear_hashes([TEST_DOMAIN_NGINX_CONFIG])
def clean():
-
assert os.system("pgrep slapd >/dev/null") == 0
assert os.system("pgrep nginx >/dev/null") == 0
@@ -48,7 +45,6 @@ def clean():
def test_add_domain():
-
domain_add(TEST_DOMAIN)
assert TEST_DOMAIN in domain_list()["domains"]
@@ -60,7 +56,6 @@ def test_add_domain():
def test_add_and_edit_domain_conf():
-
domain_add(TEST_DOMAIN)
assert os.path.exists(TEST_DOMAIN_NGINX_CONFIG)
@@ -73,7 +68,6 @@ def test_add_and_edit_domain_conf():
def test_add_domain_conf_already_exists():
-
os.system("echo ' ' >> %s" % TEST_DOMAIN_NGINX_CONFIG)
domain_add(TEST_DOMAIN)
@@ -84,7 +78,6 @@ def test_add_domain_conf_already_exists():
def test_ssh_conf_unmanaged():
-
_force_clear_hashes([SSHD_CONFIG])
assert SSHD_CONFIG not in _get_conf_hashes("ssh")
@@ -95,7 +88,6 @@ def test_ssh_conf_unmanaged():
def test_ssh_conf_unmanaged_and_manually_modified(mocker):
-
_force_clear_hashes([SSHD_CONFIG])
os.system("echo ' ' >> %s" % SSHD_CONFIG)
diff --git a/src/tests/test_service.py b/src/tests/test_service.py
index 88013a3fe..84573fd89 100644
--- a/src/tests/test_service.py
+++ b/src/tests/test_service.py
@@ -14,17 +14,14 @@ from yunohost.service import (
def setup_function(function):
-
clean()
def teardown_function(function):
-
clean()
def clean():
-
# To run these tests, we assume ssh(d) service exists and is running
assert os.system("pgrep sshd >/dev/null") == 0
@@ -45,46 +42,39 @@ def clean():
def test_service_status_all():
-
status = service_status()
assert "ssh" in status.keys()
assert status["ssh"]["status"] == "running"
def test_service_status_single():
-
status = service_status("ssh")
assert "status" in status.keys()
assert status["status"] == "running"
def test_service_log():
-
logs = service_log("ssh")
assert "journalctl" in logs.keys()
assert "/var/log/auth.log" in logs.keys()
def test_service_status_unknown_service(mocker):
-
with raiseYunohostError(mocker, "service_unknown"):
service_status(["ssh", "doesnotexists"])
def test_service_add():
-
service_add("dummyservice", description="A dummy service to run tests")
assert "dummyservice" in service_status().keys()
def test_service_add_real_service():
-
service_add("networking")
assert "networking" in service_status().keys()
def test_service_remove():
-
service_add("dummyservice", description="A dummy service to run tests")
assert "dummyservice" in service_status().keys()
service_remove("dummyservice")
@@ -92,7 +82,6 @@ def test_service_remove():
def test_service_remove_service_that_doesnt_exists(mocker):
-
assert "dummyservice" not in service_status().keys()
with raiseYunohostError(mocker, "service_unknown"):
@@ -102,7 +91,6 @@ def test_service_remove_service_that_doesnt_exists(mocker):
def test_service_update_to_add_properties():
-
service_add("dummyservice", description="dummy")
assert not _get_services()["dummyservice"].get("test_status")
service_add("dummyservice", description="dummy", test_status="true")
@@ -110,7 +98,6 @@ def test_service_update_to_add_properties():
def test_service_update_to_change_properties():
-
service_add("dummyservice", description="dummy", test_status="false")
assert _get_services()["dummyservice"].get("test_status") == "false"
service_add("dummyservice", description="dummy", test_status="true")
@@ -118,7 +105,6 @@ def test_service_update_to_change_properties():
def test_service_update_to_remove_properties():
-
service_add("dummyservice", description="dummy", test_status="false")
assert _get_services()["dummyservice"].get("test_status") == "false"
service_add("dummyservice", description="dummy", test_status="")
@@ -126,7 +112,6 @@ def test_service_update_to_remove_properties():
def test_service_conf_broken():
-
os.system("echo pwet > /etc/nginx/conf.d/broken.conf")
status = service_status("nginx")
diff --git a/src/tests/test_settings.py b/src/tests/test_settings.py
index 1a9063e56..20f959a80 100644
--- a/src/tests/test_settings.py
+++ b/src/tests/test_settings.py
@@ -1,179 +1,229 @@
import os
-import json
-import glob
import pytest
+import yaml
+from mock import patch
-from yunohost.utils.error import YunohostError
-
-import yunohost.settings as settings
+import moulinette
+from yunohost.utils.error import YunohostError, YunohostValidationError
from yunohost.settings import (
settings_get,
settings_list,
- _get_settings,
settings_set,
settings_reset,
settings_reset_all,
- SETTINGS_PATH_OTHER_LOCATION,
SETTINGS_PATH,
- DEFAULTS,
)
-DEFAULTS["example.bool"] = {"type": "bool", "default": True}
-DEFAULTS["example.int"] = {"type": "int", "default": 42}
-DEFAULTS["example.string"] = {"type": "string", "default": "yolo swag"}
-DEFAULTS["example.enum"] = {"type": "enum", "default": "a", "choices": ["a", "b", "c"]}
+EXAMPLE_SETTINGS = """
+[example]
+ [example.example]
+ [example.example.boolean]
+ type = "boolean"
+ yes = "True"
+ no = "False"
+ default = "True"
+
+ [example.example.number]
+ type = "number"
+ default = 42
+
+ [example.example.string]
+ type = "string"
+ default = "yolo swag"
+
+ [example.example.select]
+ type = "select"
+ choices = ["a", "b", "c"]
+ default = "a"
+"""
def setup_function(function):
- os.system("mv /etc/yunohost/settings.json /etc/yunohost/settings.json.saved")
+ # Backup settings
+ if os.path.exists(SETTINGS_PATH):
+ os.system(f"mv {SETTINGS_PATH} {SETTINGS_PATH}.saved")
+ # Add example settings to config panel
+ os.system(
+ "cp /usr/share/yunohost/config_global.toml /usr/share/yunohost/config_global.toml.saved"
+ )
+ with open("/usr/share/yunohost/config_global.toml", "a") as file:
+ file.write(EXAMPLE_SETTINGS)
def teardown_function(function):
- os.system("mv /etc/yunohost/settings.json.saved /etc/yunohost/settings.json")
- for filename in glob.glob("/etc/yunohost/settings-*.json"):
- os.remove(filename)
+ if os.path.exists("/etc/yunohost/settings.yml.saved"):
+ os.system(f"mv {SETTINGS_PATH}.saved {SETTINGS_PATH}")
+ elif os.path.exists(SETTINGS_PATH):
+ os.remove(SETTINGS_PATH)
+ os.system(
+ "mv /usr/share/yunohost/config_global.toml.saved /usr/share/yunohost/config_global.toml"
+ )
-def monkey_get_setting_description(key):
- return "Dummy %s setting" % key.split(".")[-1]
+old_translate = moulinette.core.Translator.translate
-settings._get_setting_description = monkey_get_setting_description
+def _monkeypatch_translator(self, key, *args, **kwargs):
+ if key.startswith("global_settings_setting_"):
+ return f"Dummy translation for {key}"
+
+ return old_translate(self, key, *args, **kwargs)
+
+
+moulinette.core.Translator.translate = _monkeypatch_translator
+
+
+def _get_settings():
+ return yaml.load(open(SETTINGS_PATH, "r"))
def test_settings_get_bool():
- assert settings_get("example.bool")
+ assert settings_get("example.example.boolean")
-def test_settings_get_full_bool():
- assert settings_get("example.bool", True) == {
- "type": "bool",
- "value": True,
- "default": True,
- "description": "Dummy bool setting",
- }
+# FIXME : Testing this doesn't make sense ? This should be tested in test_config.py ?
+# def test_settings_get_full_bool():
+# assert settings_get("example.example.boolean", True) == {'version': '1.0',
+# 'i18n': 'global_settings_setting',
+# 'panels': [{'services': [],
+# 'actions': {'apply': {'en': 'Apply'}},
+# 'sections': [{'name': '',
+# 'services': [],
+# 'optional': True,
+# 'options': [{'type': 'boolean',
+# 'yes': 'True',
+# 'no': 'False',
+# 'default': 'True',
+# 'id': 'boolean',
+# 'name': 'boolean',
+# 'optional': True,
+# 'current_value': 'True',
+# 'ask': 'global_settings_setting_boolean',
+# 'choices': []}],
+# 'id': 'example'}],
+# 'id': 'example',
+# 'name': {'en': 'Example'}}]}
def test_settings_get_int():
- assert settings_get("example.int") == 42
+ assert settings_get("example.example.number") == 42
-def test_settings_get_full_int():
- assert settings_get("example.int", True) == {
- "type": "int",
- "value": 42,
- "default": 42,
- "description": "Dummy int setting",
- }
+# def test_settings_get_full_int():
+# assert settings_get("example.int", True) == {
+# "type": "int",
+# "value": 42,
+# "default": 42,
+# "description": "Dummy int setting",
+# }
def test_settings_get_string():
- assert settings_get("example.string") == "yolo swag"
+ assert settings_get("example.example.string") == "yolo swag"
-def test_settings_get_full_string():
- assert settings_get("example.string", True) == {
- "type": "string",
- "value": "yolo swag",
- "default": "yolo swag",
- "description": "Dummy string setting",
- }
+# def test_settings_get_full_string():
+# assert settings_get("example.example.string", True) == {
+# "type": "string",
+# "value": "yolo swag",
+# "default": "yolo swag",
+# "description": "Dummy string setting",
+# }
-def test_settings_get_enum():
- assert settings_get("example.enum") == "a"
+def test_settings_get_select():
+ assert settings_get("example.example.select") == "a"
-def test_settings_get_full_enum():
- assert settings_get("example.enum", True) == {
- "type": "enum",
- "value": "a",
- "default": "a",
- "description": "Dummy enum setting",
- "choices": ["a", "b", "c"],
- }
+# def test_settings_get_full_select():
+# option = settings_get("example.example.select", full=True).get('panels')[0].get('sections')[0].get('options')[0]
+# assert option.get('choices') == ["a", "b", "c"]
def test_settings_get_doesnt_exists():
- with pytest.raises(YunohostError):
+ with pytest.raises(YunohostValidationError):
settings_get("doesnt.exists")
-def test_settings_list():
- assert settings_list() == _get_settings()
+# def test_settings_list():
+# assert settings_list() == _get_settings()
def test_settings_set():
- settings_set("example.bool", False)
- assert settings_get("example.bool") is False
+ settings_set("example.example.boolean", False)
+ assert settings_get("example.example.boolean") == 0
- settings_set("example.bool", "on")
- assert settings_get("example.bool") is True
+ settings_set("example.example.boolean", "on")
+ assert settings_get("example.example.boolean") == 1
def test_settings_set_int():
- settings_set("example.int", 21)
- assert settings_get("example.int") == 21
+ settings_set("example.example.number", 21)
+ assert settings_get("example.example.number") == 21
-def test_settings_set_enum():
- settings_set("example.enum", "c")
- assert settings_get("example.enum") == "c"
+def test_settings_set_select():
+ settings_set("example.example.select", "c")
+ assert settings_get("example.example.select") == "c"
def test_settings_set_doesexit():
- with pytest.raises(YunohostError):
+ with pytest.raises(YunohostValidationError):
settings_set("doesnt.exist", True)
def test_settings_set_bad_type_bool():
- with pytest.raises(YunohostError):
- settings_set("example.bool", 42)
- with pytest.raises(YunohostError):
- settings_set("example.bool", "pouet")
+ with patch.object(os, "isatty", return_value=False):
+ with pytest.raises(YunohostError):
+ settings_set("example.example.boolean", 42)
+ with pytest.raises(YunohostError):
+ settings_set("example.example.boolean", "pouet")
def test_settings_set_bad_type_int():
- with pytest.raises(YunohostError):
- settings_set("example.int", True)
- with pytest.raises(YunohostError):
- settings_set("example.int", "pouet")
+ # with pytest.raises(YunohostError):
+ # settings_set("example.example.number", True)
+ with patch.object(os, "isatty", return_value=False):
+ with pytest.raises(YunohostError):
+ settings_set("example.example.number", "pouet")
-def test_settings_set_bad_type_string():
- with pytest.raises(YunohostError):
- settings_set("example.string", True)
- with pytest.raises(YunohostError):
- settings_set("example.string", 42)
+# def test_settings_set_bad_type_string():
+# with pytest.raises(YunohostError):
+# settings_set(eexample.example.string", True)
+# with pytest.raises(YunohostError):
+# settings_set("example.example.string", 42)
-def test_settings_set_bad_value_enum():
- with pytest.raises(YunohostError):
- settings_set("example.enum", True)
- with pytest.raises(YunohostError):
- settings_set("example.enum", "e")
- with pytest.raises(YunohostError):
- settings_set("example.enum", 42)
- with pytest.raises(YunohostError):
- settings_set("example.enum", "pouet")
+def test_settings_set_bad_value_select():
+ with patch.object(os, "isatty", return_value=False):
+ with pytest.raises(YunohostError):
+ settings_set("example.example.select", True)
+ with pytest.raises(YunohostError):
+ settings_set("example.example.select", "e")
+ with pytest.raises(YunohostError):
+ settings_set("example.example.select", 42)
+ with pytest.raises(YunohostError):
+ settings_set("example.example.select", "pouet")
def test_settings_list_modified():
- settings_set("example.int", 21)
- assert settings_list()["example.int"] == {
- "default": 42,
- "description": "Dummy int setting",
- "type": "int",
- "value": 21,
- }
+ settings_set("example.example.number", 21)
+ assert int(settings_list()["example.example.number"]["value"]) == 21
def test_reset():
- settings_set("example.int", 21)
- assert settings_get("example.int") == 21
- settings_reset("example.int")
- assert settings_get("example.int") == settings_get("example.int", True)["default"]
+ option = (
+ settings_get("example.example.number", full=True)
+ .get("panels")[0]
+ .get("sections")[0]
+ .get("options")[0]
+ )
+ settings_set("example.example.number", 21)
+ assert settings_get("example.example.number") == 21
+ settings_reset("example.example.number")
+ assert settings_get("example.example.number") == option["default"]
def test_settings_reset_doesexit():
@@ -183,10 +233,10 @@ def test_settings_reset_doesexit():
def test_reset_all():
settings_before = settings_list()
- settings_set("example.bool", False)
- settings_set("example.int", 21)
- settings_set("example.string", "pif paf pouf")
- settings_set("example.enum", "c")
+ settings_set("example.example.boolean", False)
+ settings_set("example.example.number", 21)
+ settings_set("example.example.string", "pif paf pouf")
+ settings_set("example.example.select", "c")
assert settings_before != settings_list()
settings_reset_all()
if settings_before != settings_list():
@@ -194,30 +244,30 @@ def test_reset_all():
assert settings_before[i] == settings_list()[i]
-def test_reset_all_backup():
- settings_before = settings_list()
- settings_set("example.bool", False)
- settings_set("example.int", 21)
- settings_set("example.string", "pif paf pouf")
- settings_set("example.enum", "c")
- settings_after_modification = settings_list()
- assert settings_before != settings_after_modification
- old_settings_backup_path = settings_reset_all()["old_settings_backup_path"]
-
- for i in settings_after_modification:
- del settings_after_modification[i]["description"]
-
- assert settings_after_modification == json.load(open(old_settings_backup_path, "r"))
+# def test_reset_all_backup():
+# settings_before = settings_list()
+# settings_set("example.bool", False)
+# settings_set("example.int", 21)
+# settings_set("example.string", "pif paf pouf")
+# settings_set("example.select", "c")
+# settings_after_modification = settings_list()
+# assert settings_before != settings_after_modification
+# old_settings_backup_path = settings_reset_all()["old_settings_backup_path"]
+#
+# for i in settings_after_modification:
+# del settings_after_modification[i]["description"]
+#
+# assert settings_after_modification == json.load(open(old_settings_backup_path, "r"))
-def test_unknown_keys():
- unknown_settings_path = SETTINGS_PATH_OTHER_LOCATION % "unknown"
- unknown_setting = {
- "unkown_key": {"value": 42, "default": 31, "type": "int"},
- }
- open(SETTINGS_PATH, "w").write(json.dumps(unknown_setting))
-
- # stimulate a write
- settings_reset_all()
-
- assert unknown_setting == json.load(open(unknown_settings_path, "r"))
+# def test_unknown_keys():
+# unknown_settings_path = SETTINGS_PATH_OTHER_LOCATION % "unknown"
+# unknown_setting = {
+# "unkown_key": {"value": 42, "default": 31, "type": "int"},
+# }
+# open(SETTINGS_PATH, "w").write(json.dumps(unknown_setting))
+#
+# # stimulate a write
+# settings_reset_all()
+#
+# assert unknown_setting == json.load(open(unknown_settings_path, "r"))
diff --git a/src/tests/test_user-group.py b/src/tests/test_user-group.py
index e561118e0..eececb827 100644
--- a/src/tests/test_user-group.py
+++ b/src/tests/test_user-group.py
@@ -11,7 +11,6 @@ from yunohost.user import (
user_import,
user_export,
FIELDS_FOR_IMPORT,
- FIRST_ALIASES,
user_group_list,
user_group_create,
user_group_delete,
@@ -29,7 +28,7 @@ def clean_user_groups():
user_delete(u, purge=True)
for g in user_group_list()["groups"]:
- if g not in ["all_users", "visitors"]:
+ if g not in ["all_users", "visitors", "admins"]:
user_group_delete(g)
@@ -39,9 +38,9 @@ def setup_function(function):
global maindomain
maindomain = _get_maindomain()
- user_create("alice", "Alice", "White", maindomain, "test123Ynh")
- user_create("bob", "Bob", "Snow", maindomain, "test123Ynh")
- user_create("jack", "Jack", "Black", maindomain, "test123Ynh")
+ user_create("alice", maindomain, "test123Ynh", admin=True, fullname="Alice White")
+ user_create("bob", maindomain, "test123Ynh", fullname="Bob Snow")
+ user_create("jack", maindomain, "test123Ynh", fullname="Jack Black")
user_group_create("dev")
user_group_create("apps")
@@ -80,6 +79,7 @@ def test_list_groups():
assert "alice" in res
assert "bob" in res
assert "jack" in res
+ assert "alice" in res["admins"]["members"]
for u in ["alice", "bob", "jack"]:
assert u in res
assert u in res[u]["members"]
@@ -92,9 +92,8 @@ def test_list_groups():
def test_create_user(mocker):
-
with message(mocker, "user_created"):
- user_create("albert", "Albert", "Good", maindomain, "test123Ynh")
+ user_create("albert", maindomain, "test123Ynh", fullname="Albert Good")
group_res = user_group_list()["groups"]
assert "albert" in user_list()["users"]
@@ -104,7 +103,6 @@ def test_create_user(mocker):
def test_del_user(mocker):
-
with message(mocker, "user_deleted"):
user_delete("alice")
@@ -175,10 +173,9 @@ def test_import_user(mocker):
def test_export_user(mocker):
result = user_export()
- aliases = ",".join([alias + maindomain for alias in FIRST_ALIASES])
should_be = (
"username;firstname;lastname;password;mail;mail-alias;mail-forward;mailbox-quota;groups\r\n"
- f"alice;Alice;White;;alice@{maindomain};{aliases};;0;dev\r\n"
+ f"alice;Alice;White;;alice@{maindomain};;;0;admins,dev\r\n"
f"bob;Bob;Snow;;bob@{maindomain};;;0;apps\r\n"
f"jack;Jack;Black;;jack@{maindomain};;;0;"
)
@@ -186,7 +183,6 @@ def test_export_user(mocker):
def test_create_group(mocker):
-
with message(mocker, "group_created", group="adminsys"):
user_group_create("adminsys")
@@ -197,7 +193,6 @@ def test_create_group(mocker):
def test_del_group(mocker):
-
with message(mocker, "group_deleted", group="dev"):
user_group_delete("dev")
@@ -212,17 +207,17 @@ def test_del_group(mocker):
def test_create_user_with_password_too_simple(mocker):
with raiseYunohostError(mocker, "password_listed"):
- user_create("other", "Alice", "White", maindomain, "12")
+ user_create("other", maindomain, "12", fullname="Alice White")
def test_create_user_already_exists(mocker):
with raiseYunohostError(mocker, "user_already_exists"):
- user_create("alice", "Alice", "White", maindomain, "test123Ynh")
+ user_create("alice", maindomain, "test123Ynh", fullname="Alice White")
def test_create_user_with_domain_that_doesnt_exists(mocker):
with raiseYunohostError(mocker, "domain_unknown"):
- user_create("alice", "Alice", "White", "doesnt.exists", "test123Ynh")
+ user_create("alice", "doesnt.exists", "test123Ynh", fullname="Alice White")
def test_update_user_with_mail_address_already_taken(mocker):
@@ -272,8 +267,13 @@ def test_update_user(mocker):
user_update("alice", firstname="NewName", lastname="NewLast")
info = user_info("alice")
- assert info["firstname"] == "NewName"
- assert info["lastname"] == "NewLast"
+ assert info["fullname"] == "NewName NewLast"
+
+ with message(mocker, "user_updated"):
+ user_update("alice", fullname="New2Name New2Last")
+
+ info = user_info("alice")
+ assert info["fullname"] == "New2Name New2Last"
def test_update_group_add_user(mocker):
diff --git a/src/tools.py b/src/tools.py
index abf224c1c..488ed516b 100644
--- a/src/tools.py
+++ b/src/tools.py
@@ -1,28 +1,22 @@
-# -*- 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_tools.py
-
- Specific tools
-"""
+#
+# Copyright (c) 2023 YunoHost Contributors
+#
+# This file is part of YunoHost (see https://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 .
+#
+import pwd
import re
import os
import subprocess
@@ -34,9 +28,13 @@ from typing import List
from moulinette import Moulinette, m18n
from moulinette.utils.log import getActionLogger
from moulinette.utils.process import call_async_output
-from moulinette.utils.filesystem import read_yaml, write_to_yaml, cp, mkdir, rm
+from moulinette.utils.filesystem import read_yaml, write_to_yaml, cp, mkdir, rm, chown
-from yunohost.app import app_upgrade, app_list
+from yunohost.app import (
+ app_upgrade,
+ app_list,
+ _list_upgradable_apps,
+)
from yunohost.app_catalog import (
_initialize_apps_catalog_system,
_update_apps_catalog,
@@ -45,10 +43,12 @@ from yunohost.domain import domain_add
from yunohost.firewall import firewall_upnp
from yunohost.service import service_start, service_enable
from yunohost.regenconf import regen_conf
-from yunohost.utils.packages import (
+from yunohost.utils.system import (
_dump_sources_list,
_list_upgradable_apt_packages,
ynh_packages_version,
+ dpkg_is_broken,
+ dpkg_lock_available,
)
from yunohost.utils.error import YunohostError, YunohostValidationError
from yunohost.log import is_unit_operation, OperationLogger
@@ -62,14 +62,7 @@ def tools_versions():
return ynh_packages_version()
-def tools_adminpw(new_password, check_strength=True):
- """
- Change admin password
-
- Keyword argument:
- new_password
-
- """
+def tools_rootpw(new_password, check_strength=True):
from yunohost.user import _hash_user_password
from yunohost.utils.password import (
assert_password_is_strong_enough,
@@ -77,48 +70,33 @@ def tools_adminpw(new_password, check_strength=True):
)
import spwd
+ assert_password_is_compatible(new_password)
if check_strength:
assert_password_is_strong_enough("admin", new_password)
- assert_password_is_compatible(new_password)
-
new_hash = _hash_user_password(new_password)
- from yunohost.utils.ldap import _get_ldap_interface
-
- ldap = _get_ldap_interface()
-
+ # Write as root password
try:
- ldap.update(
- "cn=admin",
- {"userPassword": [new_hash]},
- )
- except Exception as e:
- logger.error(f"unable to change admin password : {e}")
- raise YunohostError("admin_password_change_failed")
- else:
- # Write as root password
- try:
- hash_root = spwd.getspnam("root").sp_pwd
+ hash_root = spwd.getspnam("root").sp_pwd
- with open("/etc/shadow", "r") as before_file:
- before = before_file.read()
+ with open("/etc/shadow", "r") as before_file:
+ before = before_file.read()
- with open("/etc/shadow", "w") as after_file:
- after_file.write(
- before.replace(
- "root:" + hash_root, "root:" + new_hash.replace("{CRYPT}", "")
- )
+ with open("/etc/shadow", "w") as after_file:
+ after_file.write(
+ before.replace(
+ "root:" + hash_root, "root:" + new_hash.replace("{CRYPT}", "")
)
- # An IOError may be thrown if for some reason we can't read/write /etc/passwd
- # A KeyError could also be thrown if 'root' is not in /etc/passwd in the first place (for example because no password defined ?)
- # (c.f. the line about getspnam)
- except (IOError, KeyError):
- logger.warning(m18n.n("root_password_desynchronized"))
- return
-
- logger.info(m18n.n("root_password_replaced_by_admin_password"))
- logger.success(m18n.n("admin_password_changed"))
+ )
+ # An IOError may be thrown if for some reason we can't read/write /etc/passwd
+ # A KeyError could also be thrown if 'root' is not in /etc/passwd in the first place (for example because no password defined ?)
+ # (c.f. the line about getspnam)
+ except (IOError, KeyError):
+ logger.warning(m18n.n("root_password_desynchronized"))
+ return
+ else:
+ logger.info(m18n.n("root_password_changed"))
def tools_maindomain(new_main_domain=None):
@@ -166,39 +144,17 @@ def _set_hostname(hostname, pretty_hostname=None):
logger.debug(out)
-def _detect_virt():
- """
- Returns the output of systemd-detect-virt (so e.g. 'none' or 'lxc' or ...)
- You can check the man of the command to have a list of possible outputs...
- """
-
- p = subprocess.Popen(
- "systemd-detect-virt".split(), stdout=subprocess.PIPE, stderr=subprocess.STDOUT
- )
-
- out, _ = p.communicate()
- return out.split()[0]
-
-
@is_unit_operation()
def tools_postinstall(
operation_logger,
domain,
+ username,
+ fullname,
password,
ignore_dyndns=False,
- force_password=False,
force_diskspace=False,
+ overwrite_root_password=True,
):
- """
- YunoHost post-install
-
- Keyword argument:
- domain -- YunoHost main domain
- ignore_dyndns -- Do not subscribe domain to a DynDNS service (only
- needed for nohost.me, noho.st domains)
- password -- YunoHost admin password
-
- """
from yunohost.dyndns import _dyndns_available
from yunohost.utils.dns import is_yunohost_dyndns_domain
from yunohost.utils.password import (
@@ -206,6 +162,7 @@ def tools_postinstall(
assert_password_is_compatible,
)
from yunohost.domain import domain_main_domain
+ from yunohost.user import user_create, ADMIN_ALIASES
import psutil
# Do some checks at first
@@ -218,8 +175,21 @@ def tools_postinstall(
raw_msg=True,
)
+ # Crash early if the username is already a system user, which is
+ # a common confusion. We don't want to crash later and end up in an half-configured state.
+ all_existing_usernames = {x.pw_name for x in pwd.getpwall()}
+ if username in all_existing_usernames:
+ raise YunohostValidationError("system_username_exists")
+
+ if username in ADMIN_ALIASES:
+ raise YunohostValidationError(
+ f"Unfortunately, {username} cannot be used as a username", raw_msg=True
+ )
+
# Check there's at least 10 GB on the rootfs...
- disk_partitions = sorted(psutil.disk_partitions(), key=lambda k: k.mountpoint)
+ disk_partitions = sorted(
+ psutil.disk_partitions(all=True), key=lambda k: k.mountpoint
+ )
main_disk_partitions = [d for d in disk_partitions if d.mountpoint in ["/", "/var"]]
main_space = sum(
psutil.disk_usage(d.mountpoint).total for d in main_disk_partitions
@@ -230,12 +200,12 @@ def tools_postinstall(
# Check password
assert_password_is_compatible(password)
-
- if not force_password:
- assert_password_is_strong_enough("admin", password)
+ assert_password_is_strong_enough("admin", password)
# If this is a nohost.me/noho.st, actually check for availability
if not ignore_dyndns and is_yunohost_dyndns_domain(domain):
+ available = None
+
# Check if the domain is available...
try:
available = _dyndns_available(domain)
@@ -268,8 +238,11 @@ def tools_postinstall(
domain_add(domain, dyndns)
domain_main_domain(domain)
- # Update LDAP admin and create home dir
- tools_adminpw(password, check_strength=not force_password)
+ # First user
+ user_create(username, domain, password, admin=True, fullname=fullname)
+
+ if overwrite_root_password:
+ tools_rootpw(password)
# Enable UPnP silently and reload firewall
firewall_upnp("enable", no_refresh=True)
@@ -319,6 +292,17 @@ def tools_postinstall(
def tools_regen_conf(
names=[], with_diff=False, force=False, dry_run=False, list_pending=False
):
+ # Make sure the settings are migrated before running the migration,
+ # which may otherwise fuck things up such as the ssh config ...
+ # We do this here because the regen-conf is called before the migration in debian/postinst
+ if os.path.exists("/etc/yunohost/settings.json") and not os.path.exists(
+ "/etc/yunohost/settings.yml"
+ ):
+ try:
+ tools_migrations_run(["0025_global_settings_to_configpanel"])
+ except Exception as e:
+ logger.error(e)
+
return regen_conf(names, with_diff, force, dry_run, list_pending)
@@ -338,10 +322,11 @@ def tools_update(target=None):
upgradable_system_packages = []
if target in ["system", "all"]:
-
# Update APT cache
# LC_ALL=C is here to make sure the results are in english
- command = "LC_ALL=C apt-get update -o Acquire::Retries=3"
+ command = (
+ "LC_ALL=C apt-get update -o Acquire::Retries=3 --allow-releaseinfo-change"
+ )
# Filter boring message about "apt not having a stable CLI interface"
# Also keep track of wether or not we encountered a warning...
@@ -391,12 +376,34 @@ def tools_update(target=None):
except YunohostError as e:
logger.error(str(e))
- upgradable_apps = list(app_list(upgradable=True)["apps"])
+ upgradable_apps = _list_upgradable_apps()
if len(upgradable_apps) == 0 and len(upgradable_system_packages) == 0:
logger.info(m18n.n("already_up_to_date"))
- return {"system": upgradable_system_packages, "apps": upgradable_apps}
+ important_yunohost_upgrade = False
+ if upgradable_system_packages and any(
+ p["name"] == "yunohost" for p in upgradable_system_packages
+ ):
+ yunohost = [p for p in upgradable_system_packages if p["name"] == "yunohost"][0]
+ current_version = yunohost["current_version"].split(".")[:2]
+ new_version = yunohost["new_version"].split(".")[:2]
+ important_yunohost_upgrade = current_version != new_version
+
+ # Wrapping this in a try/except just in case for some reason we can't load
+ # the migrations, which would result in the update/upgrade process being blocked...
+ try:
+ pending_migrations = tools_migrations_list(pending=True)["migrations"]
+ except Exception as e:
+ logger.error(e)
+ pending_migrations = []
+
+ return {
+ "system": upgradable_system_packages,
+ "apps": upgradable_apps,
+ "important_yunohost_upgrade": important_yunohost_upgrade,
+ "pending_migrations": pending_migrations,
+ }
@is_unit_operation()
@@ -408,18 +415,18 @@ def tools_upgrade(operation_logger, target=None):
apps -- List of apps to upgrade (or [] to update all apps)
system -- True to upgrade system
"""
- from yunohost.utils import packages
- if packages.dpkg_is_broken():
+ if dpkg_is_broken():
raise YunohostValidationError("dpkg_is_broken")
# Check for obvious conflict with other dpkg/apt commands already running in parallel
- if not packages.dpkg_lock_available():
+ if not dpkg_lock_available():
raise YunohostValidationError("dpkg_lock_not_available")
if target not in ["apps", "system"]:
raise YunohostValidationError(
- "Uhoh ?! tools_upgrade should have 'apps' or 'system' value for argument target"
+ "Uhoh ?! tools_upgrade should have 'apps' or 'system' value for argument target",
+ raw_msg=True,
)
#
@@ -428,7 +435,6 @@ def tools_upgrade(operation_logger, target=None):
#
if target == "apps":
-
# Make sure there's actually something to upgrade
upgradable_apps = [app["id"] for app in app_list(upgradable=True)["apps"]]
@@ -452,7 +458,6 @@ def tools_upgrade(operation_logger, target=None):
#
if target == "system":
-
# Check that there's indeed some packages to upgrade
upgradables = list(_list_upgradable_apt_packages())
if not upgradables:
@@ -478,13 +483,6 @@ def tools_upgrade(operation_logger, target=None):
logger.debug("Running apt command :\n{}".format(dist_upgrade))
- def is_relevant(line):
- irrelevants = [
- "service sudo-ldap already provided",
- "Reading database ...",
- ]
- return all(i not in line.rstrip() for i in irrelevants)
-
callbacks = (
lambda l: logger.info("+ " + l.rstrip() + "\r")
if _apt_log_line_is_relevant(l)
@@ -496,8 +494,10 @@ def tools_upgrade(operation_logger, target=None):
returncode = call_async_output(dist_upgrade, callbacks, shell=True)
# If yunohost is being upgraded from the webadmin
- if "yunohost" in upgradables and Moulinette.interface.type == "api":
-
+ if (
+ any(p["name"] == "yunohost" for p in upgradables)
+ and Moulinette.interface.type == "api"
+ ):
# Restart the API after 10 sec (at now doesn't support sub-minute times...)
# We do this so that the API / webadmin still gets the proper HTTP response
# It's then up to the webadmin to implement a proper UX process to wait 10 sec and then auto-fresh the webadmin
@@ -510,7 +510,7 @@ def tools_upgrade(operation_logger, target=None):
logger.warning(
m18n.n(
"tools_upgrade_failed",
- packages_list=", ".join(upgradables),
+ packages_list=", ".join([p["name"] for p in upgradables]),
)
)
@@ -538,6 +538,8 @@ def _apt_log_line_is_relevant(line):
"==> Keeping old config file as default.",
"is a disabled or a static unit",
" update-rc.d: warning: start and stop actions are no longer supported; falling back to defaults",
+ "insserv: warning: current stop runlevel",
+ "insserv: warning: current start runlevel",
]
return line.rstrip() and all(i not in line.rstrip() for i in irrelevants)
@@ -719,7 +721,6 @@ def tools_migrations_run(
# Actually run selected migrations
for migration in targets:
-
# If we are migrating in "automatic mode" (i.e. from debian configure
# during an upgrade of the package) but we are asked for running
# migrations to be ran manually by the user, stop there and ask the
@@ -775,7 +776,6 @@ def tools_migrations_run(
_write_migration_state(migration.id, "skipped")
operation_logger.success()
else:
-
try:
migration.operation_logger = operation_logger
logger.info(m18n.n("migrations_running_forward", id=migration.id))
@@ -807,14 +807,12 @@ def tools_migrations_state():
def _write_migration_state(migration_id, state):
-
current_states = tools_migrations_state()
current_states["migrations"][migration_id] = state
write_to_yaml(MIGRATIONS_STATE_PATH, current_states)
def _get_migrations_list():
-
# states is a datastructure that represents the last run migration
# it has this form:
# {
@@ -865,7 +863,6 @@ def _get_migration_by_name(migration_name):
def _load_migration(migration_file):
-
migration_id = migration_file[: -len(".py")]
logger.debug(m18n.n("migrations_loading_migration", id=migration_id))
@@ -900,7 +897,6 @@ def _skip_all_migrations():
def _tools_migrations_run_after_system_restore(backup_version):
-
all_migrations = _get_migrations_list()
current_version = version.parse(ynh_packages_version()["yunohost"]["version"])
@@ -927,7 +923,6 @@ def _tools_migrations_run_after_system_restore(backup_version):
def _tools_migrations_run_before_app_restore(backup_version, app_id):
-
all_migrations = _get_migrations_list()
current_version = version.parse(ynh_packages_version()["yunohost"]["version"])
@@ -954,7 +949,6 @@ def _tools_migrations_run_before_app_restore(backup_version, app_id):
class Migration:
-
# Those are to be implemented by daughter classes
mode = "auto"
@@ -980,9 +974,8 @@ class Migration:
def description(self):
return m18n.n(f"migration_description_{self.id}")
- def ldap_migration(self, run):
+ def ldap_migration(run):
def func(self):
-
# Backup LDAP before the migration
logger.info(m18n.n("migration_ldap_backup_before_migration"))
try:
@@ -1008,22 +1001,28 @@ class Migration:
try:
run(self, backup_folder)
except Exception:
- logger.warning(
- m18n.n("migration_ldap_migration_failed_trying_to_rollback")
- )
- os.system("systemctl stop slapd")
- # To be sure that we don't keep some part of the old config
- rm("/etc/ldap/slapd.d", force=True, recursive=True)
- cp(f"{backup_folder}/ldap_config", "/etc/ldap", recursive=True)
- cp(f"{backup_folder}/ldap_db", "/var/lib/ldap", recursive=True)
- cp(
- f"{backup_folder}/apps_settings",
- "/etc/yunohost/apps",
- recursive=True,
- )
- os.system("systemctl start slapd")
- rm(backup_folder, force=True, recursive=True)
- logger.info(m18n.n("migration_ldap_rollback_success"))
+ if self.ldap_migration_started:
+ logger.warning(
+ m18n.n("migration_ldap_migration_failed_trying_to_rollback")
+ )
+ os.system("systemctl stop slapd")
+ # To be sure that we don't keep some part of the old config
+ rm("/etc/ldap", force=True, recursive=True)
+ cp(f"{backup_folder}/ldap_config", "/etc/ldap", recursive=True)
+ chown("/etc/ldap/schema/", "openldap", "openldap", recursive=True)
+ chown("/etc/ldap/slapd.d/", "openldap", "openldap", recursive=True)
+ rm("/var/lib/ldap", force=True, recursive=True)
+ cp(f"{backup_folder}/ldap_db", "/var/lib/ldap", recursive=True)
+ rm("/etc/yunohost/apps", force=True, recursive=True)
+ chown("/var/lib/ldap/", "openldap", recursive=True)
+ cp(
+ f"{backup_folder}/apps_settings",
+ "/etc/yunohost/apps",
+ recursive=True,
+ )
+ os.system("systemctl start slapd")
+ rm(backup_folder, force=True, recursive=True)
+ logger.info(m18n.n("migration_ldap_rollback_success"))
raise
else:
rm(backup_folder, force=True, recursive=True)
diff --git a/src/user.py b/src/user.py
index 0c5a577d7..00876854e 100644
--- a/src/user.py
+++ b/src/user.py
@@ -1,28 +1,21 @@
-# -*- coding: utf-8 -*-
-
-""" License
-
- Copyright (C) 2014 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
-
-"""
-
-""" yunohost_user.py
-
- Manage users
-"""
+#
+# Copyright (c) 2023 YunoHost Contributors
+#
+# This file is part of YunoHost (see https://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 .
+#
import os
import re
import pwd
@@ -40,6 +33,7 @@ from moulinette.utils.process import check_output
from yunohost.utils.error import YunohostError, YunohostValidationError
from yunohost.service import service_status
from yunohost.log import is_unit_operation
+from yunohost.utils.system import binary_to_human
logger = getActionLogger("yunohost.user")
@@ -55,11 +49,10 @@ FIELDS_FOR_IMPORT = {
"groups": r"^|([a-z0-9_]+(,?[a-z0-9_]+)*)$",
}
-FIRST_ALIASES = ["root@", "admin@", "webmaster@", "postmaster@", "abuse@"]
+ADMIN_ALIASES = ["root", "admin", "admins", "webmaster", "postmaster", "abuse"]
def user_list(fields=None):
-
from yunohost.utils.ldap import _get_ldap_interface
ldap_attrs = {
@@ -129,17 +122,52 @@ def user_list(fields=None):
return {"users": users}
+def list_shells():
+ with open("/etc/shells", "r") as f:
+ content = f.readlines()
+
+ return [line.strip() for line in content if line.startswith("/")]
+
+
+def shellexists(shell):
+ """Check if the provided shell exists and is executable."""
+ return os.path.isfile(shell) and os.access(shell, os.X_OK)
+
+
@is_unit_operation([("username", "user")])
def user_create(
operation_logger,
username,
- firstname,
- lastname,
domain,
password,
+ fullname=None,
+ firstname=None,
+ lastname=None,
mailbox_quota="0",
+ admin=False,
from_import=False,
+ loginShell=None,
):
+ if firstname or lastname:
+ logger.warning(
+ "Options --firstname / --lastname of 'yunohost user create' are deprecated. We recommend using --fullname instead."
+ )
+
+ if not fullname or not fullname.strip():
+ if not firstname.strip():
+ raise YunohostValidationError(
+ "You should specify the fullname of the user using option -F"
+ )
+ lastname = (
+ lastname or " "
+ ) # Stupid hack because LDAP requires the sn/lastname attr, but it accepts a single whitespace...
+ fullname = f"{firstname} {lastname}".strip()
+ else:
+ fullname = fullname.strip()
+ firstname = fullname.split()[0]
+ lastname = (
+ " ".join(fullname.split()[1:]) or " "
+ ) # Stupid hack because LDAP requires the sn/lastname attr, but it accepts a single whitespace...
from yunohost.domain import domain_list, _get_maindomain, _assert_domain_exists
from yunohost.hook import hook_callback
@@ -151,7 +179,7 @@ def user_create(
# Ensure compatibility and sufficiently complex password
assert_password_is_compatible(password)
- assert_password_is_strong_enough("user", password)
+ assert_password_is_strong_enough("admin" if admin else "user", password)
# Validate domain used for email address/xmpp account
if domain is None:
@@ -192,10 +220,7 @@ def user_create(
if username in all_existing_usernames:
raise YunohostValidationError("system_username_exists")
- main_domain = _get_maindomain()
- aliases = [alias + main_domain for alias in FIRST_ALIASES]
-
- if mail in aliases:
+ if mail.split("@")[0] in ADMIN_ALIASES:
raise YunohostValidationError("mail_unavailable")
if not from_import:
@@ -205,14 +230,22 @@ def user_create(
all_uid = {str(x.pw_uid) for x in pwd.getpwall()}
all_gid = {str(x.gr_gid) for x in grp.getgrall()}
+ # Prevent users from obtaining uid 1007 which is the uid of the legacy admin,
+ # and there could be a edge case where a new user becomes owner of an old, removed admin user
+ all_uid.add("1007")
+ all_gid.add("1007")
+
uid_guid_found = False
while not uid_guid_found:
# LXC uid number is limited to 65536 by default
uid = str(random.randint(1001, 65000))
uid_guid_found = uid not in all_uid and uid not in all_gid
- # Adapt values for LDAP
- fullname = f"{firstname} {lastname}"
+ if not loginShell:
+ loginShell = "/bin/bash"
+ else:
+ if not shellexists(loginShell) or loginShell not in list_shells():
+ raise YunohostValidationError("invalid_shell", shell=loginShell)
attr_dict = {
"objectClass": [
@@ -233,13 +266,9 @@ def user_create(
"gidNumber": [uid],
"uidNumber": [uid],
"homeDirectory": ["/home/" + username],
- "loginShell": ["/bin/bash"],
+ "loginShell": [loginShell],
}
- # If it is the first user, add some aliases
- if not ldap.search(base="ou=users", filter="uid=*"):
- attr_dict["mail"] = [attr_dict["mail"]] + aliases
-
try:
ldap.add(f"uid={username},ou=users", attr_dict)
except Exception as e:
@@ -265,6 +294,8 @@ def user_create(
# Create group for user and add to group 'all_users'
user_group_create(groupname=username, gid=uid, primary_group=True, sync_perm=False)
user_group_update(groupname="all_users", add=username, force=True, sync_perm=True)
+ if admin:
+ user_group_update(groupname="admins", add=username, sync_perm=True)
# Trigger post_user_create hooks
env_dict = {
@@ -286,14 +317,6 @@ def user_create(
@is_unit_operation([("username", "user")])
def user_delete(operation_logger, username, purge=False, from_import=False):
- """
- Delete user
-
- Keyword argument:
- username -- Username to delete
- purge
-
- """
from yunohost.hook import hook_callback
from yunohost.utils.ldap import _get_ldap_interface
@@ -351,23 +374,22 @@ def user_update(
remove_mailalias=None,
mailbox_quota=None,
from_import=False,
+ fullname=None,
+ loginShell=None,
):
- """
- Update user informations
+ if firstname or lastname:
+ logger.warning(
+ "Options --firstname / --lastname of 'yunohost user create' are deprecated. We recommend using --fullname instead."
+ )
- Keyword argument:
- lastname
- mail
- firstname
- add_mailalias -- Mail aliases to add
- remove_mailforward -- Mailforward addresses to remove
- username -- Username of user to update
- add_mailforward -- Mailforward addresses to add
- change_password -- New password to set
- remove_mailalias -- Mail aliases to remove
+ if fullname and fullname.strip():
+ fullname = fullname.strip()
+ firstname = fullname.split()[0]
+ lastname = (
+ " ".join(fullname.split()[1:]) or " "
+ ) # Stupid hack because LDAP requires the sn/lastname attr, but it accepts a single whitespace...
- """
- from yunohost.domain import domain_list, _get_maindomain
+ from yunohost.domain import domain_list
from yunohost.app import app_ssowatconf
from yunohost.utils.password import (
assert_password_is_strong_enough,
@@ -380,7 +402,7 @@ def user_update(
# Populate user informations
ldap = _get_ldap_interface()
- attrs_to_fetch = ["givenName", "sn", "mail", "maildrop"]
+ attrs_to_fetch = ["givenName", "sn", "mail", "maildrop", "memberOf"]
result = ldap.search(
base="ou=users",
filter="uid=" + username,
@@ -396,20 +418,20 @@ def user_update(
if firstname:
new_attr_dict["givenName"] = [firstname] # TODO: Validate
new_attr_dict["cn"] = new_attr_dict["displayName"] = [
- firstname + " " + user["sn"][0]
+ (firstname + " " + user["sn"][0]).strip()
]
env_dict["YNH_USER_FIRSTNAME"] = firstname
if lastname:
new_attr_dict["sn"] = [lastname] # TODO: Validate
new_attr_dict["cn"] = new_attr_dict["displayName"] = [
- user["givenName"][0] + " " + lastname
+ (user["givenName"][0] + " " + lastname).strip()
]
env_dict["YNH_USER_LASTNAME"] = lastname
if lastname and firstname:
new_attr_dict["cn"] = new_attr_dict["displayName"] = [
- firstname + " " + lastname
+ (firstname + " " + lastname).strip()
]
# change_password is None if user_update is not called to change the password
@@ -421,17 +443,18 @@ def user_update(
change_password = Moulinette.prompt(
m18n.n("ask_password"), is_password=True, confirm=True
)
+
# Ensure compatibility and sufficiently complex password
assert_password_is_compatible(change_password)
- assert_password_is_strong_enough("user", change_password)
+ is_admin = "cn=admins,ou=groups,dc=yunohost,dc=org" in user["memberOf"]
+ assert_password_is_strong_enough(
+ "admin" if is_admin else "user", change_password
+ )
new_attr_dict["userPassword"] = [_hash_user_password(change_password)]
env_dict["YNH_USER_PASSWORD"] = change_password
if mail:
- main_domain = _get_maindomain()
- aliases = [alias + main_domain for alias in FIRST_ALIASES]
-
# If the requested mail address is already as main address or as an alias by this user
if mail in user["mail"]:
user["mail"].remove(mail)
@@ -445,7 +468,8 @@ def user_update(
raise YunohostError(
"mail_domain_unknown", domain=mail[mail.find("@") + 1 :]
)
- if mail in aliases:
+
+ if mail.split("@")[0] in ADMIN_ALIASES:
raise YunohostValidationError("mail_unavailable")
new_attr_dict["mail"] = [mail] + user["mail"][1:]
@@ -454,6 +478,9 @@ def user_update(
if not isinstance(add_mailalias, list):
add_mailalias = [add_mailalias]
for mail in add_mailalias:
+ if mail.split("@")[0] in ADMIN_ALIASES:
+ raise YunohostValidationError("mail_unavailable")
+
# (c.f. similar stuff as before)
if mail in user["mail"]:
user["mail"].remove(mail)
@@ -508,6 +535,12 @@ def user_update(
new_attr_dict["mailuserquota"] = [mailbox_quota]
env_dict["YNH_USER_MAILQUOTA"] = mailbox_quota
+ if loginShell is not None:
+ if not shellexists(loginShell) or loginShell not in list_shells():
+ raise YunohostValidationError("invalid_shell", shell=loginShell)
+ new_attr_dict["loginShell"] = [loginShell]
+ env_dict["YNH_USER_LOGINSHELL"] = loginShell
+
if not from_import:
operation_logger.start()
@@ -516,6 +549,10 @@ def user_update(
except Exception as e:
raise YunohostError("user_update_failed", user=username, error=e)
+ # Invalidate passwd and group to update the loginShell
+ subprocess.call(["nscd", "-i", "passwd"])
+ subprocess.call(["nscd", "-i", "group"])
+
# Trigger post_user_update hooks
hook_callback("post_user_update", env=env_dict)
@@ -537,7 +574,7 @@ def user_info(username):
ldap = _get_ldap_interface()
- user_attrs = ["cn", "mail", "uid", "maildrop", "givenName", "sn", "mailuserquota"]
+ user_attrs = ["cn", "mail", "uid", "maildrop", "mailuserquota", "loginShell"]
if len(username.split("@")) == 2:
filter = "mail=" + username
@@ -554,9 +591,8 @@ def user_info(username):
result_dict = {
"username": user["uid"][0],
"fullname": user["cn"][0],
- "firstname": user["givenName"][0],
- "lastname": user["sn"][0],
"mail": user["mail"][0],
+ "loginShell": user["loginShell"][0],
"mail-aliases": [],
"mail-forward": [],
}
@@ -595,8 +631,8 @@ def user_info(username):
has_value = re.search(r"Value=(\d+)", cmd_result)
if has_value:
- storage_use = int(has_value.group(1))
- storage_use = _convertSize(storage_use)
+ storage_use = int(has_value.group(1)) * 1000
+ storage_use = binary_to_human(storage_use)
if is_limited:
has_percent = re.search(r"%=(\d+)", cmd_result)
@@ -695,7 +731,6 @@ def user_import(operation_logger, csvfile, update=False, delete=False):
)
for user in reader:
-
# Validate column values against regexes
format_errors = [
f"{key}: '{user[key]}' doesn't match the expected format"
@@ -849,10 +884,9 @@ def user_import(operation_logger, csvfile, update=False, delete=False):
user_update(
new_infos["username"],
- new_infos["firstname"],
- new_infos["lastname"],
- new_infos["mail"],
- new_infos["password"],
+ firstname=new_infos["firstname"],
+ lastname=new_infos["lastname"],
+ change_password=new_infos["password"],
mailbox_quota=new_infos["mailbox-quota"],
mail=new_infos["mail"],
add_mailalias=new_infos["mail-alias"],
@@ -892,12 +926,12 @@ def user_import(operation_logger, csvfile, update=False, delete=False):
try:
user_create(
user["username"],
- user["firstname"],
- user["lastname"],
user["domain"],
user["password"],
user["mailbox-quota"],
from_import=True,
+ firstname=user["firstname"],
+ lastname=user["lastname"],
)
update(user)
result["created"] += 1
@@ -952,7 +986,6 @@ def user_group_list(short=False, full=False, include_primary_groups=True):
users = user_list()["users"]
groups = {}
for infos in groups_infos:
-
name = infos["cn"][0]
if not include_primary_groups and name in users:
@@ -1070,7 +1103,7 @@ def user_group_delete(operation_logger, groupname, force=False, sync_perm=True):
#
# We also can't delete "all_users" because that's a special group...
existing_users = list(user_list()["users"].keys())
- undeletable_groups = existing_users + ["all_users", "visitors"]
+ undeletable_groups = existing_users + ["all_users", "visitors", "admins"]
if groupname in undeletable_groups and not force:
raise YunohostValidationError("group_cannot_be_deleted", group=groupname)
@@ -1096,22 +1129,14 @@ def user_group_update(
groupname,
add=None,
remove=None,
+ add_mailalias=None,
+ remove_mailalias=None,
force=False,
sync_perm=True,
from_import=False,
):
- """
- Update user informations
-
- Keyword argument:
- groupname -- Groupname to update
- add -- User(s) to add in group
- remove -- User(s) to remove in group
-
- """
-
from yunohost.permission import permission_sync_to_user
- from yunohost.utils.ldap import _get_ldap_interface
+ from yunohost.utils.ldap import _get_ldap_interface, _ldap_path_extract
existing_users = list(user_list()["users"].keys())
@@ -1128,9 +1153,28 @@ def user_group_update(
"group_cannot_edit_primary_group", group=groupname
)
+ ldap = _get_ldap_interface()
+
+ # Fetch info for this group
+ result = ldap.search(
+ "ou=groups",
+ "cn=" + groupname,
+ ["cn", "member", "permission", "mail", "objectClass"],
+ )
+
+ if not result:
+ raise YunohostValidationError("group_unknown", group=groupname)
+
+ group = result[0]
+
# We extract the uid for each member of the group to keep a simple flat list of members
- current_group = user_group_info(groupname)["members"]
- new_group = copy.copy(current_group)
+ current_group_mail = group.get("mail", [])
+ new_group_mail = copy.copy(current_group_mail)
+ current_group_members = [
+ _ldap_path_extract(p, "uid") for p in group.get("member", [])
+ ]
+ new_group_members = copy.copy(current_group_members)
+ new_attr_dict = {}
if add:
users_to_add = [add] if not isinstance(add, list) else add
@@ -1139,43 +1183,106 @@ def user_group_update(
if user not in existing_users:
raise YunohostValidationError("user_unknown", user=user)
- if user in current_group:
+ if user in current_group_members:
logger.warning(
m18n.n("group_user_already_in_group", user=user, group=groupname)
)
else:
operation_logger.related_to.append(("user", user))
+ logger.info(m18n.n("group_user_add", group=groupname, user=user))
- new_group += users_to_add
+ new_group_members += users_to_add
if remove:
users_to_remove = [remove] if not isinstance(remove, list) else remove
for user in users_to_remove:
- if user not in current_group:
+ if user not in current_group_members:
logger.warning(
m18n.n("group_user_not_in_group", user=user, group=groupname)
)
else:
operation_logger.related_to.append(("user", user))
+ logger.info(m18n.n("group_user_remove", group=groupname, user=user))
- # Remove users_to_remove from new_group
- # Kinda like a new_group -= users_to_remove
- new_group = [u for u in new_group if u not in users_to_remove]
+ # Remove users_to_remove from new_group_members
+ # Kinda like a new_group_members -= users_to_remove
+ new_group_members = [u for u in new_group_members if u not in users_to_remove]
- new_group_dns = [
- "uid=" + user + ",ou=users,dc=yunohost,dc=org" for user in new_group
- ]
+ # If something changed, we add this to the stuff to commit later in the code
+ if set(new_group_members) != set(current_group_members):
+ new_group_members_dns = [
+ "uid=" + user + ",ou=users,dc=yunohost,dc=org" for user in new_group_members
+ ]
+ new_attr_dict["member"] = set(new_group_members_dns)
+ new_attr_dict["memberUid"] = set(new_group_members)
- if set(new_group) != set(current_group):
+ # Check the whole alias situation
+ if add_mailalias:
+ from yunohost.domain import domain_list
+
+ domains = domain_list()["domains"]
+
+ if not isinstance(add_mailalias, list):
+ add_mailalias = [add_mailalias]
+ for mail in add_mailalias:
+ if mail.split("@")[0] in ADMIN_ALIASES and groupname != "admins":
+ raise YunohostValidationError("mail_unavailable")
+ if mail in current_group_mail:
+ continue
+ try:
+ ldap.validate_uniqueness({"mail": mail})
+ except Exception as e:
+ raise YunohostError("group_update_failed", group=groupname, error=e)
+ if mail[mail.find("@") + 1 :] not in domains:
+ raise YunohostError(
+ "mail_domain_unknown", domain=mail[mail.find("@") + 1 :]
+ )
+ new_group_mail.append(mail)
+ logger.info(m18n.n("group_mailalias_add", group=groupname, mail=mail))
+
+ if remove_mailalias:
+ from yunohost.domain import _get_maindomain
+
+ if not isinstance(remove_mailalias, list):
+ remove_mailalias = [remove_mailalias]
+ for mail in remove_mailalias:
+ if (
+ "@" in mail
+ and mail.split("@")[0] in ADMIN_ALIASES
+ and groupname == "admins"
+ and mail.split("@")[1] == _get_maindomain()
+ ):
+ raise YunohostValidationError(
+ f"The alias {mail} can not be removed from the 'admins' group",
+ raw_msg=True,
+ )
+ if mail in new_group_mail:
+ new_group_mail.remove(mail)
+ logger.info(
+ m18n.n("group_mailalias_remove", group=groupname, mail=mail)
+ )
+ else:
+ raise YunohostValidationError("mail_alias_remove_failed", mail=mail)
+
+ if set(new_group_mail) != set(current_group_mail):
+ logger.info(m18n.n("group_update_aliases", group=groupname))
+ new_attr_dict["mail"] = set(new_group_mail)
+
+ if new_attr_dict["mail"] and "mailGroup" not in group["objectClass"]:
+ new_attr_dict["objectClass"] = group["objectClass"] + ["mailGroup"]
+ if not new_attr_dict["mail"] and "mailGroup" in group["objectClass"]:
+ new_attr_dict["objectClass"] = [
+ c
+ for c in group["objectClass"]
+ if c != "mailGroup" and c != "mailAccount"
+ ]
+
+ if new_attr_dict:
if not from_import:
operation_logger.start()
- ldap = _get_ldap_interface()
try:
- ldap.update(
- f"cn={groupname},ou=groups",
- {"member": set(new_group_dns), "memberUid": set(new_group)},
- )
+ ldap.update(f"cn={groupname},ou=groups", new_attr_dict)
except Exception as e:
raise YunohostError("group_update_failed", group=groupname, error=e)
@@ -1184,7 +1291,10 @@ def user_group_update(
if not from_import:
if groupname != "all_users":
- logger.success(m18n.n("group_updated", group=groupname))
+ if not new_attr_dict:
+ logger.info(m18n.n("group_no_change", group=groupname))
+ else:
+ logger.success(m18n.n("group_updated", group=groupname))
else:
logger.debug(m18n.n("group_updated", group=groupname))
@@ -1208,7 +1318,7 @@ def user_group_info(groupname):
result = ldap.search(
"ou=groups",
"cn=" + groupname,
- ["cn", "member", "permission"],
+ ["cn", "member", "permission", "mail"],
)
if not result:
@@ -1223,6 +1333,7 @@ def user_group_info(groupname):
"permissions": [
_ldap_path_extract(p, "cn") for p in infos.get("permission", [])
],
+ "mail-aliases": [m for m in infos.get("mail", [])],
}
@@ -1252,6 +1363,14 @@ def user_group_remove(groupname, usernames, force=False, sync_perm=True):
)
+def user_group_add_mailalias(groupname, aliases):
+ return user_group_update(groupname, add_mailalias=aliases, sync_perm=False)
+
+
+def user_group_remove_mailalias(groupname, aliases):
+ return user_group_update(groupname, remove_mailalias=aliases, sync_perm=False)
+
+
#
# Permission subcategory
#
@@ -1324,14 +1443,6 @@ def user_ssh_remove_key(username, key):
#
-def _convertSize(num, suffix=""):
- for unit in ["K", "M", "G", "T", "P", "E", "Z"]:
- if abs(num) < 1024.0:
- return "{:3.1f}{}{}".format(num, unit, suffix)
- num /= 1024.0
- return "{:.1f}{}{}".format(num, "Yi", suffix)
-
-
def _hash_user_password(password):
"""
This function computes and return a salted hash for the password in input.
@@ -1359,3 +1470,20 @@ def _hash_user_password(password):
salt = "$6$" + salt + "$"
return "{CRYPT}" + crypt.crypt(str(password), salt)
+
+
+def _update_admins_group_aliases(old_main_domain, new_main_domain):
+ current_admin_aliases = user_group_info("admins")["mail-aliases"]
+
+ aliases_to_remove = [
+ a
+ for a in current_admin_aliases
+ if "@" in a
+ and a.split("@")[1] == old_main_domain
+ and a.split("@")[0] in ADMIN_ALIASES
+ ]
+ aliases_to_add = [f"{a}@{new_main_domain}" for a in ADMIN_ALIASES]
+
+ user_group_update(
+ "admins", add_mailalias=aliases_to_add, remove_mailalias=aliases_to_remove
+ )
diff --git a/src/utils/__init__.py b/src/utils/__init__.py
index e69de29bb..7c1e7b0cd 100644
--- a/src/utils/__init__.py
+++ b/src/utils/__init__.py
@@ -0,0 +1,18 @@
+#
+# Copyright (c) 2023 YunoHost Contributors
+#
+# This file is part of YunoHost (see https://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 .
+#
diff --git a/src/utils/configpanel.py b/src/utils/configpanel.py
new file mode 100644
index 000000000..2c56eb754
--- /dev/null
+++ b/src/utils/configpanel.py
@@ -0,0 +1,686 @@
+#
+# Copyright (c) 2023 YunoHost Contributors
+#
+# This file is part of YunoHost (see https://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 .
+#
+import glob
+import os
+import re
+import urllib.parse
+from collections import OrderedDict
+from typing import Union
+
+from moulinette import Moulinette, m18n
+from moulinette.interfaces.cli import colorize
+from moulinette.utils.filesystem import mkdir, read_toml, read_yaml, write_to_yaml
+from moulinette.utils.log import getActionLogger
+from yunohost.utils.error import YunohostError, YunohostValidationError
+from yunohost.utils.form import (
+ OPTIONS,
+ BaseOption,
+ FileOption,
+ ask_questions_and_parse_answers,
+ evaluate_simple_js_expression,
+)
+from yunohost.utils.i18n import _value_for_locale
+
+logger = getActionLogger("yunohost.configpanel")
+CONFIG_PANEL_VERSION_SUPPORTED = 1.0
+
+
+class ConfigPanel:
+ entity_type = "config"
+ save_path_tpl: Union[str, None] = None
+ config_path_tpl = "/usr/share/yunohost/config_{entity_type}.toml"
+ save_mode = "full"
+
+ @classmethod
+ def list(cls):
+ """
+ List available config panel
+ """
+ try:
+ entities = [
+ re.match(
+ "^" + cls.save_path_tpl.format(entity="(?p)") + "$", f
+ ).group("entity")
+ for f in glob.glob(cls.save_path_tpl.format(entity="*"))
+ if os.path.isfile(f)
+ ]
+ except FileNotFoundError:
+ entities = []
+ return entities
+
+ def __init__(self, entity, config_path=None, save_path=None, creation=False):
+ self.entity = entity
+ self.config_path = config_path
+ if not config_path:
+ self.config_path = self.config_path_tpl.format(
+ entity=entity, entity_type=self.entity_type
+ )
+ self.save_path = save_path
+ if not save_path and self.save_path_tpl:
+ self.save_path = self.save_path_tpl.format(entity=entity)
+ self.config = {}
+ self.values = {}
+ self.new_values = {}
+
+ if (
+ self.save_path
+ and self.save_mode != "diff"
+ and not creation
+ and not os.path.exists(self.save_path)
+ ):
+ raise YunohostValidationError(
+ f"{self.entity_type}_unknown", **{self.entity_type: entity}
+ )
+ if self.save_path and creation and os.path.exists(self.save_path):
+ raise YunohostValidationError(
+ f"{self.entity_type}_exists", **{self.entity_type: entity}
+ )
+
+ # Search for hooks in the config panel
+ self.hooks = {
+ func: getattr(self, func)
+ for func in dir(self)
+ if callable(getattr(self, func))
+ and re.match("^(validate|post_ask)__", func)
+ }
+
+ 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._get_raw_settings()
+ 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]
+ value = self.values.get(option, None)
+
+ option_type = None
+ for _, _, option_ in self._iterate():
+ if option_["id"] == option:
+ option_type = OPTIONS[option_["type"]]
+ break
+
+ return option_type.normalize(value) if option_type else value
+
+ # Format result in 'classic' or 'export' mode
+ logger.debug(f"Formating result in '{mode}' mode")
+ result = {}
+ for panel, section, option in self._iterate():
+ if section["is_action_section"] and mode != "full":
+ continue
+
+ 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":
+ option["ask"] = ask
+ question_class = OPTIONS[option.get("type", "string")]
+ # FIXME : maybe other properties should be taken from the question, not just choices ?.
+ option["choices"] = question_class(option).choices
+ option["default"] = question_class(option).default
+ option["pattern"] = question_class(option).pattern
+ else:
+ result[key] = {"ask": ask}
+ if "current_value" in option:
+ question_class = OPTIONS[option.get("type", "string")]
+ result[key]["value"] = question_class.humanize(
+ option["current_value"], option
+ )
+ # FIXME: semantics, technically here this is not about a prompt...
+ if question_class.hide_user_input_in_prompt:
+ result[key][
+ "value"
+ ] = "**************" # Prevent displaying password in `config get`
+
+ 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")
+ self._parse_pre_answered(args, value, args_file)
+
+ # Read or get values and hydrate the config
+ self._get_raw_settings()
+ self._hydrate()
+ BaseOption.operation_logger = operation_logger
+ 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
+ # FIXME : this is currently done in the context of config panels,
+ # but could also happen in the context of app install ... (or anywhere else
+ # where we may parse args etc...)
+ FileOption.clean_upload_dirs()
+
+ self._reload_services()
+
+ logger.success("Config updated as expected")
+ operation_logger.success()
+
+ def list_actions(self):
+ actions = {}
+
+ # FIXME : meh, loading the entire config panel is again going to cause
+ # stupid issues for domain (e.g loading registrar stuff when willing to just list available actions ...)
+ self.filter_key = ""
+ self._get_config_panel()
+ for panel, section, option in self._iterate():
+ if option["type"] == "button":
+ key = f"{panel['id']}.{section['id']}.{option['id']}"
+ actions[key] = _value_for_locale(option["ask"])
+
+ return actions
+
+ def run_action(self, action=None, args=None, args_file=None, operation_logger=None):
+ #
+ # FIXME : this stuff looks a lot like set() ...
+ #
+
+ self.filter_key = ".".join(action.split(".")[:2])
+ action_id = action.split(".")[2]
+
+ # Read config panel toml
+ self._get_config_panel()
+
+ # FIXME: should also check that there's indeed a key called action
+ if not self.config:
+ raise YunohostValidationError(f"No action named {action}", raw_msg=True)
+
+ # Import and parse pre-answered options
+ logger.debug("Import and parse pre-answered options")
+ self._parse_pre_answered(args, None, args_file)
+
+ # Read or get values and hydrate the config
+ self._get_raw_settings()
+ self._hydrate()
+ BaseOption.operation_logger = operation_logger
+ self._ask(action=action_id)
+
+ # FIXME: here, we could want to check constrains on
+ # the action's visibility / requirements wrt to the answer to questions ...
+
+ if operation_logger:
+ operation_logger.start()
+
+ try:
+ self._run_action(action_id)
+ 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_action_failed", action=action, 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_action_failed", action=action, error=error))
+ raise
+ finally:
+ # Delete files uploaded from API
+ # FIXME : this is currently done in the context of config panels,
+ # but could also happen in the context of app install ... (or anywhere else
+ # where we may parse args etc...)
+ FileOption.clean_upload_dirs()
+
+ # FIXME: i18n
+ logger.success(f"Action {action_id} successful")
+ operation_logger.success()
+
+ def _get_raw_config(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_raw_config()
+
+ # Check TOML config panel is in a supported version
+ if float(toml_config_panel["version"]) < CONFIG_PANEL_VERSION_SUPPORTED:
+ logger.error(
+ f"Config panels version {toml_config_panel['version']} are not supported"
+ )
+ return None
+
+ # Transform toml format into internal format
+ format_description = {
+ "root": {
+ "properties": ["version", "i18n"],
+ "defaults": {"version": 1.0},
+ },
+ "panels": {
+ "properties": ["name", "services", "actions", "help"],
+ "defaults": {
+ "services": [],
+ "actions": {"apply": {"en": "Apply"}},
+ },
+ },
+ "sections": {
+ "properties": ["name", "services", "optional", "help", "visible"],
+ "defaults": {
+ "name": "",
+ "services": [],
+ "optional": True,
+ "is_action_section": False,
+ },
+ },
+ "options": {
+ "properties": [
+ "ask",
+ "type",
+ "bind",
+ "help",
+ "example",
+ "default",
+ "style",
+ "icon",
+ "placeholder",
+ "visible",
+ "optional",
+ "choices",
+ "yes",
+ "no",
+ "pattern",
+ "limit",
+ "min",
+ "max",
+ "step",
+ "accept",
+ "redact",
+ "filter",
+ "readonly",
+ "enabled",
+ # "confirm", # TODO: to ask confirmation before running an action
+ ],
+ "defaults": {},
+ },
+ }
+
+ def _build_internal_config_panel(raw_infos, level):
+ """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
+ """
+
+ defaults = format_description[level]["defaults"]
+ properties = format_description[level]["properties"]
+
+ # Start building the ouput (merging the raw infos + defaults)
+ out = {key: raw_infos.get(key, value) for key, value in defaults.items()}
+
+ # Now fill the sublevels (+ apply filter_key)
+ i = list(format_description).index(level)
+ sublevel = list(format_description)[i + 1] if level != "options" else None
+ search_key = filter_key[i] if len(filter_key) > i else False
+
+ for key, value in raw_infos.items():
+ # Key/value are a child node
+ if (
+ isinstance(value, OrderedDict)
+ and key not in properties
+ and sublevel
+ ):
+ # We exclude all nodes not referenced by the filter_key
+ if search_key and key != search_key:
+ continue
+ subnode = _build_internal_config_panel(value, sublevel)
+ subnode["id"] = key
+ if level == "root":
+ subnode.setdefault("name", {"en": key.capitalize()})
+ elif level == "sections":
+ subnode["name"] = key # legacy
+ subnode.setdefault("optional", raw_infos.get("optional", True))
+ # If this section contains at least one button, it becomes an "action" section
+ if subnode.get("type") == "button":
+ out["is_action_section"] = True
+ out.setdefault(sublevel, []).append(subnode)
+ # Key/value are a property
+ else:
+ if key not in properties:
+ logger.warning(f"Unknown key '{key}' found in config panel")
+ # Todo search all i18n keys
+ out[key] = (
+ value
+ if key not in ["ask", "help", "name"] or isinstance(value, dict)
+ else {"en": value}
+ )
+ return out
+
+ self.config = _build_internal_config_panel(toml_config_panel, "root")
+
+ 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"]
+ forbidden_readonly_types = ["password", "app", "domain", "user", "file"]
+
+ for _, _, option in self._iterate():
+ if option["id"] in forbidden_keywords:
+ raise YunohostError("config_forbidden_keyword", keyword=option["id"])
+ if (
+ option.get("readonly", False)
+ and option.get("type", "string") in forbidden_readonly_types
+ ):
+ raise YunohostError(
+ "config_forbidden_readonly_type",
+ type=option["type"],
+ id=option["id"],
+ )
+
+ return self.config
+
+ def _get_default_values(self):
+ return {
+ option["id"]: option["default"]
+ for _, _, option in self._iterate()
+ if "default" in option
+ }
+
+ def _get_raw_settings(self):
+ """
+ Retrieve entries in YAML file
+ And set default values if needed
+ """
+
+ # Inject defaults if needed (using the magic .update() ;))
+ self.values = self._get_default_values()
+
+ # Retrieve entries in the YAML
+ if os.path.exists(self.save_path) and os.path.isfile(self.save_path):
+ self.values.update(read_yaml(self.save_path) or {})
+
+ def _hydrate(self):
+ # Hydrating config panel with current value
+ for _, section, option in self._iterate():
+ if option["id"] not in self.values:
+ allowed_empty_types = [
+ "alert",
+ "display_text",
+ "markdown",
+ "file",
+ "button",
+ ]
+
+ if section["is_action_section"] and option.get("default") is not None:
+ self.values[option["id"]] = option["default"]
+ elif (
+ 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"]]
+
+ # Allow to use value instead of current_value in app config script.
+ # e.g. apps may write `echo 'value: "foobar"'` in the config file (which is more intuitive that `echo 'current_value: "foobar"'`
+ # For example hotspot used it...
+ # See https://github.com/YunoHost/yunohost/pull/1546
+ if (
+ isinstance(value, dict)
+ and "value" in value
+ and "current_value" not in value
+ ):
+ value["current_value"] = value["value"]
+
+ # 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)
+
+ self.values[option["id"]] = value.get("current_value")
+
+ return self.values
+
+ def _ask(self, action=None):
+ 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"])
+ # auto add i18n help text if present in locales
+ if m18n.key_exists(self.config["i18n"] + "_" + option["id"] + "_help"):
+ option["help"] = m18n.n(
+ self.config["i18n"] + "_" + option["id"] + "_help"
+ )
+
+ 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 (
+ section
+ and section.get("visible")
+ and not evaluate_simple_js_expression(
+ section["visible"], context=self.future_values
+ )
+ ):
+ continue
+
+ # Ugly hack to skip action section ... except when when explicitly running actions
+ if not action:
+ if section and section["is_action_section"]:
+ continue
+
+ if panel == obj:
+ name = _value_for_locale(panel["name"])
+ display_header(f"\n{'='*40}\n>>>> {name}\n{'='*40}")
+ else:
+ name = _value_for_locale(section["name"])
+ if name:
+ display_header(f"\n# {name}")
+ elif section:
+ # filter action section options in case of multiple buttons
+ section["options"] = [
+ option
+ for option in section["options"]
+ if option.get("type", "string") != "button"
+ or option["id"] == action
+ ]
+
+ if panel == obj:
+ continue
+
+ # Check and ask unanswered questions
+ prefilled_answers = self.args.copy()
+ prefilled_answers.update(self.new_values)
+
+ questions = ask_questions_and_parse_answers(
+ {question["name"]: question for question in section["options"]},
+ prefilled_answers=prefilled_answers,
+ current_values=self.values,
+ hooks=self.hooks,
+ )
+ self.new_values.update(
+ {
+ question.name: question.value
+ for question in questions
+ if question.value is not None
+ }
+ )
+
+ @property
+ def future_values(self):
+ return {**self.values, **self.new_values}
+
+ def __getattr__(self, name):
+ if "new_values" in self.__dict__ and name in self.new_values:
+ return self.new_values[name]
+
+ if "values" in self.__dict__ and name in self.values:
+ return self.values[name]
+
+ return self.__dict__[name]
+
+ def _parse_pre_answered(self, args, value, args_file):
+ 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}
+
+ 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.future_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, "entity"):
+ service = service.replace("__APP__", self.entity)
+ 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)
diff --git a/src/utils/dns.py b/src/utils/dns.py
index ccb6c5406..b3ca4b564 100644
--- a/src/utils/dns.py
+++ b/src/utils/dns.py
@@ -1,23 +1,21 @@
-# -*- 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
-
-"""
+#
+# Copyright (c) 2023 YunoHost Contributors
+#
+# This file is part of YunoHost (see https://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 .
+#
import dns.resolver
from typing import List
@@ -33,19 +31,16 @@ external_resolvers_: List[str] = []
def is_yunohost_dyndns_domain(domain):
-
return any(
domain.endswith(f".{dyndns_domain}") for dyndns_domain in YNH_DYNDNS_DOMAINS
)
def is_special_use_tld(domain):
-
return any(domain.endswith(f".{tld}") for tld in SPECIAL_USE_TLDS)
def external_resolvers():
-
global external_resolvers_
if not external_resolvers_:
diff --git a/src/utils/error.py b/src/utils/error.py
index a92f3bd5a..9be48c5df 100644
--- a/src/utils/error.py
+++ b/src/utils/error.py
@@ -1,30 +1,26 @@
-# -*- 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
-
-"""
-
+#
+# Copyright (c) 2023 YunoHost Contributors
+#
+# This file is part of YunoHost (see https://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 .
+#
from moulinette.core import MoulinetteError, MoulinetteAuthenticationError
from moulinette import m18n
class YunohostError(MoulinetteError):
-
http_code = 500
"""
@@ -46,7 +42,6 @@ class YunohostError(MoulinetteError):
super(YunohostError, self).__init__(msg, raw_msg=True)
def content(self):
-
if not self.log_ref:
return super().content()
else:
@@ -54,14 +49,11 @@ class YunohostError(MoulinetteError):
class YunohostValidationError(YunohostError):
-
http_code = 400
def content(self):
-
return {"error": self.strerror, "error_key": self.key, **self.kwargs}
class YunohostAuthenticationError(MoulinetteAuthenticationError):
-
pass
diff --git a/src/utils/filesystem.py b/src/utils/filesystem.py
deleted file mode 100644
index 04d7d3906..000000000
--- a/src/utils/filesystem.py
+++ /dev/null
@@ -1,31 +0,0 @@
-# -*- 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
-
-
-def free_space_in_directory(dirpath):
- stat = os.statvfs(dirpath)
- return stat.f_frsize * stat.f_bavail
-
-
-def space_used_by_directory(dirpath):
- stat = os.statvfs(dirpath)
- return stat.f_frsize * stat.f_blocks
diff --git a/src/utils/config.py b/src/utils/form.py
similarity index 53%
rename from src/utils/config.py
rename to src/utils/form.py
index ec7faa719..12c3249c3 100644
--- a/src/utils/config.py
+++ b/src/utils/form.py
@@ -1,53 +1,47 @@
-# -*- 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 glob
-import os
-import re
-import urllib.parse
-import tempfile
-import shutil
+#
+# Copyright (c) 2023 YunoHost Contributors
+#
+# This file is part of YunoHost (see https://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 .
+#
import ast
import operator as op
-from collections import OrderedDict
-from typing import Optional, Dict, List, Union, Any, Mapping, Callable
+import os
+import re
+import shutil
+import tempfile
+import urllib.parse
+from typing import Any, Callable, Dict, List, Mapping, Optional, Union
-from moulinette.interfaces.cli import colorize
from moulinette import Moulinette, m18n
+from moulinette.interfaces.cli import colorize
+from moulinette.utils.filesystem import read_file, write_to_file
from moulinette.utils.log import getActionLogger
-from moulinette.utils.filesystem import (
- read_file,
- 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
+from yunohost.utils.error import YunohostError, YunohostValidationError
+from yunohost.utils.i18n import _value_for_locale
+
+logger = getActionLogger("yunohost.form")
+
+
+# ╭───────────────────────────────────────────────────────╮
+# │ ┌─╴╷ ╷╭─┐╷ │
+# │ ├─╴│╭╯├─┤│ │
+# │ ╰─╴╰╯ ╵ ╵╰─╴ │
+# ╰───────────────────────────────────────────────────────╯
-logger = getActionLogger("yunohost.config")
-CONFIG_PANEL_VERSION_SUPPORTED = 1.0
# Those js-like evaluate functions are used to eval safely visible attributes
# The goal is to evaluate in the same way than js simple-evaluate
@@ -192,500 +186,14 @@ def evaluate_simple_js_expression(expr, context={}):
return evaluate_simple_ast(node, context)
-class ConfigPanel:
- entity_type = "config"
- save_path_tpl: Union[str, None] = None
- config_path_tpl = "/usr/share/yunohost/config_{entity_type}.toml"
- save_mode = "full"
+# ╭───────────────────────────────────────────────────────╮
+# │ ╭─╮┌─╮╶┬╴╶┬╴╭─╮╭╮╷╭─╴ │
+# │ │ │├─╯ │ │ │ ││││╰─╮ │
+# │ ╰─╯╵ ╵ ╶┴╴╰─╯╵╰╯╶─╯ │
+# ╰───────────────────────────────────────────────────────╯
- @classmethod
- def list(cls):
- """
- List available config panel
- """
- try:
- entities = [
- re.match(
- "^" + cls.save_path_tpl.format(entity="(?p)") + "$", f
- ).group("entity")
- for f in glob.glob(cls.save_path_tpl.format(entity="*"))
- if os.path.isfile(f)
- ]
- except FileNotFoundError:
- entities = []
- return entities
- def __init__(self, entity, config_path=None, save_path=None, creation=False):
- self.entity = entity
- self.config_path = config_path
- if not config_path:
- self.config_path = self.config_path_tpl.format(
- entity=entity, entity_type=self.entity_type
- )
- self.save_path = save_path
- if not save_path and self.save_path_tpl:
- self.save_path = self.save_path_tpl.format(entity=entity)
- self.config = {}
- self.values = {}
- self.new_values = {}
-
- if (
- self.save_path
- and self.save_mode != "diff"
- and not creation
- and not os.path.exists(self.save_path)
- ):
- raise YunohostValidationError(
- f"{self.entity_type}_unknown", **{self.entity_type: entity}
- )
- if self.save_path and creation and os.path.exists(self.save_path):
- raise YunohostValidationError(
- f"{self.entity_type}_exists", **{self.entity_type: entity}
- )
-
- # Search for hooks in the config panel
- self.hooks = {
- func: getattr(self, func)
- for func in dir(self)
- if callable(getattr(self, func))
- and re.match("^(validate|post_ask)__", func)
- }
-
- 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":
- option["ask"] = ask
- question_class = ARGUMENTS_TYPE_PARSERS[option.get("type", "string")]
- # FIXME : maybe other properties should be taken from the question, not just choices ?.
- option["choices"] = question_class(option).choices
- option["default"] = question_class(option).default
- option["pattern"] = question_class(option).pattern
- 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
- )
- # FIXME: semantics, technically here this is not about a prompt...
- if question_class.hide_user_input_in_prompt:
- result[key][
- "value"
- ] = "**************" # Prevent displaying password in `config get`
-
- 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")
- self._parse_pre_answered(args, value, args_file)
-
- # Read or get values and hydrate the config
- self._load_current_values()
- self._hydrate()
- Question.operation_logger = operation_logger
- 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
- # FIXME : this is currently done in the context of config panels,
- # but could also happen in the context of app install ... (or anywhere else
- # where we may parse args etc...)
- 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 = {
- "root": {
- "properties": ["version", "i18n"],
- "defaults": {"version": 1.0},
- },
- "panels": {
- "properties": ["name", "services", "actions", "help"],
- "defaults": {
- "services": [],
- "actions": {"apply": {"en": "Apply"}},
- },
- },
- "sections": {
- "properties": ["name", "services", "optional", "help", "visible"],
- "defaults": {
- "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",
- "filter",
- ],
- "defaults": {},
- },
- }
-
- def _build_internal_config_panel(raw_infos, level):
- """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
- """
-
- defaults = format_description[level]["defaults"]
- properties = format_description[level]["properties"]
-
- # Start building the ouput (merging the raw infos + defaults)
- out = {key: raw_infos.get(key, value) for key, value in defaults.items()}
-
- # Now fill the sublevels (+ apply filter_key)
- i = list(format_description).index(level)
- sublevel = list(format_description)[i + 1] if level != "options" else None
- search_key = filter_key[i] if len(filter_key) > i else False
-
- for key, value in raw_infos.items():
- # Key/value are a child node
- if (
- isinstance(value, OrderedDict)
- and key not in properties
- and sublevel
- ):
- # We exclude all nodes not referenced by the filter_key
- if search_key and key != search_key:
- continue
- subnode = _build_internal_config_panel(value, sublevel)
- subnode["id"] = key
- if level == "root":
- subnode.setdefault("name", {"en": key.capitalize()})
- elif level == "sections":
- subnode["name"] = key # legacy
- subnode.setdefault("optional", raw_infos.get("optional", True))
- out.setdefault(sublevel, []).append(subnode)
- # Key/value are a property
- else:
- if key not in properties:
- logger.warning(f"Unknown key '{key}' found in config panel")
- # Todo search all i18n keys
- out[key] = (
- value if key not in ["ask", "help", "name"] else {"en": value}
- )
- return out
-
- self.config = _build_internal_config_panel(toml_config_panel, "root")
-
- 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
- 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
- prefilled_answers = self.args.copy()
- prefilled_answers.update(self.new_values)
-
- questions = ask_questions_and_parse_answers(
- section["options"],
- prefilled_answers=prefilled_answers,
- current_values=self.values,
- hooks=self.hooks,
- )
- self.new_values.update(
- {
- question.name: question.value
- for question in questions
- if question.value is not None
- }
- )
-
- self.errors = None
-
- def _get_default_values(self):
- return {
- option["id"]: option["default"]
- for _, _, option in self._iterate()
- if "default" in option
- }
-
- @property
- def future_values(self):
- return {**self.values, **self.new_values}
-
- def __getattr__(self, name):
- if "new_values" in self.__dict__ and name in self.new_values:
- return self.new_values[name]
-
- if "values" in self.__dict__ and name in self.values:
- return self.values[name]
-
- return self.__dict__[name]
-
- def _load_current_values(self):
- """
- Retrieve entries in YAML file
- And set default values if needed
- """
-
- # Inject defaults if needed (using the magic .update() ;))
- self.values = self._get_default_values()
-
- # Retrieve entries in the YAML
- if os.path.exists(self.save_path) and os.path.isfile(self.save_path):
- self.values.update(read_yaml(self.save_path) or {})
-
- def _parse_pre_answered(self, args, value, args_file):
- 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}
-
- 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.future_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, "entity"):
- service = service.replace("__APP__", self.entity)
- 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:
+class BaseOption:
hide_user_input_in_prompt = False
pattern: Optional[Dict] = None
@@ -702,10 +210,13 @@ class Question:
self.default = question.get("default", None)
self.optional = question.get("optional", False)
self.visible = question.get("visible", None)
+ self.readonly = question.get("readonly", False)
# Don't restrict choices if there's none specified
self.choices = question.get("choices", None)
self.pattern = question.get("pattern", self.pattern)
- self.ask = question.get("ask", {"en": self.name})
+ self.ask = question.get("ask", self.name)
+ if not isinstance(self.ask, dict):
+ self.ask = {"en": self.ask}
self.help = question.get("help")
self.redact = question.get("redact", False)
self.filter = question.get("filter", None)
@@ -730,6 +241,60 @@ class Question:
value = value.strip()
return value
+ def ask_if_needed(self):
+ if self.visible and not evaluate_simple_js_expression(
+ self.visible, context=self.context
+ ):
+ # FIXME There could be several use case if the question is not displayed:
+ # - we doesn't want to give a specific value
+ # - we want to keep the previous value
+ # - we want the default value
+ self.value = self.values[self.name] = None
+ return self.values
+
+ 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 self.readonly:
+ Moulinette.display(text_for_user_input_in_cli)
+ self.value = self.values[self.name] = self.current_value
+ return self.values
+ 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
+
+ try:
+ # Normalize and validate
+ self.value = self.normalize(self.value, self)
+ self._value_pre_validator()
+ 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.values[self.name] = self._value_post_validator()
+
+ # Search for post actions in hooks
+ post_hook = f"post_ask__{self.name}"
+ if post_hook in self.hooks:
+ self.values.update(self.hooks[post_hook](self))
+
+ return self.values
+
def _prompt(self, text):
prefill = ""
if self.current_value is not None:
@@ -746,85 +311,17 @@ class Question:
help=_value_for_locale(self.help),
)
- def ask_if_needed(self):
-
- if self.visible and not evaluate_simple_js_expression(
- self.visible, context=self.context
- ):
- # FIXME There could be several use case if the question is not displayed:
- # - we doesn't want to give a specific value
- # - we want to keep the previous value
- # - we want the default value
- self.value = self.values[self.name] = None
- return self.values
-
- 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
-
- try:
- # Normalize and validate
- self.value = self.normalize(self.value, self)
- 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.values[self.name] = self._post_parse_value()
-
- # Search for post actions in hooks
- post_hook = f"post_ask__{self.name}"
- if post_hook in self.hooks:
- self.values.update(self.hooks[post_hook](self))
-
- return self.values
-
- 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:
- raise YunohostValidationError(
- "app_argument_choice_invalid",
- name=self.name,
- value=self.value,
- choices=", ".join(self.choices),
- )
- 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 _format_text_for_user_input_in_cli(self):
-
text_for_user_input_in_cli = _value_for_locale(self.ask)
- if self.choices:
-
+ if self.readonly:
+ text_for_user_input_in_cli = colorize(text_for_user_input_in_cli, "purple")
+ if self.choices:
+ return (
+ text_for_user_input_in_cli + f" {self.choices[self.current_value]}"
+ )
+ return text_for_user_input_in_cli + f" {self.humanize(self.current_value)}"
+ elif self.choices:
# Prevent displaying a shitload of choices
# (e.g. 100+ available users when choosing an app admin...)
choices = (
@@ -846,7 +343,27 @@ class Question:
return text_for_user_input_in_cli
- def _post_parse_value(self):
+ def _value_pre_validator(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:
+ raise YunohostValidationError(
+ "app_argument_choice_invalid",
+ name=self.name,
+ value=self.value,
+ choices=", ".join(str(choice) for choice in self.choices),
+ )
+ 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 _value_post_validator(self):
if not self.redact:
return self.value
@@ -870,92 +387,66 @@ class Question:
return self.value
-class StringQuestion(Question):
+# ╭───────────────────────────────────────────────────────╮
+# │ DISPLAY OPTIONS │
+# ╰───────────────────────────────────────────────────────╯
+
+
+class DisplayTextOption(BaseOption):
+ argument_type = "display_text"
+
+ def __init__(
+ self, question, context: Mapping[str, Any] = {}, hooks: Dict[str, Callable] = {}
+ ):
+ super().__init__(question, context, hooks)
+
+ self.optional = True
+ self.readonly = 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 ButtonOption(BaseOption):
+ argument_type = "button"
+ enabled = None
+
+ def __init__(
+ self, question, context: Mapping[str, Any] = {}, hooks: Dict[str, Callable] = {}
+ ):
+ super().__init__(question, context, hooks)
+ self.enabled = question.get("enabled", None)
+
+
+# ╭───────────────────────────────────────────────────────╮
+# │ INPUT OPTIONS │
+# ╰───────────────────────────────────────────────────────╯
+
+
+# ─ STRINGS ───────────────────────────────────────────────
+
+
+class StringOption(BaseOption):
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)
- if isinstance(value, str):
- value = value.strip()
- 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):
+class PasswordOption(BaseOption):
hide_user_input_in_prompt = True
argument_type = "password"
default_value = ""
@@ -971,8 +462,8 @@ class PasswordQuestion(Question):
"app_argument_password_no_default", name=self.name
)
- def _prevalidate(self):
- super()._prevalidate()
+ def _value_pre_validator(self):
+ super()._value_pre_validator()
if self.value not in [None, ""]:
if any(char in self.value for char in self.forbidden_chars):
@@ -986,46 +477,95 @@ class PasswordQuestion(Question):
assert_password_is_strong_enough("user", self.value)
-class PathQuestion(Question):
- argument_type = "path"
- default_value = ""
+class ColorOption(StringOption):
+ pattern = {
+ "regexp": r"^#[ABCDEFabcdef\d]{3,6}$",
+ "error": "config_validate_color", # i18n: config_validate_color
+ }
+
+
+# ─ NUMERIC ───────────────────────────────────────────────
+
+
+class NumberOption(BaseOption):
+ argument_type = "number"
+ default_value = None
+
+ def __init__(
+ self, question, context: Mapping[str, Any] = {}, hooks: Dict[str, Callable] = {}
+ ):
+ super().__init__(question, context, hooks)
+ 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
- option = option.__dict__ if isinstance(option, Question) else option
+ if isinstance(value, str):
+ value = value.strip()
- if not value.strip():
- if option.get("optional"):
- return ""
- # Hmpf here we could just have a "else" case
- # but we also want PathQuestion.normalize("") to return "/"
- # (i.e. if no option is provided, hence .get("optional") is None
- elif option.get("optional") is False:
- raise YunohostValidationError(
- "app_argument_invalid",
- name=option.get("name"),
- error="Question is mandatory",
- )
+ if isinstance(value, str) and value.isdigit():
+ return int(value)
- return "/" + value.strip().strip(" /")
+ if value in [None, ""]:
+ return None
+
+ option = option.__dict__ if isinstance(option, BaseOption) else option
+ raise YunohostValidationError(
+ "app_argument_invalid",
+ name=option.get("name"),
+ error=m18n.n("invalid_number"),
+ )
+
+ def _value_pre_validator(self):
+ super()._value_pre_validator()
+ 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 BooleanQuestion(Question):
+# ─ BOOLEAN ───────────────────────────────────────────────
+
+
+class BooleanOption(BaseOption):
argument_type = "boolean"
default_value = 0
yes_answers = ["1", "yes", "y", "true", "t", "on"]
no_answers = ["0", "no", "n", "false", "f", "off"]
+ def __init__(
+ self, question, context: Mapping[str, Any] = {}, hooks: Dict[str, Callable] = {}
+ ):
+ super().__init__(question, context, hooks)
+ self.yes = question.get("yes", 1)
+ self.no = question.get("no", 0)
+ if self.default is None:
+ self.default = self.no
+
@staticmethod
def humanize(value, option={}):
-
- option = option.__dict__ if isinstance(option, Question) else option
+ option = option.__dict__ if isinstance(option, BaseOption) else option
yes = option.get("yes", 1)
no = option.get("no", 0)
- value = BooleanQuestion.normalize(value, option)
+ value = BooleanOption.normalize(value, option)
if value == yes:
return "yes"
@@ -1043,8 +583,7 @@ class BooleanQuestion(Question):
@staticmethod
def normalize(value, option={}):
-
- option = option.__dict__ if isinstance(option, Question) else option
+ option = option.__dict__ if isinstance(option, BaseOption) else option
if isinstance(value, str):
value = value.strip()
@@ -1052,8 +591,8 @@ class BooleanQuestion(Question):
technical_yes = option.get("yes", 1)
technical_no = option.get("no", 0)
- no_answers = BooleanQuestion.no_answers
- yes_answers = BooleanQuestion.yes_answers
+ no_answers = BooleanOption.no_answers
+ yes_answers = BooleanOption.yes_answers
assert (
str(technical_yes).lower() not in no_answers
@@ -1082,27 +621,216 @@ class BooleanQuestion(Question):
choices="yes/no",
)
- def __init__(
- self, question, context: Mapping[str, Any] = {}, hooks: Dict[str, Callable] = {}
- ):
- super().__init__(question, context, hooks)
- self.yes = question.get("yes", 1)
- self.no = question.get("no", 0)
- if self.default is None:
- self.default = self.no
+ def get(self, key, default=None):
+ return getattr(self, key, default)
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]"
+ if not self.readonly:
+ text_for_user_input_in_cli += " [yes | no]"
return text_for_user_input_in_cli
- def get(self, key, default=None):
- return getattr(self, key, default)
+
+# ─ TIME ──────────────────────────────────────────────────
-class DomainQuestion(Question):
+class DateOption(StringOption):
+ pattern = {
+ "regexp": r"^\d{4}-\d\d-\d\d$",
+ "error": "config_validate_date", # i18n: config_validate_date
+ }
+
+ def _value_pre_validator(self):
+ from datetime import datetime
+
+ super()._value_pre_validator()
+
+ if self.value not in [None, ""]:
+ try:
+ datetime.strptime(self.value, "%Y-%m-%d")
+ except ValueError:
+ raise YunohostValidationError("config_validate_date")
+
+
+class TimeOption(StringOption):
+ pattern = {
+ "regexp": r"^(?:\d|[01]\d|2[0-3]):[0-5]\d$",
+ "error": "config_validate_time", # i18n: config_validate_time
+ }
+
+
+# ─ LOCATIONS ─────────────────────────────────────────────
+
+
+class EmailOption(StringOption):
+ pattern = {
+ "regexp": r"^.+@.+",
+ "error": "config_validate_email", # i18n: config_validate_email
+ }
+
+
+class WebPathOption(BaseOption):
+ argument_type = "path"
+ default_value = ""
+
+ @staticmethod
+ def normalize(value, option={}):
+ option = option.__dict__ if isinstance(option, BaseOption) else option
+
+ if not isinstance(value, str):
+ raise YunohostValidationError(
+ "app_argument_invalid",
+ name=option.get("name"),
+ error="Argument for path should be a string.",
+ )
+
+ if not value.strip():
+ if option.get("optional"):
+ return ""
+ # Hmpf here we could just have a "else" case
+ # but we also want WebPathOption.normalize("") to return "/"
+ # (i.e. if no option is provided, hence .get("optional") is None
+ elif option.get("optional") is False:
+ raise YunohostValidationError(
+ "app_argument_invalid",
+ name=option.get("name"),
+ error="Option is mandatory",
+ )
+
+ return "/" + value.strip().strip(" /")
+
+
+class URLOption(StringOption):
+ pattern = {
+ "regexp": r"^https?://.*$",
+ "error": "config_validate_url", # i18n: config_validate_url
+ }
+
+
+# ─ FILE ──────────────────────────────────────────────────
+
+
+class FileOption(BaseOption):
+ argument_type = "file"
+ upload_dirs: List[str] = []
+
+ def __init__(
+ self, question, context: Mapping[str, Any] = {}, hooks: Dict[str, Callable] = {}
+ ):
+ super().__init__(question, context, hooks)
+ self.accept = question.get("accept", "")
+
+ @classmethod
+ def clean_upload_dirs(cls):
+ # Delete files uploaded from API
+ for upload_dir in cls.upload_dirs:
+ if os.path.exists(upload_dir):
+ shutil.rmtree(upload_dir)
+
+ def _value_pre_validator(self):
+ if self.value is None:
+ self.value = self.current_value
+
+ super()._value_pre_validator()
+
+ # Validation should have already failed if required
+ if self.value in [None, ""]:
+ return self.value
+
+ if Moulinette.interface.type != "api":
+ if not os.path.exists(str(self.value)) or not os.path.isfile(
+ str(self.value)
+ ):
+ raise YunohostValidationError(
+ "app_argument_invalid",
+ name=self.name,
+ error=m18n.n("file_does_not_exist", path=str(self.value)),
+ )
+
+ def _value_post_validator(self):
+ from base64 import b64decode
+
+ if not self.value:
+ return ""
+
+ upload_dir = tempfile.mkdtemp(prefix="ynh_filequestion_")
+ _, file_path = tempfile.mkstemp(dir=upload_dir)
+
+ FileOption.upload_dirs += [upload_dir]
+
+ logger.debug(f"Saving file {self.name} for file question into {file_path}")
+
+ def is_file_path(s):
+ return isinstance(s, str) and s.startswith("/") and os.path.exists(s)
+
+ if Moulinette.interface.type != "api" or is_file_path(self.value):
+ content = read_file(str(self.value), file_mode="rb")
+ else:
+ content = b64decode(self.value)
+
+ write_to_file(file_path, content, file_mode="wb")
+
+ self.value = file_path
+
+ return self.value
+
+
+# ─ CHOICES ───────────────────────────────────────────────
+
+
+class TagsOption(BaseOption):
+ argument_type = "tags"
+ default_value = ""
+
+ @staticmethod
+ def humanize(value, option={}):
+ if isinstance(value, list):
+ return ",".join(str(v) for v in value)
+ return value
+
+ @staticmethod
+ def normalize(value, option={}):
+ if isinstance(value, list):
+ return ",".join(str(v) for v in value)
+ if isinstance(value, str):
+ value = value.strip()
+ return value
+
+ def _value_pre_validator(self):
+ values = self.value
+ if isinstance(values, str):
+ values = values.split(",")
+ elif values is None:
+ values = []
+
+ if not isinstance(values, list):
+ if self.choices:
+ raise YunohostValidationError(
+ "app_argument_choice_invalid",
+ name=self.name,
+ value=self.value,
+ choices=", ".join(str(choice) for choice in self.choices),
+ )
+ raise YunohostValidationError(
+ "app_argument_invalid",
+ name=self.name,
+ error=f"'{str(self.value)}' is not a list",
+ )
+
+ for value in values:
+ self.value = value
+ super()._value_pre_validator()
+ self.value = values
+
+ def _value_post_validator(self):
+ if isinstance(self.value, list):
+ self.value = ",".join(self.value)
+ return super()._value_post_validator()
+
+
+class DomainOption(BaseOption):
argument_type = "domain"
def __init__(
@@ -1133,7 +861,7 @@ class DomainQuestion(Question):
return value
-class AppQuestion(Question):
+class AppOption(BaseOption):
argument_type = "app"
def __init__(
@@ -1160,7 +888,7 @@ class AppQuestion(Question):
self.choices.update({app["id"]: _app_display(app) for app in apps})
-class UserQuestion(Question):
+class UserOption(BaseOption):
argument_type = "user"
def __init__(
@@ -1184,6 +912,8 @@ class UserQuestion(Question):
)
if self.default is None:
+ # FIXME: this code is obsolete with the new admins group
+ # Should be replaced by something like "any first user we find in the admin group"
root_mail = "root@%s" % _get_maindomain()
for user in self.choices.keys():
if root_mail in user_info(user).get("mail-aliases", []):
@@ -1191,180 +921,72 @@ class UserQuestion(Question):
break
-class NumberQuestion(Question):
- argument_type = "number"
- default_value = None
+class GroupOption(BaseOption):
+ argument_type = "group"
def __init__(
self, question, context: Mapping[str, Any] = {}, hooks: Dict[str, Callable] = {}
):
- super().__init__(question, context, hooks)
- self.min = question.get("min", None)
- self.max = question.get("max", None)
- self.step = question.get("step", None)
+ from yunohost.user import user_group_list
- @staticmethod
- def normalize(value, option={}):
+ super().__init__(question, context)
- if isinstance(value, int):
- return value
-
- if isinstance(value, str):
- value = value.strip()
-
- if isinstance(value, str) and value.isdigit():
- return int(value)
-
- if value in [None, ""]:
- return value
-
- option = option.__dict__ if isinstance(option, Question) else option
- raise YunohostValidationError(
- "app_argument_invalid",
- name=option.get("name"),
- error=m18n.n("invalid_number"),
+ self.choices = list(
+ user_group_list(short=True, include_primary_groups=False)["groups"]
)
- def _prevalidate(self):
- super()._prevalidate()
- if self.value in [None, ""]:
- return
+ def _human_readable_group(g):
+ # i18n: visitors
+ # i18n: all_users
+ # i18n: admins
+ return m18n.n(g) if g in ["visitors", "all_users", "admins"] else g
- 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),
- )
+ self.choices = {g: _human_readable_group(g) for g in self.choices}
- 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),
- )
+ if self.default is None:
+ self.default = "all_users"
-class DisplayTextQuestion(Question):
- argument_type = "display_text"
- readonly = True
-
- def __init__(
- self, question, context: Mapping[str, Any] = {}, hooks: Dict[str, Callable] = {}
- ):
- super().__init__(question, context, hooks)
-
- 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
- for upload_dir in cls.upload_dirs:
- if os.path.exists(upload_dir):
- shutil.rmtree(upload_dir)
-
- def __init__(
- self, question, context: Mapping[str, Any] = {}, hooks: Dict[str, Callable] = {}
- ):
- super().__init__(question, context, hooks)
- self.accept = question.get("accept", "")
-
- def _prevalidate(self):
- if self.value is None:
- self.value = self.current_value
-
- super()._prevalidate()
-
- if Moulinette.interface.type != "api":
- if not self.value or not os.path.exists(str(self.value)):
- raise YunohostValidationError(
- "app_argument_invalid",
- name=self.name,
- error=m18n.n("file_does_not_exist", path=str(self.value)),
- )
-
- def _post_parse_value(self):
- from base64 import b64decode
-
- if not self.value:
- return self.value
-
- upload_dir = tempfile.mkdtemp(prefix="ynh_filequestion_")
- _, file_path = tempfile.mkstemp(dir=upload_dir)
-
- FileQuestion.upload_dirs += [upload_dir]
-
- logger.debug(f"Saving file {self.name} for file question into {file_path}")
-
- def is_file_path(s):
- return isinstance(s, str) and s.startswith("/") and os.path.exists(s)
-
- if Moulinette.interface.type != "api" or is_file_path(self.value):
- content = read_file(str(self.value), file_mode="rb")
- else:
- content = b64decode(self.value)
-
- write_to_file(file_path, 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,
- "app": AppQuestion,
+OPTIONS = {
+ "display_text": DisplayTextOption,
+ "markdown": DisplayTextOption,
+ "alert": DisplayTextOption,
+ "button": ButtonOption,
+ "string": StringOption,
+ "text": StringOption,
+ "password": PasswordOption,
+ "color": ColorOption,
+ "number": NumberOption,
+ "range": NumberOption,
+ "boolean": BooleanOption,
+ "date": DateOption,
+ "time": TimeOption,
+ "email": EmailOption,
+ "path": WebPathOption,
+ "url": URLOption,
+ "file": FileOption,
+ "select": StringOption,
+ "tags": TagsOption,
+ "domain": DomainOption,
+ "app": AppOption,
+ "user": UserOption,
+ "group": GroupOption,
}
+# ╭───────────────────────────────────────────────────────╮
+# │ ╷ ╷╶┬╴╶┬╴╷ ╭─╴ │
+# │ │ │ │ │ │ ╰─╮ │
+# │ ╰─╯ ╵ ╶┴╴╰─╴╶─╯ │
+# ╰───────────────────────────────────────────────────────╯
+
+
def ask_questions_and_parse_answers(
raw_questions: Dict,
prefilled_answers: Union[str, Mapping[str, Any]] = {},
current_values: Mapping[str, Any] = {},
hooks: Dict[str, Callable[[], None]] = {},
-) -> List[Question]:
+) -> List[BaseOption]:
"""Parse arguments store in either manifest.json or actions.json or from a
config panel against the user answers when they are present.
@@ -1393,10 +1015,23 @@ def ask_questions_and_parse_answers(
context = {**current_values, **answers}
out = []
- for raw_question in raw_questions:
- question_class = ARGUMENTS_TYPE_PARSERS[raw_question.get("type", "string")]
- raw_question["value"] = answers.get(raw_question["name"])
+ for name, raw_question in raw_questions.items():
+ raw_question["name"] = name
+ question_class = OPTIONS[raw_question.get("type", "string")]
+ raw_question["value"] = answers.get(name)
question = question_class(raw_question, context=context, hooks=hooks)
+ if question.type == "button":
+ if question.enabled is None or evaluate_simple_js_expression( # type: ignore
+ question.enabled, context=context # type: ignore
+ ): # type: ignore
+ continue
+ else:
+ raise YunohostValidationError(
+ "config_action_disabled",
+ action=question.name,
+ help=_value_for_locale(question.help),
+ )
+
new_values = question.ask_if_needed()
answers.update(new_values)
context.update(new_values)
@@ -1409,9 +1044,7 @@ def hydrate_questions_with_choices(raw_questions: List) -> List:
out = []
for raw_question in raw_questions:
- question = ARGUMENTS_TYPE_PARSERS[raw_question.get("type", "string")](
- raw_question
- )
+ question = OPTIONS[raw_question.get("type", "string")](raw_question)
if question.choices:
raw_question["choices"] = question.choices
raw_question["default"] = question.default
diff --git a/src/utils/i18n.py b/src/utils/i18n.py
index a0daf8181..2aafafbdd 100644
--- a/src/utils/i18n.py
+++ b/src/utils/i18n.py
@@ -1,23 +1,21 @@
-# -*- 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
-
-"""
+#
+# Copyright (c) 2023 YunoHost Contributors
+#
+# This file is part of YunoHost (see https://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 .
+#
from moulinette import m18n
diff --git a/src/utils/ldap.py b/src/utils/ldap.py
index b204460fe..11141dcb0 100644
--- a/src/utils/ldap.py
+++ b/src/utils/ldap.py
@@ -1,23 +1,21 @@
-# -*- coding: utf-8 -*-
-""" License
-
- Copyright (C) 2019 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
-
-"""
-
+#
+# Copyright (c) 2023 YunoHost Contributors
+#
+# This file is part of YunoHost (see https://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 .
+#
import os
import atexit
import logging
@@ -38,7 +36,6 @@ _ldap_interface = None
def _get_ldap_interface():
-
global _ldap_interface
if _ldap_interface is None:
@@ -96,7 +93,7 @@ class LDAPInterface:
def _reconnect():
con = ldap.ldapobject.ReconnectLDAPObject(
- URI, retry_max=10, retry_delay=0.5
+ URI, retry_max=10, retry_delay=2
)
self._connect(con)
return con
@@ -157,6 +154,8 @@ class LDAPInterface:
try:
result = self.con.search_s(base, ldap.SCOPE_SUBTREE, filter, attrs)
+ except ldap.SERVER_DOWN as e:
+ raise e
except Exception as e:
raise MoulinetteError(
"error during LDAP search operation with: base='%s', "
diff --git a/src/utils/legacy.py b/src/utils/legacy.py
index 5e5d15fe8..82507d64d 100644
--- a/src/utils/legacy.py
+++ b/src/utils/legacy.py
@@ -1,3 +1,21 @@
+#
+# Copyright (c) 2023 YunoHost Contributors
+#
+# This file is part of YunoHost (see https://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 .
+#
import os
import re
import glob
@@ -62,6 +80,32 @@ LEGACY_PERMISSION_LABEL = {
): "api", # $excaped_domain$excaped_path/[%w-.]*/[%w-.]*/git%-receive%-pack,$excaped_domain$excaped_path/[%w-.]*/[%w-.]*/git%-upload%-pack,$excaped_domain$excaped_path/[%w-.]*/[%w-.]*/info/refs
}
+LEGACY_SETTINGS = {
+ "security.password.admin.strength": "security.password.admin_strength",
+ "security.password.user.strength": "security.password.user_strength",
+ "security.ssh.compatibility": "security.ssh.ssh_compatibility",
+ "security.ssh.port": "security.ssh.ssh_port",
+ "security.ssh.password_authentication": "security.ssh.ssh_password_authentication",
+ "security.nginx.redirect_to_https": "security.nginx.nginx_redirect_to_https",
+ "security.nginx.compatibility": "security.nginx.nginx_compatibility",
+ "security.postfix.compatibility": "security.postfix.postfix_compatibility",
+ "pop3.enabled": "email.pop3.pop3_enabled",
+ "smtp.allow_ipv6": "email.smtp.smtp_allow_ipv6",
+ "smtp.relay.host": "email.smtp.smtp_relay_host",
+ "smtp.relay.port": "email.smtp.smtp_relay_port",
+ "smtp.relay.user": "email.smtp.smtp_relay_user",
+ "smtp.relay.password": "email.smtp.smtp_relay_password",
+ "backup.compress_tar_archives": "misc.backup.backup_compress_tar_archives",
+ "ssowat.panel_overlay.enabled": "misc.portal.ssowat_panel_overlay_enabled",
+ "security.webadmin.allowlist.enabled": "security.webadmin.webadmin_allowlist_enabled",
+ "security.webadmin.allowlist": "security.webadmin.webadmin_allowlist",
+ "security.experimental.enabled": "security.experimental.security_experimental_enabled",
+}
+
+
+def translate_legacy_settings_to_configpanel_settings(settings):
+ return LEGACY_SETTINGS.get(settings, settings)
+
def legacy_permission_label(app, permission_type):
return LEGACY_PERMISSION_LABEL.get(
@@ -149,7 +193,6 @@ LEGACY_PHP_VERSION_REPLACEMENTS = [
def _patch_legacy_php_versions(app_folder):
-
files_to_patch = []
files_to_patch.extend(glob.glob("%s/conf/*" % app_folder))
files_to_patch.extend(glob.glob("%s/scripts/*" % app_folder))
@@ -159,7 +202,6 @@ def _patch_legacy_php_versions(app_folder):
files_to_patch.append("%s/manifest.toml" % app_folder)
for filename in files_to_patch:
-
# Ignore non-regular files
if not os.path.isfile(filename):
continue
@@ -173,7 +215,6 @@ def _patch_legacy_php_versions(app_folder):
def _patch_legacy_php_versions_in_settings(app_folder):
-
settings = read_yaml(os.path.join(app_folder, "settings.yml"))
if settings.get("fpm_config_dir") in ["/etc/php/7.0/fpm", "/etc/php/7.3/fpm"]:
@@ -199,7 +240,6 @@ def _patch_legacy_php_versions_in_settings(app_folder):
def _patch_legacy_helpers(app_folder):
-
files_to_patch = []
files_to_patch.extend(glob.glob("%s/scripts/*" % app_folder))
files_to_patch.extend(glob.glob("%s/scripts/.*" % app_folder))
@@ -209,6 +249,11 @@ def _patch_legacy_helpers(app_folder):
"yunohost app checkport": {"important": True},
"yunohost tools port-available": {"important": True},
"yunohost app checkurl": {"important": True},
+ "yunohost user create": {
+ "pattern": r"yunohost user create (\S+) (-f|--firstname) (\S+) (-l|--lastname) \S+ (.*)",
+ "replace": r"yunohost user create \1 --fullname \3 \5",
+ "important": False,
+ },
# Remove
# Automatic diagnosis data from YunoHost
# __PRE_TAG1__$(yunohost tools diagnosis | ...)__PRE_TAG2__"
@@ -242,7 +287,6 @@ def _patch_legacy_helpers(app_folder):
infos["replace"] = infos.get("replace")
for filename in files_to_patch:
-
# Ignore non-regular files
if not os.path.isfile(filename):
continue
@@ -256,7 +300,6 @@ def _patch_legacy_helpers(app_folder):
show_warning = False
for helper, infos in stuff_to_replace.items():
-
# Ignore if not relevant for this file
if infos.get("only_for") and not any(
filename.endswith(f) for f in infos["only_for"]
@@ -280,7 +323,6 @@ def _patch_legacy_helpers(app_folder):
)
if replaced_stuff:
-
# Check the app do load the helper
# If it doesn't, add the instruction ourselve (making sure it's after the #!/bin/bash if it's there...
if filename.split("/")[-1] in [
diff --git a/src/utils/network.py b/src/utils/network.py
index 28dcb204c..2a13f966e 100644
--- a/src/utils/network.py
+++ b/src/utils/network.py
@@ -1,23 +1,21 @@
-# -*- coding: utf-8 -*-
-
-""" License
-
- Copyright (C) 2017 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
-
-"""
+#
+# Copyright (c) 2023 YunoHost Contributors
+#
+# This file is part of YunoHost (see https://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 .
+#
import os
import re
import logging
@@ -31,7 +29,6 @@ logger = logging.getLogger("yunohost.utils.network")
def get_public_ip(protocol=4):
-
assert protocol in [4, 6], (
"Invalid protocol version for get_public_ip: %s, expected 4 or 6" % protocol
)
@@ -92,7 +89,6 @@ def get_public_ip_from_remote_server(protocol=4):
def get_network_interfaces():
-
# Get network devices and their addresses (raw infos from 'ip addr')
devices_raw = {}
output = check_output("ip addr show")
@@ -113,7 +109,6 @@ def get_network_interfaces():
def get_gateway():
-
output = check_output("ip route show")
m = re.search(r"default via (.*) dev ([a-z]+[0-9]?)", output)
if not m:
diff --git a/src/utils/password.py b/src/utils/password.py
index a38bc4e23..833933d33 100644
--- a/src/utils/password.py
+++ b/src/utils/password.py
@@ -1,29 +1,26 @@
-# -*- coding: utf-8 -*-
-
-""" License
-
- Copyright (C) 2018 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
-
-"""
-
+#
+# Copyright (c) 2023 YunoHost Contributors
+#
+# This file is part of YunoHost (see https://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 .
+#
import sys
import os
-import json
import string
import subprocess
+import yaml
SMALL_PWD_LIST = [
"yunohost",
@@ -36,7 +33,14 @@ SMALL_PWD_LIST = [
"rpi",
]
-MOST_USED_PASSWORDS = "/usr/share/yunohost/100000-most-used-passwords.txt"
+#
+# 100k firsts "most used password" with length 8+
+#
+# List obtained with:
+# curl -L https://github.com/danielmiessler/SecLists/raw/master/Passwords/Common-Credentials/10-million-password-list-top-1000000.txt \
+# | grep -v -E "^[a-zA-Z0-9]{1,7}$" | head -n 100000 | gzip > 100000-most-used-passwords-length8plus.txt.gz
+#
+MOST_USED_PASSWORDS = "/usr/share/yunohost/100000-most-used-passwords-length8plus.txt"
# Length, digits, lowers, uppers, others
STRENGTH_LEVELS = [
@@ -54,18 +58,16 @@ def assert_password_is_compatible(password):
"""
if len(password) >= 127:
-
# Note that those imports are made here and can't be put
# on top (at least not the moulinette ones)
# because the moulinette needs to be correctly initialized
# as well as modules available in python's path.
from yunohost.utils.error import YunohostValidationError
- raise YunohostValidationError("admin_password_too_long")
+ raise YunohostValidationError("password_too_long")
def assert_password_is_strong_enough(profile, password):
-
PasswordValidator(profile).validate(password)
@@ -76,7 +78,7 @@ class PasswordValidator:
The profile shall be either "user" or "admin"
and will correspond to a validation strength
- defined via the setting "security.password..strength"
+ defined via the setting "security.password._strength"
"""
self.profile = profile
@@ -85,9 +87,9 @@ class PasswordValidator:
# from settings.py because this file is also meant to be
# use as a script by ssowat.
# (or at least that's my understanding -- Alex)
- settings = json.load(open("/etc/yunohost/settings.json", "r"))
- setting_key = "security.password." + profile + ".strength"
- self.validation_strength = int(settings[setting_key]["value"])
+ settings = yaml.safe_load(open("/etc/yunohost/settings.yml", "r"))
+ setting_key = profile + "_strength"
+ self.validation_strength = int(settings[setting_key])
except Exception:
# Fallback to default value if we can't fetch settings for some reason
self.validation_strength = 1
@@ -193,7 +195,6 @@ class PasswordValidator:
return strength_level
def is_in_most_used_list(self, password):
-
# Decompress file if compressed
if os.path.exists("%s.gz" % MOST_USED_PASSWORDS):
os.system("gzip -fd %s.gz" % MOST_USED_PASSWORDS)
diff --git a/src/utils/resources.py b/src/utils/resources.py
new file mode 100644
index 000000000..8d33c3bac
--- /dev/null
+++ b/src/utils/resources.py
@@ -0,0 +1,1353 @@
+#
+# Copyright (c) 2023 YunoHost Contributors
+#
+# This file is part of YunoHost (see https://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 .
+#
+import os
+import copy
+import shutil
+import random
+import tempfile
+import subprocess
+from typing import Dict, Any, List, Union
+
+from moulinette import m18n
+from moulinette.utils.process import check_output
+from moulinette.utils.log import getActionLogger
+from moulinette.utils.filesystem import mkdir, chown, chmod, write_to_file
+from moulinette.utils.filesystem import (
+ rm,
+)
+from yunohost.utils.system import system_arch
+from yunohost.utils.error import YunohostError, YunohostValidationError
+
+logger = getActionLogger("yunohost.app_resources")
+
+
+class AppResourceManager:
+ def __init__(self, app: str, current: Dict, wanted: Dict):
+ self.app = app
+ self.current = current
+ self.wanted = wanted
+
+ if "resources" not in self.current:
+ self.current["resources"] = {}
+ if "resources" not in self.wanted:
+ self.wanted["resources"] = {}
+
+ def apply(
+ self, rollback_and_raise_exception_if_failure, operation_logger=None, **context
+ ):
+ todos = list(self.compute_todos())
+ completed = []
+ rollback = False
+ exception = None
+
+ for todo, name, old, new in todos:
+ try:
+ if todo == "deprovision":
+ # FIXME : i18n, better info strings
+ logger.info(f"Deprovisionning {name}...")
+ old.deprovision(context=context)
+ elif todo == "provision":
+ logger.info(f"Provisionning {name}...")
+ new.provision_or_update(context=context)
+ elif todo == "update":
+ logger.info(f"Updating {name}...")
+ new.provision_or_update(context=context)
+ except (KeyboardInterrupt, Exception) as e:
+ exception = e
+ if isinstance(e, KeyboardInterrupt):
+ logger.error(m18n.n("operation_interrupted"))
+ else:
+ logger.warning(f"Failed to {todo} {name} : {e}")
+ if rollback_and_raise_exception_if_failure:
+ rollback = True
+ completed.append((todo, name, old, new))
+ break
+ else:
+ pass
+ else:
+ completed.append((todo, name, old, new))
+
+ if rollback:
+ for todo, name, old, new in completed:
+ try:
+ # (NB. here we want to undo the todo)
+ if todo == "deprovision":
+ # FIXME : i18n, better info strings
+ logger.info(f"Reprovisionning {name}...")
+ old.provision_or_update(context=context)
+ elif todo == "provision":
+ logger.info(f"Deprovisionning {name}...")
+ new.deprovision(context=context)
+ elif todo == "update":
+ logger.info(f"Reverting {name}...")
+ old.provision_or_update(context=context)
+ except (KeyboardInterrupt, Exception) as e:
+ if isinstance(e, KeyboardInterrupt):
+ logger.error(m18n.n("operation_interrupted"))
+ else:
+ logger.error(f"Failed to rollback {name} : {e}")
+
+ if exception:
+ if rollback_and_raise_exception_if_failure:
+ logger.error(
+ m18n.n("app_resource_failed", app=self.app, error=exception)
+ )
+ if operation_logger:
+ failure_message_with_debug_instructions = operation_logger.error(
+ str(exception)
+ )
+ raise YunohostError(
+ failure_message_with_debug_instructions, raw_msg=True
+ )
+ else:
+ raise YunohostError(str(exception), raw_msg=True)
+ else:
+ logger.error(exception)
+
+ def compute_todos(self):
+ for name, infos in reversed(self.current["resources"].items()):
+ if name not in self.wanted["resources"].keys():
+ resource = AppResourceClassesByType[name](infos, self.app, self)
+ yield ("deprovision", name, resource, None)
+
+ for name, infos in self.wanted["resources"].items():
+ wanted_resource = AppResourceClassesByType[name](infos, self.app, self)
+ if name not in self.current["resources"].keys():
+ yield ("provision", name, None, wanted_resource)
+ else:
+ infos_ = self.current["resources"][name]
+ current_resource = AppResourceClassesByType[name](
+ infos_, self.app, self
+ )
+ yield ("update", name, current_resource, wanted_resource)
+
+
+class AppResource:
+ type: str = ""
+ default_properties: Dict[str, Any] = {}
+
+ def __init__(self, properties: Dict[str, Any], app: str, manager=None):
+ self.app = app
+ self.manager = manager
+
+ for key, value in self.default_properties.items():
+ if isinstance(value, str):
+ value = value.replace("__APP__", self.app)
+ setattr(self, key, value)
+
+ for key, value in properties.items():
+ if isinstance(value, str):
+ value = value.replace("__APP__", self.app)
+ setattr(self, key, value)
+
+ def get_setting(self, key):
+ from yunohost.app import app_setting
+
+ return app_setting(self.app, key)
+
+ def set_setting(self, key, value):
+ from yunohost.app import app_setting
+
+ app_setting(self.app, key, value=value)
+
+ def delete_setting(self, key):
+ from yunohost.app import app_setting
+
+ app_setting(self.app, key, delete=True)
+
+ def check_output_bash_snippet(self, snippet, env={}):
+ from yunohost.app import (
+ _make_environment_for_app_script,
+ )
+
+ env_ = _make_environment_for_app_script(
+ self.app,
+ force_include_app_settings=True,
+ )
+ env_.update(env)
+
+ with tempfile.NamedTemporaryFile(prefix="ynh_") as fp:
+ fp.write(snippet.encode())
+ fp.seek(0)
+ with tempfile.TemporaryFile() as stderr:
+ out = check_output(f"bash {fp.name}", env=env_, stderr=stderr)
+
+ stderr.seek(0)
+ err = stderr.read().decode()
+
+ return out, err
+
+ def _run_script(self, action, script, env={}):
+ from yunohost.app import (
+ _make_tmp_workdir_for_app,
+ _make_environment_for_app_script,
+ )
+ from yunohost.hook import hook_exec_with_script_debug_if_failure
+
+ tmpdir = _make_tmp_workdir_for_app(app=self.app)
+
+ env_ = _make_environment_for_app_script(
+ self.app,
+ workdir=tmpdir,
+ action=f"{action}_{self.type}",
+ force_include_app_settings=True,
+ )
+ env_.update(env)
+
+ script_path = f"{tmpdir}/{action}_{self.type}"
+ script = f"""
+source /usr/share/yunohost/helpers
+ynh_abort_if_errors
+
+{script}
+"""
+
+ write_to_file(script_path, script)
+
+ from yunohost.log import OperationLogger
+
+ # FIXME ? : this is an ugly hack :(
+ active_operation_loggers = [
+ o for o in OperationLogger._instances if o.ended_at is None
+ ]
+ if active_operation_loggers:
+ operation_logger = active_operation_loggers[-1]
+ else:
+ operation_logger = OperationLogger(
+ "resource_snippet", [("app", self.app)], env=env_
+ )
+ operation_logger.start()
+
+ try:
+ (
+ call_failed,
+ failure_message_with_debug_instructions,
+ ) = hook_exec_with_script_debug_if_failure(
+ script_path,
+ env=env_,
+ operation_logger=operation_logger,
+ error_message_if_script_failed="An error occured inside the script snippet",
+ error_message_if_failed=lambda e: f"{action} failed for {self.type} : {e}",
+ )
+ finally:
+ if call_failed:
+ raise YunohostError(
+ failure_message_with_debug_instructions, raw_msg=True
+ )
+ else:
+ # FIXME: currently in app install code, we have
+ # more sophisticated code checking if this broke something on the system etc.
+ # dunno if we want to do this here or manage it elsewhere
+ pass
+
+ # print(ret)
+
+
+class SourcesResource(AppResource):
+ """
+ Declare what are the sources / assets used by this app. Typically, this corresponds to some tarball published by the upstream project, that needs to be downloaded and extracted in the install dir using the ynh_setup_source helper.
+
+ This resource is intended both to declare the assets, which will be parsed by ynh_setup_source during the app script runtime, AND to prefetch and validate the sha256sum of those asset before actually running the script, to be able to report an error early when the asset turns out to not be available for some reason.
+
+ Various options are available to accomodate the behavior according to the asset structure
+
+ ##### Example
+
+ ```toml
+ [resources.sources]
+
+ [resources.sources.main]
+ url = "https://github.com/foo/bar/archive/refs/tags/v1.2.3.tar.gz"
+ sha256 = "01ba4719c80b6fe911b091a7c05124b64eeece964e09c058ef8f9805daca546b"
+
+ autoupdate.strategy = "latest_github_tag"
+ ```
+
+ Or more complex examples with several element, including one with asset that depends on the arch
+
+ ```toml
+ [resources.sources]
+
+ [resources.sources.main]
+ in_subdir = false
+ amd64.url = "https://github.com/foo/bar/archive/refs/tags/v1.2.3.amd64.tar.gz"
+ amd64.sha256 = "01ba4719c80b6fe911b091a7c05124b64eeece964e09c058ef8f9805daca546b"
+ i386.url = "https://github.com/foo/bar/archive/refs/tags/v1.2.3.386.tar.gz"
+ i386.sha256 = "53c234e5e8472b6ac51c1ae1cab3fe06fad053beb8ebfd8977b010655bfdd3c3"
+ armhf.url = "https://github.com/foo/bar/archive/refs/tags/v1.2.3.arm.tar.gz"
+ armhf.sha256 = "4355a46b19d348dc2f57c046f8ef63d4538ebb936000f3c9ee954a27460dd865"
+
+ autoupdate.strategy = "latest_github_release"
+ autoupdate.asset.amd64 = ".*\\.amd64.tar.gz"
+ autoupdate.asset.i386 = ".*\\.386.tar.gz"
+ autoupdate.asset.armhf = ".*\\.arm.tar.gz"
+
+ [resources.sources.zblerg]
+ url = "https://zblerg.com/download/zblerg"
+ sha256 = "1121cfccd5913f0a63fec40a6ffd44ea64f9dc135c66634ba001d10bcf4302a2"
+ format = "script"
+ rename = "zblerg.sh"
+
+ ```
+
+ ##### Properties (for each source)
+
+ - `prefetch` : `true` (default) or `false`, wether or not to pre-fetch this asset during the provisioning phase of the resource. If several arch-dependent url are provided, YunoHost will only prefetch the one for the current system architecture.
+ - `url` : the asset's URL
+ - If the asset's URL depend on the architecture, you may instead provide `amd64.url`, `i386.url`, `armhf.url` and `arm64.url` (depending on what architectures are supported), using the same `dpkg --print-architecture` nomenclature as for the supported architecture key in the manifest
+ - `sha256` : the asset's sha256sum. This is used both as an integrity check, and as a layer of security to protect against malicious actors which could have injected malicious code inside the asset...
+ - Same as `url` : if the asset's URL depend on the architecture, you may instead provide `amd64.sha256`, `i386.sha256`, ...
+ - `format` : The "format" of the asset. It is typically automatically guessed from the extension of the URL (or the mention of "tarball", "zipball" in the URL), but can be set explicitly:
+ - `tar.gz`, `tar.xz`, `tar.bz2` : will use `tar` to extract the archive
+ - `zip` : will use `unzip` to extract the archive
+ - `docker` : useful to extract files from an already-built docker image (instead of rebuilding them locally). Will use `docker-image-extract`
+ - `whatever`: whatever arbitrary value, not really meaningful except to imply that the file won't be extracted (eg because it's a .deb to be manually installed with dpkg/apt, or a script, or ...)
+ - `in_subdir`: `true` (default) or `false`, depending on if there's an intermediate subdir in the archive before accessing the actual files. Can also be `N` (an integer) to handle special cases where there's `N` level of subdir to get rid of to actually access the files
+ - `extract` : `true` or `false`. Defaults to `true` for archives such as `zip`, `tar.gz`, `tar.bz2`, ... Or defaults to `false` when `format` is not something that should be extracted. When `extract = false`, the file will only be `mv`ed to the location, possibly renamed using the `rename` value
+ - `rename`: some string like `whatever_your_want`, to be used for convenience when `extract` is `false` and the default name of the file is not practical
+ - `platform`: for example `linux/amd64` (defaults to `linux/$YNH_ARCH`) to be used in conjonction with `format = "docker"` to specify which architecture to extract for
+
+ ###### Regarding `autoupdate`
+
+ Strictly speaking, this has nothing to do with the actual app install. `autoupdate` is expected to contain metadata for automatic maintenance / update of the app sources info in the manifest. It is meant to be a simpler replacement for "autoupdate" Github workflow mechanism.
+
+ The infos are used by this script : https://github.com/YunoHost/apps/blob/master/tools/autoupdate_app_sources/autoupdate_app_sources.py which is ran by the YunoHost infrastructure periodically and will create the corresponding pull request automatically.
+
+ The script will rely on the code repo specified in the upstream section of the manifest.
+
+ `autoupdate.strategy` is expected to be one of :
+ - `latest_github_tag` : look for the latest tag (by sorting tags and finding the "largest" version). Then using the corresponding tar.gz url. Tags containing `rc`, `beta`, `alpha`, `start` are ignored, and actually any tag which doesn't look like `x.y.z` or `vx.y.z`
+ - `latest_github_release` : similar to `latest_github_tags`, but starting from the list of releases. Pre- or draft releases are ignored. Releases may have assets attached to them, in which case you can define:
+ - `autoupdate.asset = "some regex"` (when there's only one asset to use). The regex is used to find the appropriate asset among the list of all assets
+ - or several `autoupdate.asset.$arch = "some_regex"` (when the asset is arch-specific). The regex is used to find the appropriate asset for the specific arch among the list of assets
+ - `latest_github_commit` : will use the latest commit on github, and the corresponding tarball. If this is used for the 'main' source, it will also assume that the version is YYYY.MM.DD corresponding to the date of the commit.
+
+ It is also possible to define `autoupdate.upstream` to use a different Git(hub) repository instead of the code repository from the upstream section of the manifest. This can be useful when, for example, the app uses other assets such as plugin from a different repository.
+
+ ##### Provision/Update
+ - For elements with `prefetch = true`, will download the asset (for the appropriate architecture) and store them in `/var/cache/yunohost/download/$app/$source_id`, to be later picked up by `ynh_setup_source`. (NB: this only happens during install and upgrade, not restore)
+
+ ##### Deprovision
+ - Nothing (just cleanup the cache)
+ """
+
+ type = "sources"
+ priority = 10
+
+ default_sources_properties: Dict[str, Any] = {
+ "prefetch": True,
+ "url": None,
+ "sha256": None,
+ }
+
+ sources: Dict[str, Dict[str, Any]] = {}
+
+ def __init__(self, properties: Dict[str, Any], *args, **kwargs):
+ for source_id, infos in properties.items():
+ properties[source_id] = copy.copy(self.default_sources_properties)
+ properties[source_id].update(infos)
+
+ super().__init__({"sources": properties}, *args, **kwargs)
+
+ def deprovision(self, context: Dict = {}):
+ if os.path.isdir(f"/var/cache/yunohost/download/{self.app}/"):
+ rm(f"/var/cache/yunohost/download/{self.app}/", recursive=True)
+
+ def provision_or_update(self, context: Dict = {}):
+ # Don't prefetch stuff during restore
+ if context.get("action") == "restore":
+ return
+
+ for source_id, infos in self.sources.items():
+ if not infos["prefetch"]:
+ continue
+
+ if infos["url"] is None:
+ arch = system_arch()
+ if (
+ arch in infos
+ and isinstance(infos[arch], dict)
+ and isinstance(infos[arch].get("url"), str)
+ and isinstance(infos[arch].get("sha256"), str)
+ ):
+ self.prefetch(source_id, infos[arch]["url"], infos[arch]["sha256"])
+ else:
+ raise YunohostError(
+ f"In resources.sources: it looks like you forgot to define url/sha256 or {arch}.url/{arch}.sha256",
+ raw_msg=True,
+ )
+ else:
+ if infos["sha256"] is None:
+ raise YunohostError(
+ f"In resources.sources: it looks like the sha256 is missing for {source_id}",
+ raw_msg=True,
+ )
+ self.prefetch(source_id, infos["url"], infos["sha256"])
+
+ def prefetch(self, source_id, url, expected_sha256):
+ logger.debug(f"Prefetching asset {source_id}: {url} ...")
+
+ if not os.path.isdir(f"/var/cache/yunohost/download/{self.app}/"):
+ mkdir(f"/var/cache/yunohost/download/{self.app}/", parents=True)
+ filename = f"/var/cache/yunohost/download/{self.app}/{source_id}"
+
+ # NB: we use wget and not requests.get() because we want to output to a file (ie avoid ending up with the full archive in RAM)
+ # AND the nice --tries, --no-dns-cache, --timeout options ...
+ p = subprocess.Popen(
+ [
+ "/usr/bin/wget",
+ "--tries=3",
+ "--no-dns-cache",
+ "--timeout=900",
+ "--no-verbose",
+ "--output-document=" + filename,
+ url,
+ ],
+ stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT,
+ )
+ out, _ = p.communicate()
+ returncode = p.returncode
+ if returncode != 0:
+ if os.path.exists(filename):
+ rm(filename)
+ raise YunohostError(
+ "app_failed_to_download_asset",
+ source_id=source_id,
+ url=url,
+ app=self.app,
+ out=out.decode(),
+ )
+
+ assert os.path.exists(
+ filename
+ ), f"For some reason, wget worked but {filename} doesnt exists?"
+
+ computed_sha256 = check_output(f"sha256sum {filename}").split()[0]
+ if computed_sha256 != expected_sha256:
+ size = check_output(f"du -hs {filename}").split()[0]
+ rm(filename)
+ raise YunohostError(
+ "app_corrupt_source",
+ source_id=source_id,
+ url=url,
+ app=self.app,
+ expected_sha256=expected_sha256,
+ computed_sha256=computed_sha256,
+ size=size,
+ )
+
+
+class PermissionsResource(AppResource):
+ """
+ Configure the SSO permissions/tiles. Typically, webapps are expected to have a 'main' permission mapped to '/', meaning that a tile pointing to the `$domain/$path` will be available in the SSO for users allowed to access that app.
+
+ Additional permissions can be created, typically to have a specific tile and/or access rules for the admin part of a webapp.
+
+ The list of allowed user/groups may be initialized using the content of the `init_{perm}_permission` question from the manifest, hence `init_main_permission` replaces the `is_public` question and shall contain a group name (typically, `all_users` or `visitors`).
+
+ ##### Example
+ ```toml
+ [resources.permissions]
+ main.url = "/"
+ # (these two previous lines should be enough in the majority of cases)
+
+ admin.url = "/admin"
+ admin.show_tile = false
+ admin.allowed = "admins" # Assuming the "admins" group exists (cf future developments ;))
+ ```
+
+ ##### Properties (for each perm name)
+ - `url`: The relative URI corresponding to this permission. Typically `/` or `/something`. This property may be omitted for non-web permissions.
+ - `show_tile`: (default: `true` if `url` is defined) Wether or not a tile should be displayed for that permission in the user portal
+ - `allowed`: (default: nobody) The group initially allowed to access this perm, if `init_{perm}_permission` is not defined in the manifest questions. Note that the admin may tweak who is allowed/unallowed on that permission later on, this is only meant to **initialize** the permission.
+ - `auth_header`: (default: `true`) Define for the URL of this permission, if SSOwat pass the authentication header to the application. Default is true
+ - `protected`: (default: `false`) Define if this permission is protected. If it is protected the administrator won't be able to add or remove the visitors group of this permission. Defaults to 'false'.
+ - `additional_urls`: (default: none) List of additional URL for which access will be allowed/forbidden
+
+ ##### Provision/Update
+ - Delete any permissions that may exist and be related to this app yet is not declared anymore
+ - Loop over the declared permissions and create them if needed or update them with the new values
+
+ ##### Deprovision
+ - Delete all permission related to this app
+
+ ##### Legacy management
+ - Legacy `is_public` setting will be deleted if it exists
+ """
+
+ # Notes for future ?
+ # deep_clean -> delete permissions for any __APP__.foobar where app not in app list...
+ # backup -> handled elsewhere by the core, should be integrated in there (dump .ldif/yml?)
+ # restore -> handled by the core, should be integrated in there (restore .ldif/yml?)
+
+ type = "permissions"
+ priority = 80
+
+ default_properties: Dict[str, Any] = {}
+
+ default_perm_properties: Dict[str, Any] = {
+ "url": None,
+ "additional_urls": [],
+ "auth_header": True,
+ "allowed": None,
+ "show_tile": None, # To be automagically set to True by default if an url is defined and show_tile not provided
+ "protected": False,
+ }
+
+ permissions: Dict[str, Dict[str, Any]] = {}
+
+ def __init__(self, properties: Dict[str, Any], *args, **kwargs):
+ # FIXME : if url != None, we should check that there's indeed a domain/path defined ? ie that app is a webapp
+
+ # Validate packager-provided infos
+ for perm, infos in properties.items():
+ if "auth_header" in infos and not isinstance(
+ infos.get("auth_header"), bool
+ ):
+ raise YunohostError(
+ f"In manifest, for permission '{perm}', 'auth_header' should be a boolean",
+ raw_msg=True,
+ )
+ if "show_tile" in infos and not isinstance(infos.get("show_tile"), bool):
+ raise YunohostError(
+ f"In manifest, for permission '{perm}', 'show_tile' should be a boolean",
+ raw_msg=True,
+ )
+ if "protected" in infos and not isinstance(infos.get("protected"), bool):
+ raise YunohostError(
+ f"In manifest, for permission '{perm}', 'protected' should be a boolean",
+ raw_msg=True,
+ )
+ if "additional_urls" in infos and not isinstance(
+ infos.get("additional_urls"), list
+ ):
+ raise YunohostError(
+ f"In manifest, for permission '{perm}', 'additional_urls' should be a list",
+ raw_msg=True,
+ )
+
+ if "main" not in properties:
+ properties["main"] = copy.copy(self.default_perm_properties)
+
+ for perm, infos in properties.items():
+ properties[perm] = copy.copy(self.default_perm_properties)
+ properties[perm].update(infos)
+ if properties[perm]["show_tile"] is None:
+ properties[perm]["show_tile"] = bool(properties[perm]["url"])
+
+ if properties["main"]["url"] is not None and (
+ not isinstance(properties["main"].get("url"), str)
+ or properties["main"]["url"] != "/"
+ ):
+ raise YunohostError(
+ "URL for the 'main' permission should be '/' for webapps (or left undefined for non-webapps). Note that / refers to the install url of the app, i.e $domain.tld/$path/",
+ raw_msg=True,
+ )
+
+ super().__init__({"permissions": properties}, *args, **kwargs)
+
+ from yunohost.app import _get_app_settings, _hydrate_app_template
+
+ settings = _get_app_settings(self.app)
+ for perm, infos in self.permissions.items():
+ if infos.get("url") and "__" in infos.get("url"):
+ infos["url"] = _hydrate_app_template(infos["url"], settings)
+
+ if infos.get("additional_urls"):
+ infos["additional_urls"] = [
+ _hydrate_app_template(url, settings)
+ for url in infos["additional_urls"]
+ ]
+
+ def provision_or_update(self, context: Dict = {}):
+ from yunohost.permission import (
+ permission_create,
+ permission_url,
+ permission_delete,
+ user_permission_list,
+ user_permission_update,
+ permission_sync_to_user,
+ )
+
+ # Delete legacy is_public setting if not already done
+ self.delete_setting("is_public")
+
+ # Detect that we're using a full-domain app,
+ # in which case we probably need to automagically
+ # define the "path" setting with "/"
+ if (
+ isinstance(self.permissions["main"]["url"], str)
+ and self.get_setting("domain")
+ and not self.get_setting("path")
+ ):
+ self.set_setting("path", "/")
+
+ existing_perms = user_permission_list(short=True, apps=[self.app])[
+ "permissions"
+ ]
+ for perm in existing_perms:
+ if perm.split(".")[1] not in self.permissions.keys():
+ permission_delete(perm, force=True, sync_perm=False)
+
+ for perm, infos in self.permissions.items():
+ perm_id = f"{self.app}.{perm}"
+ if perm_id not in existing_perms:
+ # Use the 'allowed' key from the manifest,
+ # or use the 'init_{perm}_permission' from the install questions
+ # which is temporarily saved as a setting as an ugly hack to pass the info to this piece of code...
+ init_allowed = (
+ infos["allowed"]
+ or self.get_setting(f"init_{perm}_permission")
+ or []
+ )
+
+ # If we're choosing 'visitors' from the init_{perm}_permission question, add all_users too
+ if not infos["allowed"] and init_allowed == "visitors":
+ init_allowed = ["visitors", "all_users"]
+
+ permission_create(
+ perm_id,
+ allowed=init_allowed,
+ # This is why the ugly hack with self.manager exists >_>
+ label=self.manager.wanted["name"] if perm == "main" else perm,
+ url=infos["url"],
+ additional_urls=infos["additional_urls"],
+ auth_header=infos["auth_header"],
+ sync_perm=False,
+ )
+ self.delete_setting(f"init_{perm}_permission")
+
+ user_permission_update(
+ perm_id,
+ show_tile=infos["show_tile"],
+ protected=infos["protected"],
+ sync_perm=False,
+ )
+ permission_url(
+ perm_id,
+ url=infos["url"],
+ set_url=infos["additional_urls"],
+ auth_header=infos["auth_header"],
+ sync_perm=False,
+ )
+
+ permission_sync_to_user()
+
+ def deprovision(self, context: Dict = {}):
+ from yunohost.permission import (
+ permission_delete,
+ user_permission_list,
+ permission_sync_to_user,
+ )
+
+ existing_perms = user_permission_list(short=True, apps=[self.app])[
+ "permissions"
+ ]
+ for perm in existing_perms:
+ permission_delete(perm, force=True, sync_perm=False)
+
+ permission_sync_to_user()
+
+
+class SystemuserAppResource(AppResource):
+ """
+ Provision a system user to be used by the app. The username is exactly equal to the app id
+
+ ##### Example
+ ```toml
+ [resources.system_user]
+ # (empty - defaults are usually okay)
+ ```
+
+ ##### Properties
+ - `allow_ssh`: (default: False) Adds the user to the ssh.app group, allowing SSH connection via this user
+ - `allow_sftp`: (default: False) Adds the user to the sftp.app group, allowing SFTP connection via this user
+ - `home`: (default: `/var/www/__APP__`) Defines the home property for this user. NB: unfortunately you can't simply use `__INSTALL_DIR__` or `__DATA_DIR__` for now
+
+ ##### Provision/Update
+ - will create the system user if it doesn't exists yet
+ - will add/remove the ssh/sftp.app groups
+
+ ##### Deprovision
+ - deletes the user and group
+ """
+
+ # Notes for future?
+ #
+ # deep_clean -> uuuuh ? delete any user that could correspond to an app x_x ?
+ #
+ # backup -> nothing
+ # restore -> provision
+
+ type = "system_user"
+ priority = 20
+
+ default_properties: Dict[str, Any] = {
+ "allow_ssh": False,
+ "allow_sftp": False,
+ "home": "/var/www/__APP__",
+ }
+
+ # FIXME : wat do regarding ssl-cert, multimedia, and other groups
+
+ allow_ssh: bool = False
+ allow_sftp: bool = False
+ home: str = ""
+
+ def provision_or_update(self, context: Dict = {}):
+ # FIXME : validate that no yunohost user exists with that name?
+ # and/or that no system user exists during install ?
+
+ if os.system(f"getent passwd {self.app} >/dev/null 2>/dev/null") != 0:
+ # FIXME: improve logging ? os.system wont log stdout / stderr
+ cmd = f"useradd --system --user-group {self.app} --home-dir {self.home} --no-create-home"
+ ret = os.system(cmd)
+ assert ret == 0, f"useradd command failed with exit code {ret}"
+
+ if os.system(f"getent passwd {self.app} >/dev/null 2>/dev/null") != 0:
+ raise YunohostError(
+ f"Failed to create system user for {self.app}", raw_msg=True
+ )
+
+ # Update groups
+ groups = set(check_output(f"groups {self.app}").strip().split()[2:])
+
+ if self.allow_ssh:
+ groups.add("ssh.app")
+ elif "ssh.app" in groups:
+ groups.remove("ssh.app")
+
+ if self.allow_sftp:
+ groups.add("sftp.app")
+ elif "sftp.app" in groups:
+ groups.remove("sftp.app")
+
+ os.system(f"usermod -G {','.join(groups)} {self.app}")
+
+ # Update home dir
+ raw_user_line_in_etc_passwd = check_output(f"getent passwd {self.app}").strip()
+ user_infos = raw_user_line_in_etc_passwd.split(":")
+ current_home = user_infos[5]
+ if current_home != self.home:
+ ret = os.system(f"usermod --home {self.home} {self.app} 2>/dev/null")
+ # Most of the time this won't work because apparently we can't change the home dir while being logged-in -_-
+ # So we gotta brute force by replacing the line in /etc/passwd T_T
+ if ret != 0:
+ user_infos[5] = self.home
+ new_raw_user_line_in_etc_passwd = ":".join(user_infos)
+ os.system(
+ f"sed -i 's@{raw_user_line_in_etc_passwd}@{new_raw_user_line_in_etc_passwd}@g' /etc/passwd"
+ )
+
+ def deprovision(self, context: Dict = {}):
+ if os.system(f"getent passwd {self.app} >/dev/null 2>/dev/null") == 0:
+ os.system(f"deluser {self.app} >/dev/null")
+ if os.system(f"getent passwd {self.app} >/dev/null 2>/dev/null") == 0:
+ raise YunohostError(
+ f"Failed to delete system user for {self.app}", raw_msg=True
+ )
+
+ if os.system(f"getent group {self.app} >/dev/null 2>/dev/null") == 0:
+ os.system(f"delgroup {self.app} >/dev/null")
+ if os.system(f"getent group {self.app} >/dev/null 2>/dev/null") == 0:
+ raise YunohostError(
+ f"Failed to delete system user for {self.app}", raw_msg=True
+ )
+
+ # FIXME : better logging and error handling, add stdout/stderr from the deluser/delgroup commands...
+
+
+class InstalldirAppResource(AppResource):
+ """
+ Creates a directory to be used by the app as the installation directory, typically where the app sources and assets are located. The corresponding path is stored in the settings as `install_dir`
+
+ ##### Example
+ ```toml
+ [resources.install_dir]
+ # (empty - defaults are usually okay)
+ ```
+
+ ##### Properties
+ - `dir`: (default: `/var/www/__APP__`) The full path of the install dir
+ - `owner`: (default: `__APP__:rwx`) The owner (and owner permissions) for the install dir
+ - `group`: (default: `__APP__:rx`) The group (and group permissions) for the install dir
+
+ ##### Provision/Update
+ - during install, the folder will be deleted if it already exists (FIXME: is this what we want?)
+ - if the dir path changed and a folder exists at the old location, the folder will be `mv`'ed to the new location
+ - otherwise, creates the directory if it doesn't exists yet
+ - (re-)apply permissions (only on the folder itself, not recursively)
+ - save the value of `dir` as `install_dir` in the app's settings, which can be then used by the app scripts (`$install_dir`) and conf templates (`__INSTALL_DIR__`)
+
+ ##### Deprovision
+ - recursively deletes the directory if it exists
+
+ ##### Legacy management
+ - In the past, the setting was called `final_path`. The code will automatically rename it as `install_dir`.
+ - As explained in the 'Provision/Update' section, the folder will also be moved if the location changed
+
+ """
+
+ # Notes for future?
+ # deep_clean -> uuuuh ? delete any dir in /var/www/ that would not correspond to an app x_x ?
+ # backup -> cp install dir
+ # restore -> cp install dir
+
+ type = "install_dir"
+ priority = 30
+
+ default_properties: Dict[str, Any] = {
+ "dir": "/var/www/__APP__",
+ "owner": "__APP__:rwx",
+ "group": "__APP__:rx",
+ }
+
+ dir: str = ""
+ owner: str = ""
+ group: str = ""
+
+ # FIXME: change default dir to /opt/stuff if app ain't a webapp...
+
+ def provision_or_update(self, context: Dict = {}):
+ assert self.dir.strip() # Be paranoid about self.dir being empty...
+ assert self.owner.strip()
+ assert self.group.strip()
+
+ current_install_dir = self.get_setting("install_dir") or self.get_setting(
+ "final_path"
+ )
+
+ # If during install, /var/www/$app already exists, assume that it's okay to remove and recreate it
+ # FIXME : is this the right thing to do ?
+ if not current_install_dir and os.path.isdir(self.dir):
+ rm(self.dir, recursive=True)
+
+ # isdir will be True if the path is a symlink pointing to a dir
+ # This should cover cases where people moved the data dir to another place via a symlink (ie we dont enter the if)
+ if not os.path.isdir(self.dir):
+ # Handle case where install location changed, in which case we shall move the existing install dir
+ # FIXME: confirm that's what we wanna do
+ # Maybe a middle ground could be to compute the size, check that it's not too crazy (eg > 1G idk),
+ # and check for available space on the destination
+ if current_install_dir and os.path.isdir(current_install_dir):
+ logger.warning(
+ f"Moving {current_install_dir} to {self.dir}... (this may take a while)"
+ )
+ shutil.move(current_install_dir, self.dir)
+ else:
+ mkdir(self.dir, parents=True)
+
+ owner, owner_perm = self.owner.split(":")
+ group, group_perm = self.group.split(":")
+ owner_perm_octal = (
+ (4 if "r" in owner_perm else 0)
+ + (2 if "w" in owner_perm else 0)
+ + (1 if "x" in owner_perm else 0)
+ )
+ group_perm_octal = (
+ (4 if "r" in group_perm else 0)
+ + (2 if "w" in group_perm else 0)
+ + (1 if "x" in group_perm else 0)
+ )
+
+ perm_octal = 0o100 * owner_perm_octal + 0o010 * group_perm_octal
+
+ # NB: we use realpath here to cover cases where self.dir could actually be a symlink
+ # in which case we want to apply the perm to the pointed dir, not to the symlink
+ chmod(os.path.realpath(self.dir), perm_octal)
+ chown(os.path.realpath(self.dir), owner, group)
+ # FIXME: shall we apply permissions recursively ?
+
+ self.set_setting("install_dir", self.dir)
+ self.delete_setting("final_path") # Legacy
+
+ def deprovision(self, context: Dict = {}):
+ assert self.dir.strip() # Be paranoid about self.dir being empty...
+ assert self.owner.strip()
+ assert self.group.strip()
+
+ # FIXME : check that self.dir has a sensible value to prevent catastrophes
+ if os.path.isdir(self.dir):
+ rm(self.dir, recursive=True)
+ # FIXME : in fact we should delete settings to be consistent
+
+
+class DatadirAppResource(AppResource):
+ """
+ Creates a directory to be used by the app as the data store directory, typically where the app multimedia or large assets added by users are located. The corresponding path is stored in the settings as `data_dir`. This resource behaves very similarly to install_dir.
+
+ ##### Example
+ ```toml
+ [resources.data_dir]
+ # (empty - defaults are usually okay)
+ ```
+
+ ##### Properties
+ - `dir`: (default: `/home/yunohost.app/__APP__`) The full path of the data dir
+ - `subdirs`: (default: empty list) A list of subdirs to initialize inside the data dir. For example, `['foo', 'bar']`
+ - `owner`: (default: `__APP__:rwx`) The owner (and owner permissions) for the data dir
+ - `group`: (default: `__APP__:rx`) The group (and group permissions) for the data dir
+
+ ##### Provision/Update
+ - if the dir path changed and a folder exists at the old location, the folder will be `mv`'ed to the new location
+ - otherwise, creates the directory if it doesn't exists yet
+ - create each subdir declared and which do not exist already
+ - (re-)apply permissions (only on the folder itself and declared subdirs, not recursively)
+ - save the value of `dir` as `data_dir` in the app's settings, which can be then used by the app scripts (`$data_dir`) and conf templates (`__DATA_DIR__`)
+
+ ##### Deprovision
+ - (only if the purge option is chosen by the user) recursively deletes the directory if it exists
+ - also delete the corresponding setting
+
+ ##### Legacy management
+ - In the past, the setting may have been called `datadir`. The code will automatically rename it as `data_dir`.
+ - As explained in the 'Provision/Update' section, the folder will also be moved if the location changed
+
+ """
+
+ # notes for future ?
+ # deep_clean -> zblerg idk nothing
+ # backup -> cp data dir ? (if not backup_core_only)
+ # restore -> cp data dir ? (if in backup)
+
+ type = "data_dir"
+ priority = 40
+
+ default_properties: Dict[str, Any] = {
+ "dir": "/home/yunohost.app/__APP__",
+ "subdirs": [],
+ "owner": "__APP__:rwx",
+ "group": "__APP__:rx",
+ }
+
+ dir: str = ""
+ subdirs: list = []
+ owner: str = ""
+ group: str = ""
+
+ def provision_or_update(self, context: Dict = {}):
+ assert self.dir.strip() # Be paranoid about self.dir being empty...
+ assert self.owner.strip()
+ assert self.group.strip()
+
+ current_data_dir = self.get_setting("data_dir") or self.get_setting("datadir")
+
+ # isdir will be True if the path is a symlink pointing to a dir
+ # This should cover cases where people moved the data dir to another place via a symlink (ie we dont enter the if)
+ if not os.path.isdir(self.dir):
+ # Handle case where install location changed, in which case we shall move the existing install dir
+ # FIXME: same as install_dir, is this what we want ?
+ if current_data_dir and os.path.isdir(current_data_dir):
+ logger.warning(
+ f"Moving {current_data_dir} to {self.dir}... (this may take a while)"
+ )
+ shutil.move(current_data_dir, self.dir)
+ else:
+ mkdir(self.dir, parents=True)
+
+ for subdir in self.subdirs:
+ full_path = os.path.join(self.dir, subdir)
+ if not os.path.isdir(full_path):
+ mkdir(full_path, parents=True)
+
+ owner, owner_perm = self.owner.split(":")
+ group, group_perm = self.group.split(":")
+ owner_perm_octal = (
+ (4 if "r" in owner_perm else 0)
+ + (2 if "w" in owner_perm else 0)
+ + (1 if "x" in owner_perm else 0)
+ )
+ group_perm_octal = (
+ (4 if "r" in group_perm else 0)
+ + (2 if "w" in group_perm else 0)
+ + (1 if "x" in group_perm else 0)
+ )
+ perm_octal = 0o100 * owner_perm_octal + 0o010 * group_perm_octal
+
+ # NB: we use realpath here to cover cases where self.dir could actually be a symlink
+ # in which case we want to apply the perm to the pointed dir, not to the symlink
+ chmod(os.path.realpath(self.dir), perm_octal)
+ chown(os.path.realpath(self.dir), owner, group)
+ for subdir in self.subdirs:
+ full_path = os.path.join(self.dir, subdir)
+ chmod(os.path.realpath(full_path), perm_octal)
+ chown(os.path.realpath(full_path), owner, group)
+
+ self.set_setting("data_dir", self.dir)
+ self.delete_setting("datadir") # Legacy
+
+ def deprovision(self, context: Dict = {}):
+ assert self.dir.strip() # Be paranoid about self.dir being empty...
+ assert self.owner.strip()
+ assert self.group.strip()
+
+ if context.get("purge_data_dir", False) and os.path.isdir(self.dir):
+ rm(self.dir, recursive=True)
+
+ self.delete_setting("data_dir")
+
+
+class AptDependenciesAppResource(AppResource):
+ """
+ Create a virtual package in apt, depending on the list of specified packages that the app needs. The virtual packages is called `$app-ynh-deps` (with `_` being replaced by `-` in the app name, see `ynh_install_app_dependencies`)
+
+ ##### Example
+ ```toml
+ [resources.apt]
+ packages = ["nyancat", "lolcat", "sl"]
+
+ # (this part is optional and corresponds to the legacy ynh_install_extra_app_dependencies helper)
+ extras.yarn.repo = "deb https://dl.yarnpkg.com/debian/ stable main"
+ extras.yarn.key = "https://dl.yarnpkg.com/debian/pubkey.gpg"
+ extras.yarn.packages = ["yarn"]
+ ```
+
+ ##### Properties
+ - `packages`: List of packages to be installed via `apt`
+ - `packages_from_raw_bash`: A multi-line bash snippet (using triple quotes as open/close) which should echo additional packages to be installed. Meant to be used for packages to be conditionally installed depending on architecture, debian version, install questions, or other logic.
+ - `extras`: A dict of (repo, key, packages) corresponding to "extra" repositories to fetch dependencies from
+
+ ##### Provision/Update
+ - The code literally calls the bash helpers `ynh_install_app_dependencies` and `ynh_install_extra_app_dependencies`, similar to what happens in v1.
+ - Note that when `packages` contains some phpX.Y-foobar dependencies, this will automagically define a `phpversion` setting equal to `X.Y` which can therefore be used in app scripts ($phpversion) or templates (`__PHPVERSION__`)
+
+ ##### Deprovision
+ - The code literally calls the bash helper `ynh_remove_app_dependencies`
+ """
+
+ # Notes for future?
+ # deep_clean -> remove any __APP__-ynh-deps for app not in app list
+ # backup -> nothing
+ # restore = provision
+
+ type = "apt"
+ priority = 50
+
+ default_properties: Dict[str, Any] = {"packages": [], "extras": {}}
+
+ packages: List = []
+ packages_from_raw_bash: str = ""
+ extras: Dict[str, Dict[str, Union[str, List]]] = {}
+
+ def __init__(self, properties: Dict[str, Any], *args, **kwargs):
+ super().__init__(properties, *args, **kwargs)
+
+ if isinstance(self.packages, str):
+ self.packages = [value.strip() for value in self.packages.split(",")]
+
+ if self.packages_from_raw_bash:
+ out, err = self.check_output_bash_snippet(self.packages_from_raw_bash)
+ if err:
+ logger.error(
+ "Error while running apt resource packages_from_raw_bash snippet:"
+ )
+ logger.error(err)
+ self.packages += out.split("\n")
+
+ for key, values in self.extras.items():
+ if isinstance(values.get("packages"), str):
+ values["packages"] = [value.strip() for value in values["packages"].split(",")] # type: ignore
+
+ if (
+ not isinstance(values.get("repo"), str)
+ or not isinstance(values.get("key"), str)
+ or not isinstance(values.get("packages"), list)
+ ):
+ raise YunohostError(
+ "In apt resource in the manifest: 'extras' repo should have the keys 'repo', 'key' defined as strings and 'packages' defined as list",
+ raw_msg=True,
+ )
+
+ def provision_or_update(self, context: Dict = {}):
+ script = " ".join(["ynh_install_app_dependencies", *self.packages])
+ for repo, values in self.extras.items():
+ script += "\n" + " ".join(
+ [
+ "ynh_install_extra_app_dependencies",
+ f"--repo='{values['repo']}'",
+ f"--key='{values['key']}'",
+ f"--package='{' '.join(values['packages'])}'",
+ ]
+ )
+ # FIXME : we're feeding the raw value of values['packages'] to the helper .. if we want to be consistent, may they should be comma-separated, though in the majority of cases, only a single package is installed from an extra repo..
+
+ self._run_script("provision_or_update", script)
+
+ def deprovision(self, context: Dict = {}):
+ self._run_script("deprovision", "ynh_remove_app_dependencies")
+
+
+class PortsResource(AppResource):
+ """
+ Book port(s) to be used by the app, typically to be used to the internal reverse-proxy between nginx and the app process.
+
+ Note that because multiple ports can be booked, each properties is prefixed by the name of the port. `main` is a special name and will correspond to the setting `$port`, whereas for example `xmpp_client` will correspond to the setting `$port_xmpp_client`.
+
+ ##### Example
+ ```toml
+ [resources.ports]
+ # (empty should be fine for most apps... though you can customize stuff if absolutely needed)
+
+
+ main.default = 12345 # if you really want to specify a prefered value .. but shouldnt matter in the majority of cases
+
+ xmpp_client.default = 5222 # if you need another port, pick a name for it (here, "xmpp_client")
+ xmpp_client.exposed = "TCP" # here, we're telling that the port needs to be publicly exposed on TCP on the firewall
+ ```
+
+ ##### Properties (for every port name)
+ - `default`: The prefered value for the port. If this port is already being used by another process right now, or is booked in another app's setting, the code will increment the value until it finds a free port and store that value as the setting. If no value is specified, a random value between 10000 and 60000 is used.
+ - `exposed`: (default: `false`) Wether this port should be opened on the firewall and be publicly reachable. This should be kept to `false` for the majority of apps than only need a port for internal reverse-proxying! Possible values: `false`, `true`(=`Both`), `Both`, `TCP`, `UDP`. This will result in the port being opened on the firewall, and the diagnosis checking that a program answers on that port.
+ - `fixed`: (default: `false`) Tells that the app absolutely needs the specific value provided in `default`, typically because it's needed for a specific protocol
+
+ ##### Provision/Update (for every port name)
+ - If not already booked, look for a free port, starting with the `default` value (or a random value between 10000 and 60000 if no `default` set)
+ - If `exposed` is not `false`, open the port in the firewall accordingly - otherwise make sure it's closed.
+ - The value of the port is stored in the `$port` setting for the `main` port, or `$port_NAME` for other `NAME`s
+
+ ##### Deprovision
+ - Close the ports on the firewall if relevant
+ - Deletes all the port settings
+
+ ##### Legacy management
+ - In the past, some settings may have been named `NAME_port` instead of `port_NAME`, in which case the code will automatically rename the old setting.
+ """
+
+ # Notes for future?
+ # deep_clean -> ?
+ # backup -> nothing (backup port setting)
+ # restore -> nothing (restore port setting)
+
+ type = "ports"
+ priority = 70
+
+ default_properties: Dict[str, Any] = {}
+
+ default_port_properties = {
+ "default": None,
+ "exposed": False, # or True(="Both"), "TCP", "UDP"
+ "fixed": False,
+ }
+
+ ports: Dict[str, Dict[str, Any]]
+
+ def __init__(self, properties: Dict[str, Any], *args, **kwargs):
+ if "main" not in properties:
+ properties["main"] = {}
+
+ for port, infos in properties.items():
+ properties[port] = copy.copy(self.default_port_properties)
+ properties[port].update(infos)
+
+ if properties[port]["default"] is None:
+ properties[port]["default"] = random.randint(10000, 60000)
+
+ super().__init__({"ports": properties}, *args, **kwargs)
+
+ def _port_is_used(self, port):
+ # FIXME : this could be less brutal than two os.system...
+ cmd1 = (
+ "ss --numeric --listening --tcp --udp | awk '{print$5}' | grep --quiet --extended-regexp ':%s$'"
+ % port
+ )
+ # This second command is mean to cover (most) case where an app is using a port yet ain't currently using it for some reason (typically service ain't up)
+ cmd2 = f"grep --quiet --extended-regexp \"port: '?{port}'?\" /etc/yunohost/apps/*/settings.yml"
+ return os.system(cmd1) == 0 or os.system(cmd2) == 0
+
+ def provision_or_update(self, context: Dict = {}):
+ from yunohost.firewall import firewall_allow, firewall_disallow
+
+ for name, infos in self.ports.items():
+ setting_name = f"port_{name}" if name != "main" else "port"
+ port_value = self.get_setting(setting_name)
+ if not port_value and name != "main":
+ # Automigrate from legacy setting foobar_port (instead of port_foobar)
+ legacy_setting_name = f"{name}_port"
+ port_value = self.get_setting(legacy_setting_name)
+ if port_value:
+ self.set_setting(setting_name, port_value)
+ self.delete_setting(legacy_setting_name)
+ continue
+
+ if not port_value:
+ port_value = infos["default"]
+
+ if infos["fixed"]:
+ if self._port_is_used(port_value):
+ raise YunohostValidationError(
+ f"Port {port_value} is already used by another process or app.",
+ raw_msg=True,
+ )
+ else:
+ while self._port_is_used(port_value):
+ port_value += 1
+
+ self.set_setting(setting_name, port_value)
+
+ if infos["exposed"]:
+ firewall_allow(infos["exposed"], port_value, reload_only_if_change=True)
+ else:
+ firewall_disallow(
+ infos["exposed"], port_value, reload_only_if_change=True
+ )
+
+ def deprovision(self, context: Dict = {}):
+ from yunohost.firewall import firewall_disallow
+
+ for name, infos in self.ports.items():
+ setting_name = f"port_{name}" if name != "main" else "port"
+ value = self.get_setting(setting_name)
+ self.delete_setting(setting_name)
+ if value and str(value).strip():
+ firewall_disallow(
+ infos["exposed"], int(value), reload_only_if_change=True
+ )
+
+
+class DatabaseAppResource(AppResource):
+ """
+ Initialize a database, either using MySQL or Postgresql. Relevant DB infos are stored in settings `$db_name`, `$db_user` and `$db_pwd`.
+
+ NB: only one DB can be handled in such a way (is there really an app that would need two completely different DB ?...)
+
+ NB2: no automagic migration will happen in an suddenly change `type` from `mysql` to `postgresql` or viceversa in its life
+
+ ##### Example
+ ```toml
+ [resources.database]
+ type = "mysql" # or : "postgresql". Only these two values are supported
+ ```
+
+ ##### Properties
+ - `type`: The database type, either `mysql` or `postgresql`
+
+ ##### Provision/Update
+ - (Re)set the `$db_name` and `$db_user` settings with the sanitized app name (replacing `-` and `.` with `_`)
+ - If `$db_pwd` doesn't already exists, pick a random database password and store it in that setting
+ - If the database doesn't exists yet, create the SQL user and DB using `ynh_mysql_create_db` or `ynh_psql_create_db`.
+
+ ##### Deprovision
+ - Drop the DB using `ynh_mysql_remove_db` or `ynh_psql_remove_db`
+ - Deletes the `db_name`, `db_user` and `db_pwd` settings
+
+ ##### Legacy management
+ - In the past, the sql passwords may have been named `mysqlpwd` or `psqlpwd`, in which case it will automatically be renamed as `db_pwd`
+ """
+
+ # Notes for future?
+ # deep_clean -> ... idk look into any db name that would not be related to any app...
+ # backup -> dump db
+ # restore -> setup + inject db dump
+
+ type = "database"
+ priority = 90
+ dbtype: str = ""
+
+ default_properties: Dict[str, Any] = {
+ "dbtype": None,
+ }
+
+ def __init__(self, properties: Dict[str, Any], *args, **kwargs):
+ if "type" not in properties or properties["type"] not in [
+ "mysql",
+ "postgresql",
+ ]:
+ raise YunohostError(
+ "Specifying the type of db ('mysql' or 'postgresql') is mandatory for db resources",
+ raw_msg=True,
+ )
+
+ # Hack so that people can write type = "mysql/postgresql" in toml but it's loaded as dbtype
+ # to avoid conflicting with the generic self.type of the resource object...
+ # dunno if that's really a good idea :|
+ properties = {"dbtype": properties["type"]}
+
+ super().__init__(properties, *args, **kwargs)
+
+ def db_exists(self, db_name):
+ if self.dbtype == "mysql":
+ return os.system(f"mysqlshow | grep -q -w '{db_name}' 2>/dev/null") == 0
+ elif self.dbtype == "postgresql":
+ return (
+ os.system(
+ f"sudo --login --user=postgres psql '{db_name}' -c ';' >/dev/null 2>/dev/null"
+ )
+ == 0
+ )
+ else:
+ return False
+
+ def provision_or_update(self, context: Dict = {}):
+ # This is equivalent to ynh_sanitize_dbid
+ db_name = self.app.replace("-", "_").replace(".", "_")
+ db_user = db_name
+ self.set_setting("db_name", db_name)
+ self.set_setting("db_user", db_user)
+
+ db_pwd = None
+ if self.get_setting("db_pwd"):
+ db_pwd = self.get_setting("db_pwd")
+ else:
+ # Legacy setting migration
+ legacypasswordsetting = (
+ "psqlpwd" if self.dbtype == "postgresql" else "mysqlpwd"
+ )
+ if self.get_setting(legacypasswordsetting):
+ db_pwd = self.get_setting(legacypasswordsetting)
+ self.delete_setting(legacypasswordsetting)
+ self.set_setting("db_pwd", db_pwd)
+
+ if not db_pwd:
+ from moulinette.utils.text import random_ascii
+
+ db_pwd = random_ascii(24)
+ self.set_setting("db_pwd", db_pwd)
+
+ if not self.db_exists(db_name):
+ if self.dbtype == "mysql":
+ self._run_script(
+ "provision",
+ f"ynh_mysql_create_db '{db_name}' '{db_user}' '{db_pwd}'",
+ )
+ elif self.dbtype == "postgresql":
+ self._run_script(
+ "provision",
+ f"ynh_psql_create_user '{db_user}' '{db_pwd}'; ynh_psql_create_db '{db_name}' '{db_user}'",
+ )
+
+ def deprovision(self, context: Dict = {}):
+ db_name = self.app.replace("-", "_").replace(".", "_")
+ db_user = db_name
+
+ if self.dbtype == "mysql":
+ self._run_script(
+ "deprovision", f"ynh_mysql_remove_db '{db_name}' '{db_user}'"
+ )
+ elif self.dbtype == "postgresql":
+ self._run_script(
+ "deprovision", f"ynh_psql_remove_db '{db_name}' '{db_user}'"
+ )
+
+ self.delete_setting("db_name")
+ self.delete_setting("db_user")
+ self.delete_setting("db_pwd")
+
+
+AppResourceClassesByType = {c.type: c for c in AppResource.__subclasses__()}
diff --git a/src/utils/packages.py b/src/utils/system.py
similarity index 51%
rename from src/utils/packages.py
rename to src/utils/system.py
index 3105bc4c7..a169bd62c 100644
--- a/src/utils/packages.py
+++ b/src/utils/system.py
@@ -1,37 +1,112 @@
-# -*- coding: utf-8 -*-
-
-""" License
-
- Copyright (C) 2015 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
-
-"""
+#
+# Copyright (c) 2023 YunoHost Contributors
+#
+# This file is part of YunoHost (see https://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 .
+#
import re
import os
import logging
from moulinette.utils.process import check_output
-from packaging import version
+from yunohost.utils.error import YunohostError
logger = logging.getLogger("yunohost.utils.packages")
YUNOHOST_PACKAGES = ["yunohost", "yunohost-admin", "moulinette", "ssowat"]
-def get_ynh_package_version(package):
+def debian_version():
+ return check_output('grep "^VERSION_CODENAME=" /etc/os-release | cut -d= -f2')
+
+def system_arch():
+ return check_output("dpkg --print-architecture")
+
+
+def system_virt():
+ """
+ Returns the output of systemd-detect-virt (so e.g. 'none' or 'lxc' or ...)
+ You can check the man of the command to have a list of possible outputs...
+ """
+ # Detect virt technology (if not bare metal) and arch
+ # Gotta have this "|| true" because it systemd-detect-virt return 'none'
+ # with an error code on bare metal ~.~
+ return check_output("systemd-detect-virt || true")
+
+
+def free_space_in_directory(dirpath):
+ stat = os.statvfs(dirpath)
+ return stat.f_frsize * stat.f_bavail
+
+
+def space_used_by_directory(dirpath, follow_symlinks=True):
+ if not follow_symlinks:
+ du_output = check_output(["du", "-sb", dirpath], shell=False)
+ return int(du_output.split()[0])
+
+ stat = os.statvfs(dirpath)
+ return (
+ stat.f_frsize * stat.f_blocks
+ ) # FIXME : this doesnt do what the function name suggest this does ...
+
+
+def human_to_binary(size: str) -> int:
+ symbols = ("K", "M", "G", "T", "P", "E", "Z", "Y")
+ factor = {}
+ for i, s in enumerate(symbols):
+ factor[s] = 1 << (i + 1) * 10
+
+ suffix = size[-1]
+ size = size[:-1]
+
+ if suffix not in symbols:
+ raise YunohostError(
+ f"Invalid size suffix '{suffix}', expected one of {symbols}"
+ )
+
+ try:
+ size_ = float(size)
+ except Exception:
+ raise YunohostError(f"Failed to convert size {size} to float")
+
+ return int(size_ * factor[suffix])
+
+
+def binary_to_human(n: int) -> str:
+ """
+ Convert bytes or bits into human readable format with binary prefix
+ """
+ symbols = ("K", "M", "G", "T", "P", "E", "Z", "Y")
+ prefix = {}
+ for i, s in enumerate(symbols):
+ prefix[s] = 1 << (i + 1) * 10
+ for s in reversed(symbols):
+ if n >= prefix[s]:
+ value = float(n) / prefix[s]
+ return "%.1f%s" % (value, s)
+ return "%s" % n
+
+
+def ram_available():
+ import psutil
+
+ return (psutil.virtual_memory().available, psutil.swap_memory().free)
+
+
+def get_ynh_package_version(package):
# Returns the installed version and release version ('stable' or 'testing'
# or 'unstable')
@@ -40,7 +115,7 @@ def get_ynh_package_version(package):
# may handle changelog differently !
changelog = "/usr/share/doc/%s/changelog.gz" % package
- cmd = "gzip -cd %s 2>/dev/null | head -n1" % changelog
+ cmd = "gzip -cd %s 2>/dev/null | grep -v 'BASH_XTRACEFD' | head -n1" % changelog
if not os.path.exists(changelog):
return {"version": "?", "repo": "?"}
out = check_output(cmd).split()
@@ -48,43 +123,6 @@ def get_ynh_package_version(package):
return {"version": out[1].strip("()"), "repo": out[2].strip(";")}
-def meets_version_specifier(pkg_name, specifier):
- """
- Check if a package installed version meets specifier
-
- specifier is something like ">> 1.2.3"
- """
-
- # In practice, this function is only used to check the yunohost version
- # installed.
- # We'll trim any ~foobar in the current installed version because it's not
- # handled correctly by version.parse, but we don't care so much in that
- # context
- assert pkg_name in YUNOHOST_PACKAGES
- pkg_version = get_ynh_package_version(pkg_name)["version"]
- pkg_version = re.split(r"\~|\+|\-", pkg_version)[0]
- pkg_version = version.parse(pkg_version)
-
- # Extract operator and version specifier
- op, req_version = re.search(r"(<<|<=|=|>=|>>) *([\d\.]+)", specifier).groups()
- req_version = version.parse(req_version)
-
- # Python2 had a builtin that returns (-1, 0, 1) depending on comparison
- # c.f. https://stackoverflow.com/a/22490617
- def cmp(a, b):
- return (a > b) - (a < b)
-
- deb_operators = {
- "<<": lambda v1, v2: cmp(v1, v2) in [-1],
- "<=": lambda v1, v2: cmp(v1, v2) in [-1, 0],
- "=": lambda v1, v2: cmp(v1, v2) in [0],
- ">=": lambda v1, v2: cmp(v1, v2) in [0, 1],
- ">>": lambda v1, v2: cmp(v1, v2) in [1],
- }
-
- return deb_operators[op](pkg_version, req_version)
-
-
def ynh_packages_version(*args, **kwargs):
# from cli the received arguments are:
# (Namespace(_callbacks=deque([]), _tid='_global', _to_return={}), []) {}
@@ -114,7 +152,6 @@ def dpkg_lock_available():
def _list_upgradable_apt_packages():
-
# List upgradable packages
# LC_ALL=C is here to make sure the results are in english
upgradable_raw = check_output("LC_ALL=C apt list --upgradable")
@@ -124,7 +161,6 @@ def _list_upgradable_apt_packages():
line.strip() for line in upgradable_raw.split("\n") if line.strip()
]
for line in upgradable_raw:
-
# Remove stupid warning and verbose messages >.>
if "apt does not have a stable CLI interface" in line or "Listing..." in line:
continue
@@ -144,7 +180,6 @@ def _list_upgradable_apt_packages():
def _dump_sources_list():
-
from glob import glob
filenames = glob("/etc/apt/sources.list") + glob("/etc/apt/sources.list.d/*")
diff --git a/src/utils/yunopaste.py b/src/utils/yunopaste.py
index 35e829991..46131846d 100644
--- a/src/utils/yunopaste.py
+++ b/src/utils/yunopaste.py
@@ -1,5 +1,21 @@
-# -*- coding: utf-8 -*-
-
+#
+# Copyright (c) 2023 YunoHost Contributors
+#
+# This file is part of YunoHost (see https://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 .
+#
import requests
import json
import logging
@@ -12,7 +28,6 @@ logger = logging.getLogger("yunohost.utils.yunopaste")
def yunopaste(data):
-
paste_server = "https://paste.yunohost.org"
try:
diff --git a/tests/test_helpers.d/ynhtest_setup_source.sh b/tests/test_helpers.d/ynhtest_setup_source.sh
index fe61e7401..6a74a587c 100644
--- a/tests/test_helpers.d/ynhtest_setup_source.sh
+++ b/tests/test_helpers.d/ynhtest_setup_source.sh
@@ -18,51 +18,51 @@ _make_dummy_src() {
}
ynhtest_setup_source_nominal() {
- final_path="$(mktemp -d -p $VAR_WWW)"
+ install_dir="$(mktemp -d -p $VAR_WWW)"
_make_dummy_src > ../conf/dummy.src
- ynh_setup_source --dest_dir="$final_path" --source_id="dummy"
+ ynh_setup_source --dest_dir="$install_dir" --source_id="dummy"
- test -e "$final_path"
- test -e "$final_path/index.html"
+ test -e "$install_dir"
+ test -e "$install_dir/index.html"
}
ynhtest_setup_source_nominal_upgrade() {
- final_path="$(mktemp -d -p $VAR_WWW)"
+ install_dir="$(mktemp -d -p $VAR_WWW)"
_make_dummy_src > ../conf/dummy.src
- ynh_setup_source --dest_dir="$final_path" --source_id="dummy"
+ ynh_setup_source --dest_dir="$install_dir" --source_id="dummy"
- test "$(cat $final_path/index.html)" == "Lorem Ipsum"
+ test "$(cat $install_dir/index.html)" == "Lorem Ipsum"
# Except index.html to get overwritten during next ynh_setup_source
- echo "IEditedYou!" > $final_path/index.html
- test "$(cat $final_path/index.html)" == "IEditedYou!"
+ echo "IEditedYou!" > $install_dir/index.html
+ test "$(cat $install_dir/index.html)" == "IEditedYou!"
- ynh_setup_source --dest_dir="$final_path" --source_id="dummy"
+ ynh_setup_source --dest_dir="$install_dir" --source_id="dummy"
- test "$(cat $final_path/index.html)" == "Lorem Ipsum"
+ test "$(cat $install_dir/index.html)" == "Lorem Ipsum"
}
ynhtest_setup_source_with_keep() {
- final_path="$(mktemp -d -p $VAR_WWW)"
+ install_dir="$(mktemp -d -p $VAR_WWW)"
_make_dummy_src > ../conf/dummy.src
- echo "IEditedYou!" > $final_path/index.html
- echo "IEditedYou!" > $final_path/test.txt
+ echo "IEditedYou!" > $install_dir/index.html
+ echo "IEditedYou!" > $install_dir/test.txt
- ynh_setup_source --dest_dir="$final_path" --source_id="dummy" --keep="index.html test.txt"
+ ynh_setup_source --dest_dir="$install_dir" --source_id="dummy" --keep="index.html test.txt"
- test -e "$final_path"
- test -e "$final_path/index.html"
- test -e "$final_path/test.txt"
- test "$(cat $final_path/index.html)" == "IEditedYou!"
- test "$(cat $final_path/test.txt)" == "IEditedYou!"
+ test -e "$install_dir"
+ test -e "$install_dir/index.html"
+ test -e "$install_dir/test.txt"
+ test "$(cat $install_dir/index.html)" == "IEditedYou!"
+ test "$(cat $install_dir/test.txt)" == "IEditedYou!"
}
ynhtest_setup_source_with_patch() {
- final_path="$(mktemp -d -p $VAR_WWW)"
+ install_dir="$(mktemp -d -p $VAR_WWW)"
_make_dummy_src > ../conf/dummy.src
mkdir -p ../sources/patches
@@ -74,7 +74,7 @@ ynhtest_setup_source_with_patch() {
+Lorem Ipsum dolor sit amet
EOF
- ynh_setup_source --dest_dir="$final_path" --source_id="dummy"
+ ynh_setup_source --dest_dir="$install_dir" --source_id="dummy"
- test "$(cat $final_path/index.html)" == "Lorem Ipsum dolor sit amet"
+ test "$(cat $install_dir/index.html)" == "Lorem Ipsum dolor sit amet"
}
diff --git a/tox.ini b/tox.ini
index dc2c52074..c38df434b 100644
--- a/tox.ini
+++ b/tox.ini
@@ -8,7 +8,7 @@ deps =
py39-black-{run,check}: black
py39-mypy: mypy >= 0.900
commands =
- py39-lint: flake8 src doc maintenance tests --ignore E402,E501,E203,W503 --exclude src/vendor
+ py39-lint: flake8 src doc maintenance tests --ignore E402,E501,E203,W503,E741 --exclude src/tests,src/vendor
py39-invalidcode: flake8 src bin maintenance --exclude src/tests,src/vendor --select F,E722,W605
py39-black-check: black --check --diff bin src doc maintenance tests
py39-black-run: black bin src doc maintenance tests