[enh] Add visible attribute support in cli

This commit is contained in:
ljf 2021-10-06 02:37:27 +02:00
parent bbba01a72c
commit 7425684552
2 changed files with 259 additions and 19 deletions

View file

@ -15,6 +15,7 @@ from yunohost.utils.config import (
PathQuestion, PathQuestion,
BooleanQuestion, BooleanQuestion,
FileQuestion, FileQuestion,
evaluate_simple_js_expression
) )
from yunohost.utils.error import YunohostError, YunohostValidationError from yunohost.utils.error import YunohostError, YunohostValidationError
@ -2093,3 +2094,98 @@ def test_normalize_path():
assert PathQuestion.normalize("/macnuggets/") == "/macnuggets" assert PathQuestion.normalize("/macnuggets/") == "/macnuggets"
assert PathQuestion.normalize("macnuggets/") == "/macnuggets" assert PathQuestion.normalize("macnuggets/") == "/macnuggets"
assert PathQuestion.normalize("////macnuggets///") == "/macnuggets" assert PathQuestion.normalize("////macnuggets///") == "/macnuggets"
def test_simple_evaluate():
context = {
'a1': 1,
'b2': 2,
'c10': 10,
'foo': 'bar',
'comp': '1>2',
'empty': '',
'lorem': 'Lorem ipsum dolor et si qua met!',
'warning': 'Warning! This sentence will fail!',
'quote': "Je s'apelle Groot",
'and_': '&&',
'object': { 'a': 'Security risk' }
}
supported = {
'42': 42,
'9.5': 9.5,
"'bopbidibopbopbop'": 'bopbidibopbopbop',
'true': True,
'false': False,
'null': None,
# Math
'1 * (2 + 3 * (4 - 3))': 5,
'1 * (2 + 3 * (4 - 3)) > 10 - 2 || 3 * 2 > 9 - 2 * 3': True,
'(9 - 2) * 3 - 10': 11,
'12 - 2 * -2 + (3 - 4) * 3.1': 12.9,
'9 / 12 + 12 * 3 - 5': 31.75,
'9 / 12 + 12 * (3 - 5)': -23.25,
'12 > 13.1': False,
'12 < 14': True,
'12 <= 14': True,
'12 >= 14': False,
'12 == 14': False,
'12 % 5 > 3': False,
'12 != 14': True,
'9 - 1 > 10 && 3 * 5 > 10': False,
'9 - 1 > 10 || 3 * 5 > 10': True,
'a1 > 0 || a1 < -12': True,
'a1 > 0 && a1 < -12': False,
'a1 + 1 > 0 && -a1 > -12': True,
'-(a1 + 1) < 0 || -(a1 + 2) > -12': True,
'-a1 * 2': -2,
'(9 - 2) * 3 - c10': 11,
'(9 - b2) * 3 - c10': 11,
'c10 > b2': True,
# String
"foo == 'bar'":True,
"foo != 'bar'":False,
'foo == "bar" && 1 > 0':True,
'!!foo': True,
'!foo': False,
'foo': 'bar',
'!(foo > "baa") || 1 > 2': False,
'!(foo > "baa") || 1 < 2': True,
'empty == ""': True,
'1 == "1"': True,
'1.0 == "1"': True,
'1 == "aaa"': False,
"'I am ' + b2 + ' years'": 'I am 2 years',
"quote == 'Je s\\'apelle Groot'": True,
"lorem == 'Lorem ipsum dolor et si qua met!'": True,
"and_ == '&&'": True,
"warning == 'Warning! This sentence will fail!'": True,
# Match
"match(lorem, '^Lorem [ia]psumE?')": bool,
"match(foo, '^Lorem [ia]psumE?')": None,
"match(lorem, '^Lorem [ia]psumE?') && 1 == 1": bool,
# No code
"": False,
" ": False,
}
trigger_errors = {
"object.a": YunohostError, # Keep unsupported, for security reasons
'a1 ** b2': YunohostError, # Keep unsupported, for security reasons
'().__class__.__bases__[0].__subclasses__()': YunohostError, # Very dangerous code
'a1 > 11 ? 1 : 0': SyntaxError,
'c10 > b2 == false': YunohostError, # JS and Python doesn't do the same thing for this situation
'c10 > b2 == true': YunohostError,
}
for expression, result in supported.items():
if result == bool:
assert bool(evaluate_simple_js_expression(expression, context)), expression
else:
assert evaluate_simple_js_expression(expression, context) == result, expression
for expression, error in trigger_errors.items():
with pytest.raises(error):
evaluate_simple_js_expression(expression, context)

