mirror of
https://github.com/YunoHost/yunohost.git
synced 2024-09-03 20:06:10 +02:00
Merge pull request #1338 from YunoHost/rework-prompt-again
Rework prompt() again
This commit is contained in:
commit
7484f138b1
7 changed files with 835 additions and 629 deletions
|
@ -13,10 +13,8 @@
|
||||||
"app_already_installed": "{app} is already installed",
|
"app_already_installed": "{app} is already installed",
|
||||||
"app_already_installed_cant_change_url": "This app is already installed. The URL cannot be changed just by this function. Check in `app changeurl` if it's available.",
|
"app_already_installed_cant_change_url": "This app is already installed. The URL cannot be changed just by this function. Check in `app changeurl` if it's available.",
|
||||||
"app_already_up_to_date": "{app} is already up-to-date",
|
"app_already_up_to_date": "{app} is already up-to-date",
|
||||||
"app_argument_choice_invalid": "Use one of these choices '{choices}' for the argument '{name}' instead of '{value}'",
|
"app_argument_choice_invalid": "Pick a valid value for argument '{name}': '{value}' is not among the available choices ({choices})",
|
||||||
"app_argument_invalid": "Pick a valid value for the argument '{name}': {error}",
|
"app_argument_invalid": "Pick a valid value for the argument '{name}': {error}",
|
||||||
"app_argument_password_help_keep": "Press Enter to keep the current value",
|
|
||||||
"app_argument_password_help_optional": "Type one space to empty the password",
|
|
||||||
"app_argument_password_no_default": "Error while parsing password argument '{name}': password argument can't have a default value for security reason",
|
"app_argument_password_no_default": "Error while parsing password argument '{name}': password argument can't have a default value for security reason",
|
||||||
"app_argument_required": "Argument '{name}' is required",
|
"app_argument_required": "Argument '{name}' is required",
|
||||||
"app_change_url_identical_domains": "The old and new domain/url_path are identical ('{domain}{path}'), nothing to do.",
|
"app_change_url_identical_domains": "The old and new domain/url_path are identical ('{domain}{path}'), nothing to do.",
|
||||||
|
@ -366,7 +364,6 @@
|
||||||
"extracting": "Extracting...",
|
"extracting": "Extracting...",
|
||||||
"field_invalid": "Invalid field '{}'",
|
"field_invalid": "Invalid field '{}'",
|
||||||
"file_does_not_exist": "The file {path} does not exist.",
|
"file_does_not_exist": "The file {path} does not exist.",
|
||||||
"file_extension_not_accepted": "Refusing file '{path}' because its extension is not among the accepted extensions: {accept}",
|
|
||||||
"firewall_reload_failed": "Could not reload the firewall",
|
"firewall_reload_failed": "Could not reload the firewall",
|
||||||
"firewall_reloaded": "Firewall reloaded",
|
"firewall_reloaded": "Firewall reloaded",
|
||||||
"firewall_rules_cmd_failed": "Some firewall rule commands have failed. More info in log.",
|
"firewall_rules_cmd_failed": "Some firewall rule commands have failed. More info in log.",
|
||||||
|
@ -541,6 +538,7 @@
|
||||||
"migrations_to_be_ran_manually": "Migration {id} has to be run manually. Please go to Tools → Migrations on the webadmin page, or run `yunohost tools migrations run`.",
|
"migrations_to_be_ran_manually": "Migration {id} has to be run manually. Please go to Tools → Migrations on the webadmin page, or run `yunohost tools migrations run`.",
|
||||||
"not_enough_disk_space": "Not enough free space on '{path}'",
|
"not_enough_disk_space": "Not enough free space on '{path}'",
|
||||||
"operation_interrupted": "The operation was manually interrupted?",
|
"operation_interrupted": "The operation was manually interrupted?",
|
||||||
|
"other_available_options": "... and {n} other available options not shown",
|
||||||
"packages_upgrade_failed": "Could not upgrade all the packages",
|
"packages_upgrade_failed": "Could not upgrade all the packages",
|
||||||
"password_listed": "This password is among the most used passwords in the world. Please choose something more unique.",
|
"password_listed": "This password is among the most used passwords in the world. Please choose something more unique.",
|
||||||
"password_too_simple_1": "The password needs to be at least 8 characters long",
|
"password_too_simple_1": "The password needs to be at least 8 characters long",
|
||||||
|
|
|
@ -32,9 +32,9 @@ import time
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import glob
|
import glob
|
||||||
import urllib.parse
|
|
||||||
import tempfile
|
import tempfile
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
|
from typing import List
|
||||||
|
|
||||||
from moulinette import Moulinette, m18n
|
from moulinette import Moulinette, m18n
|
||||||
from moulinette.core import MoulinetteError
|
from moulinette.core import MoulinetteError
|
||||||
|
@ -55,7 +55,10 @@ from moulinette.utils.filesystem import (
|
||||||
from yunohost.utils import packages
|
from yunohost.utils import packages
|
||||||
from yunohost.utils.config import (
|
from yunohost.utils.config import (
|
||||||
ConfigPanel,
|
ConfigPanel,
|
||||||
parse_args_in_yunohost_format,
|
ask_questions_and_parse_answers,
|
||||||
|
Question,
|
||||||
|
DomainQuestion,
|
||||||
|
PathQuestion
|
||||||
)
|
)
|
||||||
from yunohost.utils.i18n import _value_for_locale
|
from yunohost.utils.i18n import _value_for_locale
|
||||||
from yunohost.utils.error import YunohostError, YunohostValidationError
|
from yunohost.utils.error import YunohostError, YunohostValidationError
|
||||||
|
@ -442,8 +445,11 @@ def app_change_url(operation_logger, app, domain, path):
|
||||||
old_path = app_setting(app, "path")
|
old_path = app_setting(app, "path")
|
||||||
|
|
||||||
# Normalize path and domain format
|
# Normalize path and domain format
|
||||||
old_domain, old_path = _normalize_domain_path(old_domain, old_path)
|
|
||||||
domain, path = _normalize_domain_path(domain, path)
|
domain = DomainQuestion.normalize(domain)
|
||||||
|
old_domain = DomainQuestion.normalize(old_domain)
|
||||||
|
path = PathQuestion.normalize(path)
|
||||||
|
old_path = PathQuestion.normalize(old_path)
|
||||||
|
|
||||||
if (domain, path) == (old_domain, old_path):
|
if (domain, path) == (old_domain, old_path):
|
||||||
raise YunohostValidationError(
|
raise YunohostValidationError(
|
||||||
|
@ -453,16 +459,10 @@ def app_change_url(operation_logger, app, domain, path):
|
||||||
# Check the url is available
|
# Check the url is available
|
||||||
_assert_no_conflicting_apps(domain, path, ignore_app=app)
|
_assert_no_conflicting_apps(domain, path, ignore_app=app)
|
||||||
|
|
||||||
manifest = _get_manifest_of_app(os.path.join(APPS_SETTING_PATH, app))
|
|
||||||
|
|
||||||
# Retrieve arguments list for change_url script
|
|
||||||
# TODO: Allow to specify arguments
|
|
||||||
args_odict = _parse_args_from_manifest(manifest, "change_url")
|
|
||||||
|
|
||||||
tmp_workdir_for_app = _make_tmp_workdir_for_app(app=app)
|
tmp_workdir_for_app = _make_tmp_workdir_for_app(app=app)
|
||||||
|
|
||||||
# Prepare env. var. to pass to script
|
# Prepare env. var. to pass to script
|
||||||
env_dict = _make_environment_for_app_script(app, args=args_odict)
|
env_dict = _make_environment_for_app_script(app)
|
||||||
env_dict["YNH_APP_OLD_DOMAIN"] = old_domain
|
env_dict["YNH_APP_OLD_DOMAIN"] = old_domain
|
||||||
env_dict["YNH_APP_OLD_PATH"] = old_path
|
env_dict["YNH_APP_OLD_PATH"] = old_path
|
||||||
env_dict["YNH_APP_NEW_DOMAIN"] = domain
|
env_dict["YNH_APP_NEW_DOMAIN"] = domain
|
||||||
|
@ -614,12 +614,8 @@ def app_upgrade(app=[], url=None, file=None, force=False, no_safety_backup=False
|
||||||
|
|
||||||
app_setting_path = os.path.join(APPS_SETTING_PATH, app_instance_name)
|
app_setting_path = os.path.join(APPS_SETTING_PATH, app_instance_name)
|
||||||
|
|
||||||
# Retrieve arguments list for upgrade script
|
|
||||||
# TODO: Allow to specify arguments
|
|
||||||
args_odict = _parse_args_from_manifest(manifest, "upgrade")
|
|
||||||
|
|
||||||
# Prepare env. var. to pass to script
|
# Prepare env. var. to pass to script
|
||||||
env_dict = _make_environment_for_app_script(app_instance_name, args=args_odict)
|
env_dict = _make_environment_for_app_script(app_instance_name)
|
||||||
env_dict["YNH_APP_UPGRADE_TYPE"] = upgrade_type
|
env_dict["YNH_APP_UPGRADE_TYPE"] = upgrade_type
|
||||||
env_dict["YNH_APP_MANIFEST_VERSION"] = str(app_new_version)
|
env_dict["YNH_APP_MANIFEST_VERSION"] = str(app_new_version)
|
||||||
env_dict["YNH_APP_CURRENT_VERSION"] = str(app_current_version)
|
env_dict["YNH_APP_CURRENT_VERSION"] = str(app_current_version)
|
||||||
|
@ -905,13 +901,13 @@ def app_install(
|
||||||
app_instance_name = app_id
|
app_instance_name = app_id
|
||||||
|
|
||||||
# Retrieve arguments list for install script
|
# Retrieve arguments list for install script
|
||||||
args_dict = (
|
raw_questions = manifest.get("arguments", {}).get("install", {})
|
||||||
{} if not args else dict(urllib.parse.parse_qsl(args, keep_blank_values=True))
|
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}
|
||||||
args_odict = _parse_args_from_manifest(manifest, "install", args=args_dict)
|
|
||||||
|
|
||||||
# Validate domain / path availability for webapps
|
# Validate domain / path availability for webapps
|
||||||
_validate_and_normalize_webpath(args_odict, extracted_app_folder)
|
path_requirement = _guess_webapp_path_requirement(questions, extracted_app_folder)
|
||||||
|
_validate_webpath_requirement(questions, path_requirement)
|
||||||
|
|
||||||
# Attempt to patch legacy helpers ...
|
# Attempt to patch legacy helpers ...
|
||||||
_patch_legacy_helpers(extracted_app_folder)
|
_patch_legacy_helpers(extracted_app_folder)
|
||||||
|
@ -976,13 +972,14 @@ def app_install(
|
||||||
)
|
)
|
||||||
|
|
||||||
# Prepare env. var. to pass to script
|
# Prepare env. var. to pass to script
|
||||||
env_dict = _make_environment_for_app_script(app_instance_name, args=args_odict)
|
env_dict = _make_environment_for_app_script(app_instance_name, args=args)
|
||||||
env_dict["YNH_APP_BASEDIR"] = extracted_app_folder
|
env_dict["YNH_APP_BASEDIR"] = extracted_app_folder
|
||||||
|
|
||||||
env_dict_for_logging = env_dict.copy()
|
env_dict_for_logging = env_dict.copy()
|
||||||
for arg_name, arg_value_and_type in args_odict.items():
|
for question in questions:
|
||||||
if arg_value_and_type[1] == "password":
|
# Or should it be more generally question.redact ?
|
||||||
del env_dict_for_logging["YNH_APP_ARG_%s" % arg_name.upper()]
|
if question.type == "password":
|
||||||
|
del env_dict_for_logging["YNH_APP_ARG_%s" % question.name.upper()]
|
||||||
|
|
||||||
operation_logger.extra.update({"env": env_dict_for_logging})
|
operation_logger.extra.update({"env": env_dict_for_logging})
|
||||||
|
|
||||||
|
@ -1472,7 +1469,8 @@ def app_register_url(app, domain, path):
|
||||||
permission_sync_to_user,
|
permission_sync_to_user,
|
||||||
)
|
)
|
||||||
|
|
||||||
domain, path = _normalize_domain_path(domain, path)
|
domain = DomainQuestion.normalize(domain)
|
||||||
|
path = PathQuestion.normalize(path)
|
||||||
|
|
||||||
# We cannot change the url of an app already installed simply by changing
|
# We cannot change the url of an app already installed simply by changing
|
||||||
# the settings...
|
# the settings...
|
||||||
|
@ -1642,15 +1640,14 @@ def app_action_run(operation_logger, app, action, args=None):
|
||||||
action_declaration = actions[action]
|
action_declaration = actions[action]
|
||||||
|
|
||||||
# Retrieve arguments list for install script
|
# Retrieve arguments list for install script
|
||||||
args_dict = (
|
raw_questions = actions[action].get("arguments", {})
|
||||||
dict(urllib.parse.parse_qsl(args, keep_blank_values=True)) if args else {}
|
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}
|
||||||
args_odict = _parse_args_for_action(actions[action], args=args_dict)
|
|
||||||
|
|
||||||
tmp_workdir_for_app = _make_tmp_workdir_for_app(app=app)
|
tmp_workdir_for_app = _make_tmp_workdir_for_app(app=app)
|
||||||
|
|
||||||
env_dict = _make_environment_for_app_script(
|
env_dict = _make_environment_for_app_script(
|
||||||
app, args=args_odict, args_prefix="ACTION_"
|
app, args=args, args_prefix="ACTION_"
|
||||||
)
|
)
|
||||||
env_dict["YNH_ACTION"] = action
|
env_dict["YNH_ACTION"] = action
|
||||||
env_dict["YNH_APP_BASEDIR"] = tmp_workdir_for_app
|
env_dict["YNH_APP_BASEDIR"] = tmp_workdir_for_app
|
||||||
|
@ -2380,79 +2377,20 @@ def _check_manifest_requirements(manifest, app_instance_name):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _parse_args_from_manifest(manifest, action, args={}):
|
def _guess_webapp_path_requirement(questions: List[Question], app_folder: str) -> str:
|
||||||
"""Parse arguments needed for an action from the manifest
|
|
||||||
|
|
||||||
Retrieve specified arguments for the action from the manifest, and parse
|
|
||||||
given args according to that. If some required arguments are not provided,
|
|
||||||
its values will be asked if interaction is possible.
|
|
||||||
Parsed arguments will be returned as an OrderedDict
|
|
||||||
|
|
||||||
Keyword arguments:
|
|
||||||
manifest -- The app manifest to use
|
|
||||||
action -- The action to retrieve arguments for
|
|
||||||
args -- A dictionnary of arguments to parse
|
|
||||||
|
|
||||||
"""
|
|
||||||
if action not in manifest["arguments"]:
|
|
||||||
logger.debug("no arguments found for '%s' in manifest", action)
|
|
||||||
return OrderedDict()
|
|
||||||
|
|
||||||
action_args = manifest["arguments"][action]
|
|
||||||
return parse_args_in_yunohost_format(args, action_args)
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_args_for_action(action, args={}):
|
|
||||||
"""Parse arguments needed for an action from the actions list
|
|
||||||
|
|
||||||
Retrieve specified arguments for the action from the manifest, and parse
|
|
||||||
given args according to that. If some required arguments are not provided,
|
|
||||||
its values will be asked if interaction is possible.
|
|
||||||
Parsed arguments will be returned as an OrderedDict
|
|
||||||
|
|
||||||
Keyword arguments:
|
|
||||||
action -- The action
|
|
||||||
args -- A dictionnary of arguments to parse
|
|
||||||
|
|
||||||
"""
|
|
||||||
args_dict = OrderedDict()
|
|
||||||
|
|
||||||
if "arguments" not in action:
|
|
||||||
logger.debug("no arguments found for '%s' in manifest", action)
|
|
||||||
return args_dict
|
|
||||||
|
|
||||||
action_args = action["arguments"]
|
|
||||||
|
|
||||||
return parse_args_in_yunohost_format(args, action_args)
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_and_normalize_webpath(args_dict, app_folder):
|
|
||||||
|
|
||||||
# If there's only one "domain" and "path", validate that domain/path
|
# If there's only one "domain" and "path", validate that domain/path
|
||||||
# is an available url and normalize the path.
|
# is an available url and normalize the path.
|
||||||
|
|
||||||
domain_args = [
|
domain_questions = [question for question in questions if question.type == "domain"]
|
||||||
(name, value[0]) for name, value in args_dict.items() if value[1] == "domain"
|
path_questions = [question for question in questions if question.type == "path"]
|
||||||
]
|
|
||||||
path_args = [
|
|
||||||
(name, value[0]) for name, value in args_dict.items() if value[1] == "path"
|
|
||||||
]
|
|
||||||
|
|
||||||
if len(domain_args) == 1 and len(path_args) == 1:
|
|
||||||
|
|
||||||
domain = domain_args[0][1]
|
|
||||||
path = path_args[0][1]
|
|
||||||
domain, path = _normalize_domain_path(domain, path)
|
|
||||||
|
|
||||||
# Check the url is available
|
|
||||||
_assert_no_conflicting_apps(domain, path)
|
|
||||||
|
|
||||||
# (We save this normalized path so that the install script have a
|
|
||||||
# standard path format to deal with no matter what the user inputted)
|
|
||||||
args_dict[path_args[0][0]] = (path, "path")
|
|
||||||
|
|
||||||
|
if len(domain_questions) == 0 and len(path_questions) == 0:
|
||||||
|
return ""
|
||||||
|
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...
|
# This is likely to be a full-domain app...
|
||||||
elif len(domain_args) == 1 and len(path_args) == 0:
|
|
||||||
|
|
||||||
# Confirm that this is a full-domain app This should cover most cases
|
# Confirm that this is a full-domain app This should cover most cases
|
||||||
# ... though anyway the proper solution is to implement some mechanism
|
# ... though anyway the proper solution is to implement some mechanism
|
||||||
|
@ -2462,38 +2400,35 @@ def _validate_and_normalize_webpath(args_dict, app_folder):
|
||||||
|
|
||||||
# Full-domain apps typically declare something like path_url="/" or path=/
|
# Full-domain apps typically declare something like path_url="/" or path=/
|
||||||
# and use ynh_webpath_register or yunohost_app_checkurl inside the install script
|
# and use ynh_webpath_register or yunohost_app_checkurl inside the install script
|
||||||
install_script_content = open(
|
install_script_content = read_file(os.path.join(app_folder, "scripts/install"))
|
||||||
os.path.join(app_folder, "scripts/install")
|
|
||||||
).read()
|
|
||||||
|
|
||||||
if re.search(
|
if re.search(
|
||||||
r"\npath(_url)?=[\"']?/[\"']?\n", install_script_content
|
r"\npath(_url)?=[\"']?/[\"']?", install_script_content
|
||||||
) and re.search(
|
) and re.search(
|
||||||
r"(ynh_webpath_register|yunohost app checkurl)", install_script_content
|
r"ynh_webpath_register", install_script_content
|
||||||
):
|
):
|
||||||
|
return "full_domain"
|
||||||
|
|
||||||
domain = domain_args[0][1]
|
return "?"
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_webpath_requirement(questions: List[Question], path_requirement: str) -> None:
|
||||||
|
|
||||||
|
domain_questions = [question for question in questions if question.type == "domain"]
|
||||||
|
path_questions = [question for question in questions if question.type == "path"]
|
||||||
|
|
||||||
|
if path_requirement == "domain_and_path":
|
||||||
|
|
||||||
|
domain = domain_questions[0].value
|
||||||
|
path = path_questions[0].value
|
||||||
|
_assert_no_conflicting_apps(domain, path, full_domain=True)
|
||||||
|
|
||||||
|
elif path_requirement == "full_domain":
|
||||||
|
|
||||||
|
domain = domain_questions[0].value
|
||||||
_assert_no_conflicting_apps(domain, "/", full_domain=True)
|
_assert_no_conflicting_apps(domain, "/", full_domain=True)
|
||||||
|
|
||||||
|
|
||||||
def _normalize_domain_path(domain, path):
|
|
||||||
|
|
||||||
# We want url to be of the format :
|
|
||||||
# some.domain.tld/foo
|
|
||||||
|
|
||||||
# Remove http/https prefix if it's there
|
|
||||||
if domain.startswith("https://"):
|
|
||||||
domain = domain[len("https://") :]
|
|
||||||
elif domain.startswith("http://"):
|
|
||||||
domain = domain[len("http://") :]
|
|
||||||
|
|
||||||
# Remove trailing slashes
|
|
||||||
domain = domain.rstrip("/").lower()
|
|
||||||
path = "/" + path.strip("/")
|
|
||||||
|
|
||||||
return domain, path
|
|
||||||
|
|
||||||
|
|
||||||
def _get_conflicting_apps(domain, path, ignore_app=None):
|
def _get_conflicting_apps(domain, path, ignore_app=None):
|
||||||
"""
|
"""
|
||||||
Return a list of all conflicting apps with a domain/path (it can be empty)
|
Return a list of all conflicting apps with a domain/path (it can be empty)
|
||||||
|
@ -2506,7 +2441,8 @@ def _get_conflicting_apps(domain, path, ignore_app=None):
|
||||||
|
|
||||||
from yunohost.domain import _assert_domain_exists
|
from yunohost.domain import _assert_domain_exists
|
||||||
|
|
||||||
domain, path = _normalize_domain_path(domain, path)
|
domain = DomainQuestion.normalize(domain)
|
||||||
|
path = PathQuestion.normalize(path)
|
||||||
|
|
||||||
# Abort if domain is unknown
|
# Abort if domain is unknown
|
||||||
_assert_domain_exists(domain)
|
_assert_domain_exists(domain)
|
||||||
|
@ -2569,10 +2505,8 @@ def _make_environment_for_app_script(app, args={}, args_prefix="APP_ARG_"):
|
||||||
"YNH_APP_MANIFEST_VERSION": manifest.get("version", "?"),
|
"YNH_APP_MANIFEST_VERSION": manifest.get("version", "?"),
|
||||||
}
|
}
|
||||||
|
|
||||||
for arg_name, arg_value_and_type in args.items():
|
for arg_name, arg_value in args.items():
|
||||||
env_dict["YNH_%s%s" % (args_prefix, arg_name.upper())] = str(
|
env_dict["YNH_%s%s" % (args_prefix, arg_name.upper())] = str(arg_value)
|
||||||
arg_value_and_type[0]
|
|
||||||
)
|
|
||||||
|
|
||||||
return env_dict
|
return env_dict
|
||||||
|
|
||||||
|
|
|
@ -2,9 +2,11 @@ import glob
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import pytest
|
import pytest
|
||||||
|
from mock import patch
|
||||||
|
|
||||||
from .conftest import get_test_apps_dir
|
from .conftest import get_test_apps_dir
|
||||||
|
|
||||||
|
from moulinette import Moulinette
|
||||||
from moulinette.utils.filesystem import read_file
|
from moulinette.utils.filesystem import read_file
|
||||||
|
|
||||||
from yunohost.domain import _get_maindomain
|
from yunohost.domain import _get_maindomain
|
||||||
|
@ -146,7 +148,9 @@ def test_app_config_regular_setting(config_app):
|
||||||
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"
|
assert app_setting(config_app, "boolean") == "1"
|
||||||
|
|
||||||
with pytest.raises(YunohostValidationError):
|
with pytest.raises(YunohostValidationError), \
|
||||||
|
patch.object(os, "isatty", return_value=False), \
|
||||||
|
patch.object(Moulinette, "prompt", return_value="pwet"):
|
||||||
app_config_set(config_app, "main.components.boolean", "pwet")
|
app_config_set(config_app, "main.components.boolean", "pwet")
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -4,7 +4,7 @@ import os
|
||||||
from .conftest import get_test_apps_dir
|
from .conftest import get_test_apps_dir
|
||||||
|
|
||||||
from yunohost.utils.error import YunohostError
|
from yunohost.utils.error import YunohostError
|
||||||
from yunohost.app import app_install, app_remove, _normalize_domain_path
|
from yunohost.app import app_install, app_remove
|
||||||
from yunohost.domain import _get_maindomain, domain_url_available
|
from yunohost.domain import _get_maindomain, domain_url_available
|
||||||
from yunohost.permission import _validate_and_sanitize_permission_url
|
from yunohost.permission import _validate_and_sanitize_permission_url
|
||||||
|
|
||||||
|
@ -28,22 +28,6 @@ def teardown_function(function):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def test_normalize_domain_path():
|
|
||||||
|
|
||||||
assert _normalize_domain_path("https://yolo.swag/", "macnuggets") == (
|
|
||||||
"yolo.swag",
|
|
||||||
"/macnuggets",
|
|
||||||
)
|
|
||||||
assert _normalize_domain_path("http://yolo.swag", "/macnuggets/") == (
|
|
||||||
"yolo.swag",
|
|
||||||
"/macnuggets",
|
|
||||||
)
|
|
||||||
assert _normalize_domain_path("yolo.swag/", "macnuggets/") == (
|
|
||||||
"yolo.swag",
|
|
||||||
"/macnuggets",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_urlavailable():
|
def test_urlavailable():
|
||||||
|
|
||||||
# Except the maindomain/macnuggets to be available
|
# Except the maindomain/macnuggets to be available
|
||||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -420,7 +420,7 @@ def user_update(
|
||||||
# without a specified value, change_password will be set to the const 0.
|
# without a specified value, change_password will be set to the const 0.
|
||||||
# In this case we prompt for the new password.
|
# In this case we prompt for the new password.
|
||||||
if Moulinette.interface.type == "cli" and not change_password:
|
if Moulinette.interface.type == "cli" and not change_password:
|
||||||
change_password = Moulinette.prompt(m18n.n("ask_password"), True, True)
|
change_password = Moulinette.prompt(m18n.n("ask_password"), is_password=True, confirm=True)
|
||||||
# Ensure sufficiently complex password
|
# Ensure sufficiently complex password
|
||||||
assert_password_is_strong_enough("user", change_password)
|
assert_password_is_strong_enough("user", change_password)
|
||||||
|
|
||||||
|
|
|
@ -25,12 +25,13 @@ import urllib.parse
|
||||||
import tempfile
|
import tempfile
|
||||||
import shutil
|
import shutil
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from typing import Optional, Dict, List
|
from typing import Optional, Dict, List, Union, Any, Mapping
|
||||||
|
|
||||||
from moulinette.interfaces.cli import colorize
|
from moulinette.interfaces.cli import colorize
|
||||||
from moulinette import Moulinette, m18n
|
from moulinette import Moulinette, m18n
|
||||||
from moulinette.utils.log import getActionLogger
|
from moulinette.utils.log import getActionLogger
|
||||||
from moulinette.utils.filesystem import (
|
from moulinette.utils.filesystem import (
|
||||||
|
read_file,
|
||||||
write_to_file,
|
write_to_file,
|
||||||
read_toml,
|
read_toml,
|
||||||
read_yaml,
|
read_yaml,
|
||||||
|
@ -99,6 +100,9 @@ class ConfigPanel:
|
||||||
result[key]["value"] = question_class.humanize(
|
result[key]["value"] = question_class.humanize(
|
||||||
option["current_value"], option
|
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":
|
if mode == "full":
|
||||||
return self.config
|
return self.config
|
||||||
|
@ -164,6 +168,9 @@ class ConfigPanel:
|
||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
# Delete files uploaded from API
|
# 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()
|
FileQuestion.clean_upload_dirs()
|
||||||
|
|
||||||
self._reload_services()
|
self._reload_services()
|
||||||
|
@ -198,20 +205,20 @@ class ConfigPanel:
|
||||||
|
|
||||||
# Transform toml format into internal format
|
# Transform toml format into internal format
|
||||||
format_description = {
|
format_description = {
|
||||||
"toml": {
|
"root": {
|
||||||
"properties": ["version", "i18n"],
|
"properties": ["version", "i18n"],
|
||||||
"default": {"version": 1.0},
|
"defaults": {"version": 1.0},
|
||||||
},
|
},
|
||||||
"panels": {
|
"panels": {
|
||||||
"properties": ["name", "services", "actions", "help"],
|
"properties": ["name", "services", "actions", "help"],
|
||||||
"default": {
|
"defaults": {
|
||||||
"services": [],
|
"services": [],
|
||||||
"actions": {"apply": {"en": "Apply"}},
|
"actions": {"apply": {"en": "Apply"}},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
"properties": ["name", "services", "optional", "help", "visible"],
|
"properties": ["name", "services", "optional", "help", "visible"],
|
||||||
"default": {
|
"defaults": {
|
||||||
"name": "",
|
"name": "",
|
||||||
"services": [],
|
"services": [],
|
||||||
"optional": True,
|
"optional": True,
|
||||||
|
@ -241,11 +248,11 @@ class ConfigPanel:
|
||||||
"accept",
|
"accept",
|
||||||
"redact",
|
"redact",
|
||||||
],
|
],
|
||||||
"default": {},
|
"defaults": {},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
def convert(toml_node, node_type):
|
def _build_internal_config_panel(raw_infos, level):
|
||||||
"""Convert TOML in internal format ('full' mode used by webadmin)
|
"""Convert TOML in internal format ('full' mode used by webadmin)
|
||||||
Here are some properties of 1.0 config panel in toml:
|
Here are some properties of 1.0 config panel in toml:
|
||||||
- node properties and node children are mixed,
|
- node properties and node children are mixed,
|
||||||
|
@ -253,48 +260,49 @@ class ConfigPanel:
|
||||||
- some properties have default values
|
- some properties have default values
|
||||||
This function detects all children nodes and put them in a list
|
This function detects all children nodes and put them in a list
|
||||||
"""
|
"""
|
||||||
# Prefill the node default keys if needed
|
|
||||||
default = format_description[node_type]["default"]
|
|
||||||
node = {key: toml_node.get(key, value) for key, value in default.items()}
|
|
||||||
|
|
||||||
properties = format_description[node_type]["properties"]
|
defaults = format_description[level]["defaults"]
|
||||||
|
properties = format_description[level]["properties"]
|
||||||
|
|
||||||
# Define the filter_key part to use and the children type
|
# Start building the ouput (merging the raw infos + defaults)
|
||||||
i = list(format_description).index(node_type)
|
out = {key: raw_infos.get(key, value) for key, value in defaults.items()}
|
||||||
subnode_type = (
|
|
||||||
list(format_description)[i + 1] if node_type != "options" else None
|
# 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
|
search_key = filter_key[i] if len(filter_key) > i else False
|
||||||
|
|
||||||
for key, value in toml_node.items():
|
for key, value in raw_infos.items():
|
||||||
# Key/value are a child node
|
# Key/value are a child node
|
||||||
if (
|
if (
|
||||||
isinstance(value, OrderedDict)
|
isinstance(value, OrderedDict)
|
||||||
and key not in properties
|
and key not in properties
|
||||||
and subnode_type
|
and sublevel
|
||||||
):
|
):
|
||||||
# We exclude all nodes not referenced by the filter_key
|
# We exclude all nodes not referenced by the filter_key
|
||||||
if search_key and key != search_key:
|
if search_key and key != search_key:
|
||||||
continue
|
continue
|
||||||
subnode = convert(value, subnode_type)
|
subnode = _build_internal_config_panel(value, sublevel)
|
||||||
subnode["id"] = key
|
subnode["id"] = key
|
||||||
if node_type == "toml":
|
if level == "root":
|
||||||
subnode.setdefault("name", {"en": key.capitalize()})
|
subnode.setdefault("name", {"en": key.capitalize()})
|
||||||
elif node_type == "sections":
|
elif level == "sections":
|
||||||
subnode["name"] = key # legacy
|
subnode["name"] = key # legacy
|
||||||
subnode.setdefault("optional", toml_node.get("optional", True))
|
subnode.setdefault("optional", raw_infos.get("optional", True))
|
||||||
node.setdefault(subnode_type, []).append(subnode)
|
out.setdefault(sublevel, []).append(subnode)
|
||||||
# Key/value are a property
|
# Key/value are a property
|
||||||
else:
|
else:
|
||||||
if key not in properties:
|
if key not in properties:
|
||||||
logger.warning(f"Unknown key '{key}' found in config toml")
|
logger.warning(f"Unknown key '{key}' found in config panel")
|
||||||
# Todo search all i18n keys
|
# Todo search all i18n keys
|
||||||
node[key] = (
|
out[key] = (
|
||||||
value if key not in ["ask", "help", "name"] else {"en": value}
|
value if key not in ["ask", "help", "name"] else {"en": value}
|
||||||
)
|
)
|
||||||
return node
|
return out
|
||||||
|
|
||||||
self.config = convert(toml_config_panel, "toml")
|
self.config = _build_internal_config_panel(toml_config_panel, "root")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.config["panels"][0]["sections"][0]["options"][0]
|
self.config["panels"][0]["sections"][0]["options"][0]
|
||||||
|
@ -376,14 +384,13 @@ class ConfigPanel:
|
||||||
display_header(f"\n# {name}")
|
display_header(f"\n# {name}")
|
||||||
|
|
||||||
# Check and ask unanswered questions
|
# Check and ask unanswered questions
|
||||||
self.new_values.update(
|
questions = ask_questions_and_parse_answers(section["options"], self.args)
|
||||||
parse_args_in_yunohost_format(self.args, section["options"])
|
self.new_values.update({
|
||||||
)
|
question.name: question.value
|
||||||
self.new_values = {
|
for question in questions
|
||||||
key: value[0]
|
if question.value is not None
|
||||||
for key, value in self.new_values.items()
|
})
|
||||||
if not value[0] is None
|
|
||||||
}
|
|
||||||
self.errors = None
|
self.errors = None
|
||||||
|
|
||||||
def _get_default_values(self):
|
def _get_default_values(self):
|
||||||
|
@ -457,18 +464,20 @@ class Question(object):
|
||||||
hide_user_input_in_prompt = False
|
hide_user_input_in_prompt = False
|
||||||
pattern: Optional[Dict] = None
|
pattern: Optional[Dict] = None
|
||||||
|
|
||||||
def __init__(self, question, user_answers):
|
def __init__(self, question: Dict[str, Any]):
|
||||||
self.name = question["name"]
|
self.name = question["name"]
|
||||||
self.type = question.get("type", "string")
|
self.type = question.get("type", "string")
|
||||||
self.default = question.get("default", None)
|
self.default = question.get("default", None)
|
||||||
self.current_value = question.get("current_value")
|
|
||||||
self.optional = question.get("optional", False)
|
self.optional = question.get("optional", False)
|
||||||
self.choices = question.get("choices", [])
|
self.choices = question.get("choices", [])
|
||||||
self.pattern = question.get("pattern", self.pattern)
|
self.pattern = question.get("pattern", self.pattern)
|
||||||
self.ask = question.get("ask", {"en": self.name})
|
self.ask = question.get("ask", {"en": self.name})
|
||||||
self.help = question.get("help")
|
self.help = question.get("help")
|
||||||
self.value = user_answers.get(self.name)
|
|
||||||
self.redact = question.get("redact", False)
|
self.redact = question.get("redact", False)
|
||||||
|
# .current_value is the currently stored value
|
||||||
|
self.current_value = question.get("current_value")
|
||||||
|
# .value is the "proposed" value which we got from the user
|
||||||
|
self.value = question.get("value")
|
||||||
|
|
||||||
# Empty value is parsed as empty string
|
# Empty value is parsed as empty string
|
||||||
if self.default == "":
|
if self.default == "":
|
||||||
|
@ -480,6 +489,8 @@ class Question(object):
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def normalize(value, option={}):
|
def normalize(value, option={}):
|
||||||
|
if isinstance(value, str):
|
||||||
|
value = value.strip()
|
||||||
return value
|
return value
|
||||||
|
|
||||||
def _prompt(self, text):
|
def _prompt(self, text):
|
||||||
|
@ -491,9 +502,11 @@ class Question(object):
|
||||||
self.value = Moulinette.prompt(
|
self.value = Moulinette.prompt(
|
||||||
message=text,
|
message=text,
|
||||||
is_password=self.hide_user_input_in_prompt,
|
is_password=self.hide_user_input_in_prompt,
|
||||||
confirm=False, # We doesn't want to confirm this kind of password like in webadmin
|
confirm=False,
|
||||||
prefill=prefill,
|
prefill=prefill,
|
||||||
is_multiline=(self.type == "text"),
|
is_multiline=(self.type == "text"),
|
||||||
|
autocomplete=self.choices,
|
||||||
|
help=_value_for_locale(self.help)
|
||||||
)
|
)
|
||||||
|
|
||||||
def ask_if_needed(self):
|
def ask_if_needed(self):
|
||||||
|
@ -513,12 +526,9 @@ class Question(object):
|
||||||
):
|
):
|
||||||
self.value = class_default if self.default is None else self.default
|
self.value = class_default if self.default is None else self.default
|
||||||
|
|
||||||
# Normalization
|
|
||||||
# This is done to enforce a certain formating like for boolean
|
|
||||||
self.value = self.normalize(self.value, self)
|
|
||||||
|
|
||||||
# Prevalidation
|
|
||||||
try:
|
try:
|
||||||
|
# Normalize and validate
|
||||||
|
self.value = self.normalize(self.value, self)
|
||||||
self._prevalidate()
|
self._prevalidate()
|
||||||
except YunohostValidationError as e:
|
except YunohostValidationError as e:
|
||||||
# If in interactive cli, re-ask the current question
|
# If in interactive cli, re-ask the current question
|
||||||
|
@ -531,9 +541,10 @@ class Question(object):
|
||||||
raise
|
raise
|
||||||
|
|
||||||
break
|
break
|
||||||
|
|
||||||
self.value = self._post_parse_value()
|
self.value = self._post_parse_value()
|
||||||
|
|
||||||
return (self.value, self.argument_type)
|
return self.value
|
||||||
|
|
||||||
def _prevalidate(self):
|
def _prevalidate(self):
|
||||||
if self.value in [None, ""] and not self.optional:
|
if self.value in [None, ""] and not self.optional:
|
||||||
|
@ -542,7 +553,12 @@ class Question(object):
|
||||||
# we have an answer, do some post checks
|
# we have an answer, do some post checks
|
||||||
if self.value not in [None, ""]:
|
if self.value not in [None, ""]:
|
||||||
if self.choices and self.value not in self.choices:
|
if self.choices and self.value not in self.choices:
|
||||||
self._raise_invalid_answer()
|
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)):
|
if self.pattern and not re.match(self.pattern["regexp"], str(self.value)):
|
||||||
raise YunohostValidationError(
|
raise YunohostValidationError(
|
||||||
self.pattern["error"],
|
self.pattern["error"],
|
||||||
|
@ -550,25 +566,25 @@ class Question(object):
|
||||||
value=self.value,
|
value=self.value,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _raise_invalid_answer(self):
|
def _format_text_for_user_input_in_cli(self):
|
||||||
raise YunohostValidationError(
|
|
||||||
"app_argument_choice_invalid",
|
|
||||||
name=self.name,
|
|
||||||
value=self.value,
|
|
||||||
choices=", ".join(self.choices),
|
|
||||||
)
|
|
||||||
|
|
||||||
def _format_text_for_user_input_in_cli(self, column=False):
|
|
||||||
text_for_user_input_in_cli = _value_for_locale(self.ask)
|
text_for_user_input_in_cli = _value_for_locale(self.ask)
|
||||||
|
|
||||||
if self.choices:
|
if self.choices:
|
||||||
text_for_user_input_in_cli += " [{0}]".format(" | ".join(self.choices))
|
|
||||||
|
|
||||||
if self.help or column:
|
# Prevent displaying a shitload of choices
|
||||||
text_for_user_input_in_cli += ":\033[m"
|
# (e.g. 100+ available users when choosing an app admin...)
|
||||||
if self.help:
|
choices = list(self.choices.values()) if isinstance(self.choices, dict) else self.choices
|
||||||
text_for_user_input_in_cli += "\n - "
|
choices_to_display = choices[:20]
|
||||||
text_for_user_input_in_cli += _value_for_locale(self.help)
|
remaining_choices = len(choices[20:])
|
||||||
|
|
||||||
|
if remaining_choices > 0:
|
||||||
|
choices_to_display += [m18n.n("other_available_options", n=remaining_choices)]
|
||||||
|
|
||||||
|
choices_to_display = " | ".join(choices_to_display)
|
||||||
|
|
||||||
|
text_for_user_input_in_cli += f" [{choices_to_display}]"
|
||||||
|
|
||||||
return text_for_user_input_in_cli
|
return text_for_user_input_in_cli
|
||||||
|
|
||||||
def _post_parse_value(self):
|
def _post_parse_value(self):
|
||||||
|
@ -659,6 +675,8 @@ class TagsQuestion(Question):
|
||||||
def normalize(value, option={}):
|
def normalize(value, option={}):
|
||||||
if isinstance(value, list):
|
if isinstance(value, list):
|
||||||
return ",".join(value)
|
return ",".join(value)
|
||||||
|
if isinstance(value, str):
|
||||||
|
value = value.strip()
|
||||||
return value
|
return value
|
||||||
|
|
||||||
def _prevalidate(self):
|
def _prevalidate(self):
|
||||||
|
@ -684,20 +702,14 @@ class PasswordQuestion(Question):
|
||||||
default_value = ""
|
default_value = ""
|
||||||
forbidden_chars = "{}"
|
forbidden_chars = "{}"
|
||||||
|
|
||||||
def __init__(self, question, user_answers):
|
def __init__(self, question):
|
||||||
super().__init__(question, user_answers)
|
super().__init__(question)
|
||||||
self.redact = True
|
self.redact = True
|
||||||
if self.default is not None:
|
if self.default is not None:
|
||||||
raise YunohostValidationError(
|
raise YunohostValidationError(
|
||||||
"app_argument_password_no_default", name=self.name
|
"app_argument_password_no_default", name=self.name
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def humanize(value, option={}):
|
|
||||||
if value:
|
|
||||||
return "********" # Avoid to display the password on screen
|
|
||||||
return ""
|
|
||||||
|
|
||||||
def _prevalidate(self):
|
def _prevalidate(self):
|
||||||
super()._prevalidate()
|
super()._prevalidate()
|
||||||
|
|
||||||
|
@ -712,34 +724,31 @@ class PasswordQuestion(Question):
|
||||||
|
|
||||||
assert_password_is_strong_enough("user", self.value)
|
assert_password_is_strong_enough("user", self.value)
|
||||||
|
|
||||||
def _format_text_for_user_input_in_cli(self):
|
|
||||||
need_column = self.current_value or self.optional
|
|
||||||
text_for_user_input_in_cli = super()._format_text_for_user_input_in_cli(
|
|
||||||
need_column
|
|
||||||
)
|
|
||||||
if self.current_value:
|
|
||||||
text_for_user_input_in_cli += "\n - " + m18n.n(
|
|
||||||
"app_argument_password_help_keep"
|
|
||||||
)
|
|
||||||
if self.optional:
|
|
||||||
text_for_user_input_in_cli += "\n - " + m18n.n(
|
|
||||||
"app_argument_password_help_optional"
|
|
||||||
)
|
|
||||||
|
|
||||||
return text_for_user_input_in_cli
|
|
||||||
|
|
||||||
def _prompt(self, text):
|
|
||||||
super()._prompt(text)
|
|
||||||
if self.current_value and self.value == "":
|
|
||||||
self.value = self.current_value
|
|
||||||
elif self.value == " ":
|
|
||||||
self.value = ""
|
|
||||||
|
|
||||||
|
|
||||||
class PathQuestion(Question):
|
class PathQuestion(Question):
|
||||||
argument_type = "path"
|
argument_type = "path"
|
||||||
default_value = ""
|
default_value = ""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def normalize(value, option={}):
|
||||||
|
|
||||||
|
option = option.__dict__ if isinstance(option, Question) else option
|
||||||
|
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
|
return "/" + value.strip().strip(" /")
|
||||||
|
|
||||||
|
|
||||||
class BooleanQuestion(Question):
|
class BooleanQuestion(Question):
|
||||||
argument_type = "boolean"
|
argument_type = "boolean"
|
||||||
|
@ -750,50 +759,66 @@ class BooleanQuestion(Question):
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def humanize(value, option={}):
|
def humanize(value, option={}):
|
||||||
|
|
||||||
|
option = option.__dict__ if isinstance(option, Question) else option
|
||||||
|
|
||||||
yes = option.get("yes", 1)
|
yes = option.get("yes", 1)
|
||||||
no = option.get("no", 0)
|
no = option.get("no", 0)
|
||||||
value = str(value).lower()
|
|
||||||
if value == str(yes).lower():
|
|
||||||
return "yes"
|
|
||||||
if value == str(no).lower():
|
|
||||||
return "no"
|
|
||||||
if value in BooleanQuestion.yes_answers:
|
|
||||||
return "yes"
|
|
||||||
if value in BooleanQuestion.no_answers:
|
|
||||||
return "no"
|
|
||||||
|
|
||||||
if value in ["none", ""]:
|
value = BooleanQuestion.normalize(value, option)
|
||||||
|
|
||||||
|
if value == yes:
|
||||||
|
return "yes"
|
||||||
|
if value == no:
|
||||||
|
return "no"
|
||||||
|
if value is None:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
raise YunohostValidationError(
|
raise YunohostValidationError(
|
||||||
"app_argument_choice_invalid",
|
"app_argument_choice_invalid",
|
||||||
name=option.get("name", ""),
|
name=option.get("name"),
|
||||||
value=value,
|
value=value,
|
||||||
choices="yes, no, y, n, 1, 0",
|
choices="yes/no",
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def normalize(value, option={}):
|
def normalize(value, option={}):
|
||||||
yes = option.get("yes", 1)
|
|
||||||
no = option.get("no", 0)
|
|
||||||
|
|
||||||
if str(value).lower() in BooleanQuestion.yes_answers:
|
option = option.__dict__ if isinstance(option, Question) else option
|
||||||
return yes
|
|
||||||
|
|
||||||
if str(value).lower() in BooleanQuestion.no_answers:
|
if isinstance(value, str):
|
||||||
return no
|
value = value.strip()
|
||||||
|
|
||||||
if value in [None, ""]:
|
technical_yes = option.get("yes", 1)
|
||||||
|
technical_no = option.get("no", 0)
|
||||||
|
|
||||||
|
no_answers = BooleanQuestion.no_answers
|
||||||
|
yes_answers = BooleanQuestion.yes_answers
|
||||||
|
|
||||||
|
assert str(technical_yes).lower() not in no_answers, f"'yes' value can't be in {no_answers}"
|
||||||
|
assert str(technical_no).lower() not in yes_answers, f"'no' value can't be in {yes_answers}"
|
||||||
|
|
||||||
|
no_answers += [str(technical_no).lower()]
|
||||||
|
yes_answers += [str(technical_yes).lower()]
|
||||||
|
|
||||||
|
strvalue = str(value).lower()
|
||||||
|
|
||||||
|
if strvalue in yes_answers:
|
||||||
|
return technical_yes
|
||||||
|
if strvalue in no_answers:
|
||||||
|
return technical_no
|
||||||
|
|
||||||
|
if strvalue in ["none", ""]:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
raise YunohostValidationError(
|
raise YunohostValidationError(
|
||||||
"app_argument_choice_invalid",
|
"app_argument_choice_invalid",
|
||||||
name=option.get("name", ""),
|
name=option.get("name"),
|
||||||
value=value,
|
value=strvalue,
|
||||||
choices="yes, no, y, n, 1, 0",
|
choices="yes/no",
|
||||||
)
|
)
|
||||||
|
|
||||||
def __init__(self, question, user_answers):
|
def __init__(self, question):
|
||||||
super().__init__(question, user_answers)
|
super().__init__(question)
|
||||||
self.yes = question.get("yes", 1)
|
self.yes = question.get("yes", 1)
|
||||||
self.no = question.get("no", 0)
|
self.no = question.get("no", 0)
|
||||||
if self.default is None:
|
if self.default is None:
|
||||||
|
@ -807,42 +832,44 @@ class BooleanQuestion(Question):
|
||||||
return text_for_user_input_in_cli
|
return text_for_user_input_in_cli
|
||||||
|
|
||||||
def get(self, key, default=None):
|
def get(self, key, default=None):
|
||||||
try:
|
return getattr(self, key, default)
|
||||||
return getattr(self, key)
|
|
||||||
except AttributeError:
|
|
||||||
return default
|
|
||||||
|
|
||||||
|
|
||||||
class DomainQuestion(Question):
|
class DomainQuestion(Question):
|
||||||
argument_type = "domain"
|
argument_type = "domain"
|
||||||
|
|
||||||
def __init__(self, question, user_answers):
|
def __init__(self, question):
|
||||||
from yunohost.domain import domain_list, _get_maindomain
|
from yunohost.domain import domain_list, _get_maindomain
|
||||||
|
|
||||||
super().__init__(question, user_answers)
|
super().__init__(question)
|
||||||
|
|
||||||
if self.default is None:
|
if self.default is None:
|
||||||
self.default = _get_maindomain()
|
self.default = _get_maindomain()
|
||||||
|
|
||||||
self.choices = domain_list()["domains"]
|
self.choices = domain_list()["domains"]
|
||||||
|
|
||||||
def _raise_invalid_answer(self):
|
@staticmethod
|
||||||
raise YunohostValidationError(
|
def normalize(value, option={}):
|
||||||
"app_argument_invalid",
|
if value.startswith("https://"):
|
||||||
name=self.name,
|
value = value[len("https://"):]
|
||||||
error=m18n.n("domain_name_unknown", domain=self.value),
|
elif value.startswith("http://"):
|
||||||
)
|
value = value[len("http://"):]
|
||||||
|
|
||||||
|
# Remove trailing slashes
|
||||||
|
value = value.rstrip("/").lower()
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
class UserQuestion(Question):
|
class UserQuestion(Question):
|
||||||
argument_type = "user"
|
argument_type = "user"
|
||||||
|
|
||||||
def __init__(self, question, user_answers):
|
def __init__(self, question):
|
||||||
from yunohost.user import user_list, user_info
|
from yunohost.user import user_list, user_info
|
||||||
from yunohost.domain import _get_maindomain
|
from yunohost.domain import _get_maindomain
|
||||||
|
|
||||||
super().__init__(question, user_answers)
|
super().__init__(question)
|
||||||
self.choices = user_list()["users"]
|
self.choices = list(user_list()["users"].keys())
|
||||||
|
|
||||||
if not self.choices:
|
if not self.choices:
|
||||||
raise YunohostValidationError(
|
raise YunohostValidationError(
|
||||||
|
@ -853,42 +880,42 @@ class UserQuestion(Question):
|
||||||
|
|
||||||
if self.default is None:
|
if self.default is None:
|
||||||
root_mail = "root@%s" % _get_maindomain()
|
root_mail = "root@%s" % _get_maindomain()
|
||||||
for user in self.choices.keys():
|
for user in self.choices:
|
||||||
if root_mail in user_info(user).get("mail-aliases", []):
|
if root_mail in user_info(user).get("mail-aliases", []):
|
||||||
self.default = user
|
self.default = user
|
||||||
break
|
break
|
||||||
|
|
||||||
def _raise_invalid_answer(self):
|
|
||||||
raise YunohostValidationError(
|
|
||||||
"app_argument_invalid",
|
|
||||||
name=self.name,
|
|
||||||
error=m18n.n("user_unknown", user=self.value),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class NumberQuestion(Question):
|
class NumberQuestion(Question):
|
||||||
argument_type = "number"
|
argument_type = "number"
|
||||||
default_value = None
|
default_value = None
|
||||||
|
|
||||||
def __init__(self, question, user_answers):
|
def __init__(self, question):
|
||||||
super().__init__(question, user_answers)
|
super().__init__(question)
|
||||||
self.min = question.get("min", None)
|
self.min = question.get("min", None)
|
||||||
self.max = question.get("max", None)
|
self.max = question.get("max", None)
|
||||||
self.step = question.get("step", None)
|
self.step = question.get("step", None)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def normalize(value, option={}):
|
def normalize(value, option={}):
|
||||||
|
|
||||||
if isinstance(value, int):
|
if isinstance(value, int):
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
if isinstance(value, str):
|
||||||
|
value = value.strip()
|
||||||
|
|
||||||
if isinstance(value, str) and value.isdigit():
|
if isinstance(value, str) and value.isdigit():
|
||||||
return int(value)
|
return int(value)
|
||||||
|
|
||||||
if value in [None, ""]:
|
if value in [None, ""]:
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
option = option.__dict__ if isinstance(option, Question) else option
|
||||||
raise YunohostValidationError(
|
raise YunohostValidationError(
|
||||||
"app_argument_invalid", name=option.name, error=m18n.n("invalid_number")
|
"app_argument_invalid",
|
||||||
|
name=option.get("name"),
|
||||||
|
error=m18n.n("invalid_number")
|
||||||
)
|
)
|
||||||
|
|
||||||
def _prevalidate(self):
|
def _prevalidate(self):
|
||||||
|
@ -915,8 +942,8 @@ class DisplayTextQuestion(Question):
|
||||||
argument_type = "display_text"
|
argument_type = "display_text"
|
||||||
readonly = True
|
readonly = True
|
||||||
|
|
||||||
def __init__(self, question, user_answers):
|
def __init__(self, question):
|
||||||
super().__init__(question, user_answers)
|
super().__init__(question)
|
||||||
|
|
||||||
self.optional = True
|
self.optional = True
|
||||||
self.style = question.get(
|
self.style = question.get(
|
||||||
|
@ -946,90 +973,50 @@ class FileQuestion(Question):
|
||||||
@classmethod
|
@classmethod
|
||||||
def clean_upload_dirs(cls):
|
def clean_upload_dirs(cls):
|
||||||
# Delete files uploaded from API
|
# Delete files uploaded from API
|
||||||
if Moulinette.interface.type == "api":
|
|
||||||
for upload_dir in cls.upload_dirs:
|
for upload_dir in cls.upload_dirs:
|
||||||
if os.path.exists(upload_dir):
|
if os.path.exists(upload_dir):
|
||||||
shutil.rmtree(upload_dir)
|
shutil.rmtree(upload_dir)
|
||||||
|
|
||||||
def __init__(self, question, user_answers):
|
def __init__(self, question):
|
||||||
super().__init__(question, user_answers)
|
super().__init__(question)
|
||||||
if question.get("accept"):
|
self.accept = question.get("accept", "")
|
||||||
self.accept = question.get("accept")
|
|
||||||
else:
|
|
||||||
self.accept = ""
|
|
||||||
if Moulinette.interface.type == "api":
|
|
||||||
if user_answers.get(f"{self.name}[name]"):
|
|
||||||
self.value = {
|
|
||||||
"content": self.value,
|
|
||||||
"filename": user_answers.get(f"{self.name}[name]", self.name),
|
|
||||||
}
|
|
||||||
|
|
||||||
def _prevalidate(self):
|
def _prevalidate(self):
|
||||||
if self.value is None:
|
if self.value is None:
|
||||||
self.value = self.current_value
|
self.value = self.current_value
|
||||||
|
|
||||||
super()._prevalidate()
|
super()._prevalidate()
|
||||||
if (
|
|
||||||
isinstance(self.value, str)
|
|
||||||
and self.value
|
|
||||||
and not os.path.exists(self.value)
|
|
||||||
):
|
|
||||||
raise YunohostValidationError(
|
|
||||||
"app_argument_invalid",
|
|
||||||
name=self.name,
|
|
||||||
error=m18n.n("file_does_not_exist", path=self.value),
|
|
||||||
)
|
|
||||||
if self.value in [None, ""] or not self.accept:
|
|
||||||
return
|
|
||||||
|
|
||||||
filename = self.value if isinstance(self.value, str) else self.value["filename"]
|
if Moulinette.interface.type != "api":
|
||||||
if "." not in filename or "." + filename.split(".")[
|
if not self.value or not os.path.exists(str(self.value)):
|
||||||
-1
|
|
||||||
] not in self.accept.replace(" ", "").split(","):
|
|
||||||
raise YunohostValidationError(
|
raise YunohostValidationError(
|
||||||
"app_argument_invalid",
|
"app_argument_invalid",
|
||||||
name=self.name,
|
name=self.name,
|
||||||
error=m18n.n(
|
error=m18n.n("file_does_not_exist", path=str(self.value)),
|
||||||
"file_extension_not_accepted", file=filename, accept=self.accept
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def _post_parse_value(self):
|
def _post_parse_value(self):
|
||||||
from base64 import b64decode
|
from base64 import b64decode
|
||||||
|
|
||||||
# Upload files from API
|
|
||||||
# A file arg contains a string with "FILENAME:BASE64_CONTENT"
|
|
||||||
if not self.value:
|
if not self.value:
|
||||||
return self.value
|
return self.value
|
||||||
|
|
||||||
if Moulinette.interface.type == "api" and isinstance(self.value, dict):
|
upload_dir = tempfile.mkdtemp(prefix="ynh_filequestion_")
|
||||||
|
_, file_path = tempfile.mkstemp(dir=upload_dir)
|
||||||
|
|
||||||
upload_dir = tempfile.mkdtemp(prefix="tmp_configpanel_")
|
|
||||||
FileQuestion.upload_dirs += [upload_dir]
|
FileQuestion.upload_dirs += [upload_dir]
|
||||||
filename = self.value["filename"]
|
|
||||||
logger.debug(
|
|
||||||
f"Save uploaded file {self.value['filename']} from API into {upload_dir}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Filename is given by user of the API. For security reason, we have replaced
|
logger.debug(f"Saving file {self.name} for file question into {file_path}")
|
||||||
# os.path.join to avoid the user to be able to rewrite a file in filesystem
|
if Moulinette.interface.type != "api":
|
||||||
# i.e. os.path.join("/foo", "/etc/passwd") == "/etc/passwd"
|
content = read_file(str(self.value), file_mode="rb")
|
||||||
file_path = os.path.normpath(upload_dir + "/" + filename)
|
|
||||||
if not file_path.startswith(upload_dir + "/"):
|
|
||||||
raise YunohostError(
|
|
||||||
f"Filename '{filename}' received from the API got a relative parent path, which is forbidden",
|
|
||||||
raw_msg=True,
|
|
||||||
)
|
|
||||||
i = 2
|
|
||||||
while os.path.exists(file_path):
|
|
||||||
file_path = os.path.normpath(upload_dir + "/" + filename + (".%d" % i))
|
|
||||||
i += 1
|
|
||||||
|
|
||||||
content = self.value["content"]
|
if Moulinette.interface.type == "api":
|
||||||
|
content = b64decode(self.value)
|
||||||
|
|
||||||
write_to_file(file_path, b64decode(content), file_mode="wb")
|
write_to_file(file_path, content, file_mode="wb")
|
||||||
|
|
||||||
self.value = file_path
|
self.value = file_path
|
||||||
|
|
||||||
return self.value
|
return self.value
|
||||||
|
|
||||||
|
|
||||||
|
@ -1057,25 +1044,37 @@ ARGUMENTS_TYPE_PARSERS = {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def parse_args_in_yunohost_format(user_answers, argument_questions):
|
def ask_questions_and_parse_answers(questions: Dict, prefilled_answers: Union[str, Mapping[str, Any]] = {}) -> List[Question]:
|
||||||
"""Parse arguments store in either manifest.json or actions.json or from a
|
"""Parse arguments store in either manifest.json or actions.json or from a
|
||||||
config panel against the user answers when they are present.
|
config panel against the user answers when they are present.
|
||||||
|
|
||||||
Keyword arguments:
|
Keyword arguments:
|
||||||
user_answers -- a dictionnary of arguments from the user (generally
|
questions -- the arguments description store in yunohost
|
||||||
empty in CLI, filed from the admin interface)
|
|
||||||
argument_questions -- the arguments description store in yunohost
|
|
||||||
format from actions.json/toml, manifest.json/toml
|
format from actions.json/toml, manifest.json/toml
|
||||||
or config_panel.json/toml
|
or config_panel.json/toml
|
||||||
|
prefilled_answers -- a url "query-string" such as "domain=yolo.test&path=/foobar&admin=sam"
|
||||||
|
or a dict such as {"domain": "yolo.test", "path": "/foobar", "admin": "sam"}
|
||||||
"""
|
"""
|
||||||
parsed_answers_dict = OrderedDict()
|
|
||||||
|
|
||||||
for question in argument_questions:
|
if isinstance(prefilled_answers, str):
|
||||||
|
# FIXME FIXME : this is not uniform with config_set() which uses parse.qs (no l)
|
||||||
|
# parse_qsl parse single values
|
||||||
|
# whereas parse.qs return list of values (which is useful for tags, etc)
|
||||||
|
# For now, let's not migrate this piece of code to parse_qs
|
||||||
|
# Because Aleks believes some bits of the app CI rely on overriding values (e.g. foo=foo&...&foo=bar)
|
||||||
|
prefilled_answers = dict(urllib.parse.parse_qsl(prefilled_answers or "", keep_blank_values=True))
|
||||||
|
|
||||||
|
if not prefilled_answers:
|
||||||
|
prefilled_answers = {}
|
||||||
|
|
||||||
|
out = []
|
||||||
|
|
||||||
|
for question in questions:
|
||||||
question_class = ARGUMENTS_TYPE_PARSERS[question.get("type", "string")]
|
question_class = ARGUMENTS_TYPE_PARSERS[question.get("type", "string")]
|
||||||
question = question_class(question, user_answers)
|
question["value"] = prefilled_answers.get(question["name"])
|
||||||
|
question = question_class(question)
|
||||||
|
|
||||||
answer = question.ask_if_needed()
|
question.ask_if_needed()
|
||||||
if answer is not None:
|
out.append(question)
|
||||||
parsed_answers_dict[question.name] = answer
|
|
||||||
|
|
||||||
return parsed_answers_dict
|
return out
|
||||||
|
|
Loading…
Add table
Reference in a new issue