View file

@ -24,6 +24,8 @@ import re
import urllib.parse import urllib.parse
import tempfile import tempfile
import shutil import shutil
import ast
import operator as op
from collections import OrderedDict from collections import OrderedDict
from typing import Optional, Dict, List, Union, Any, Mapping from typing import Optional, Dict, List, Union, Any, Mapping
@ -46,6 +48,138 @@ from yunohost.log import OperationLogger
logger = getActionLogger("yunohost.config") logger = getActionLogger("yunohost.config")
CONFIG_PANEL_VERSION_SUPPORTED = 1.0 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
# https://github.com/shepherdwind/simple-evaluate
def evaluate_simple_ast(node, context={}):
operators = {
ast.Not: op.not_,
ast.Mult: op.mul,
ast.Div: op.truediv, # number
ast.Mod: op.mod, # number
ast.Add: op.add, #str
ast.Sub: op.sub, #number
ast.USub: op.neg, # Negative number
ast.Gt: op.gt,
ast.Lt: op.lt,
ast.GtE: op.ge,
ast.LtE: op.le,
ast.Eq: op.eq,
ast.NotEq: op.ne
}
context['true'] = True
context['false'] = False
context['null'] = None
# Variable
if isinstance(node, ast.Name): # Variable
return context[node.id]
# Python <=3.7 String
elif isinstance(node, ast.Str):
return node.s
# Python <=3.7 Number
elif isinstance(node, ast.Num):
return node.n
# Boolean, None and Python 3.8 for Number, Boolean, String and None
elif isinstance(node, (ast.Constant, ast.NameConstant)):
return node.value
# + - * / %
elif isinstance(node, ast.BinOp) and type(node.op) in operators: # <left> <operator> <right>
left = evaluate_simple_ast(node.left, context)
right = evaluate_simple_ast(node.right, context)
if type(node.op) == ast.Add:
if isinstance(left, str) or isinstance(right, str): # support 'I am ' + 42
left = str(left)
right = str(right)
elif type(left) != type(right): # support "111" - "1" -> 110
left = float(left)
right = float(right)
return operators[type(node.op)](left, right)
# Comparison
# JS and Python don't give the same result for multi operators
# like True == 10 > 2.
elif isinstance(node, ast.Compare) and len(node.comparators) == 1: # <left> <ops> <comparators>
left = evaluate_simple_ast(node.left, context)
right = evaluate_simple_ast(node.comparators[0], context)
operator = node.ops[0]
if isinstance(left, (int, float)) or isinstance(right, (int, float)):
try:
left = float(left)
right = float(right)
except ValueError:
return type(operator) == ast.NotEq
try:
return operators[type(operator)](left, right)
except TypeError: # support "e" > 1 -> False like in JS
return False
# and / or
elif isinstance(node, ast.BoolOp): # <op> <values>
values = node.values
for value in node.values:
value = evaluate_simple_ast(value, context)
if isinstance(node.op, ast.And) and not value:
return False
elif isinstance(node.op, ast.Or) and value:
return True
return isinstance(node.op, ast.And)
# not / USub (it's negation number -\d)
elif isinstance(node, ast.UnaryOp): # <operator> <operand> e.g., -1
return operators[type(node.op)](evaluate_simple_ast(node.operand, context))
# match function call
elif isinstance(node, ast.Call) and node.func.__dict__.get('id') == 'match':
return re.match(
evaluate_simple_ast(node.args[1], context),
context[node.args[0].id]
)
# Unauthorized opcode
else:
opcode = str(type(node))
raise YunohostError(f"Unauthorize opcode '{opcode}' in visible attribute", raw_msg=True)
def js_to_python(expr):
in_string = None
py_expr = ""
i = 0
escaped = False
for char in expr:
if char in r"\"'":
# Start a string
if not in_string:
in_string = char
# Finish a string
elif in_string == char and not escaped:
in_string = None
# If we are not in a string, replace operators
elif not in_string:
if char == "!" and expr[i +1] != "=":
char = "not "
elif char in "|&" and py_expr[-1:] == char:
py_expr = py_expr[:-1]
char = " and " if char == "&" else " or "
# Determine if next loop will be in escaped mode
escaped = char == "\\" and not escaped
py_expr += char
i+=1
return py_expr
def evaluate_simple_js_expression(expr, context={}):
if not expr.strip():
return False
node = ast.parse(js_to_python(expr), mode="eval").body
return evaluate_simple_ast(node, context)
class ConfigPanel: class ConfigPanel:
def __init__(self, config_path, save_path=None): def __init__(self, config_path, save_path=None):
@ -466,11 +600,13 @@ 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: Dict[str, Any]): def __init__(self, question: Dict[str, Any], context: 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.optional = question.get("optional", False) self.optional = question.get("optional", False)
self.visible = question.get("visible", None)
self.context = context
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})
@ -512,6 +648,15 @@ class Question(object):
) )
def ask_if_needed(self): 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 = None
return self.value
for i in range(5): for i in range(5):
# Display question if no value filled or if it's a readonly message # Display question if no value filled or if it's a readonly message
if Moulinette.interface.type == "cli" and os.isatty(1): if Moulinette.interface.type == "cli" and os.isatty(1):
@ -577,7 +722,7 @@ class Question(object):
# Prevent displaying a shitload of choices # Prevent displaying a shitload of choices
# (e.g. 100+ available users when choosing an app admin...) # (e.g. 100+ available users when choosing an app admin...)
choices = ( choices = (
list(self.choices.values()) list(self.choices.keys())
if isinstance(self.choices, dict) if isinstance(self.choices, dict)
else self.choices else self.choices
) )
@ -710,8 +855,8 @@ class PasswordQuestion(Question):
default_value = "" default_value = ""
forbidden_chars = "{}" forbidden_chars = "{}"
def __init__(self, question): def __init__(self, question, context: Dict[str, Any] = {}):
super().__init__(question) super().__init__(question, context)
self.redact = True self.redact = True
if self.default is not None: if self.default is not None:
raise YunohostValidationError( raise YunohostValidationError(
@ -829,8 +974,8 @@ class BooleanQuestion(Question):
choices="yes/no", choices="yes/no",
) )
def __init__(self, question): def __init__(self, question, context: Dict[str, Any] = {}):
super().__init__(question) super().__init__(question, context)
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:
@ -850,10 +995,10 @@ class BooleanQuestion(Question):
class DomainQuestion(Question): class DomainQuestion(Question):
argument_type = "domain" argument_type = "domain"
def __init__(self, question): def __init__(self, question, context: Dict[str, Any] = {}):
from yunohost.domain import domain_list, _get_maindomain from yunohost.domain import domain_list, _get_maindomain
super().__init__(question) super().__init__(question, context)
if self.default is None: if self.default is None:
self.default = _get_maindomain() self.default = _get_maindomain()
@ -876,11 +1021,11 @@ class DomainQuestion(Question):
class UserQuestion(Question): class UserQuestion(Question):
argument_type = "user" argument_type = "user"
def __init__(self, question): def __init__(self, question, context: Dict[str, Any] = {}):
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) super().__init__(question, context)
self.choices = list(user_list()["users"].keys()) self.choices = list(user_list()["users"].keys())
if not self.choices: if not self.choices:
@ -902,8 +1047,8 @@ class NumberQuestion(Question):
argument_type = "number" argument_type = "number"
default_value = None default_value = None
def __init__(self, question): def __init__(self, question, context: Dict[str, Any] = {}):
super().__init__(question) super().__init__(question, context)
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)
@ -954,8 +1099,8 @@ class DisplayTextQuestion(Question):
argument_type = "display_text" argument_type = "display_text"
readonly = True readonly = True
def __init__(self, question): def __init__(self, question, context: Dict[str, Any] = {}):
super().__init__(question) super().__init__(question, context)
self.optional = True self.optional = True
self.style = question.get( self.style = question.get(
@ -989,8 +1134,8 @@ class FileQuestion(Question):
if os.path.exists(upload_dir): if os.path.exists(upload_dir):
shutil.rmtree(upload_dir) shutil.rmtree(upload_dir)
def __init__(self, question): def __init__(self, question, context: Dict[str, Any] = {}):
super().__init__(question) super().__init__(question, context)
self.accept = question.get("accept", "") self.accept = question.get("accept", "")
def _prevalidate(self): def _prevalidate(self):
@ -1089,9 +1234,8 @@ def ask_questions_and_parse_answers(
for question in questions: for question in questions:
question_class = ARGUMENTS_TYPE_PARSERS[question.get("type", "string")] question_class = ARGUMENTS_TYPE_PARSERS[question.get("type", "string")]
question["value"] = prefilled_answers.get(question["name"]) question["value"] = prefilled_answers.get(question["name"])
question = question_class(question) question = question_class(question, prefilled_answers)
prefilled_answers[question.name] = question.ask_if_needed()
question.ask_if_needed()
out.append(question) out.append(question)
return out return out