From 2360a50195d681cbe3da48983b8b51095b54af02 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 30 May 2026 18:00:57 +0200 Subject: [PATCH] sharing/bday: parameter validation --- radicale/config.py | 6 +- radicale/item/__init__.py | 126 ++++++++++++++++++----------------- radicale/sharing/__init__.py | 51 ++++++++++++-- 3 files changed, 112 insertions(+), 71 deletions(-) diff --git a/radicale/config.py b/radicale/config.py index b92c6bb4..9679c975 100644 --- a/radicale/config.py +++ b/radicale/config.py @@ -597,15 +597,15 @@ DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([ ("conversion_bday_summary_template", { "value": "[{n:f} {n:g} {n:a}|{fn}|{nickname}] (BDAY)", "help": "conversion bday summary template", - "type": str}), + "type": sharing.check_template}), ("conversion_bday_description_template", { "value": "BDAY={year}-{month}-{day}", "help": "conversion bday description template", - "type": str}), + "type": sharing.check_template}), ("conversion_bday_alarm_trigger_template", { "value": "", "help": "conversion bday alarm trigger template", - "type": str}), + "type": sharing.check_template_alarm_trigger}), ("conversion_bday_age_max", { "value": "99", "help": "conversion bday age max", diff --git a/radicale/item/__init__.py b/radicale/item/__init__.py index faa602dc..2e8a6ef4 100644 --- a/radicale/item/__init__.py +++ b/radicale/item/__init__.py @@ -47,6 +47,8 @@ PRODID_CONVERTED = u"-//Radicale//NONSGML " + utils.package_version("radicale") PRODID_SUFFIX = " (auto-converted by Radicale " + utils.package_version("radicale") + ")" UID_SUFFIX = "-auto-converted-by-Radicale" +VCF_TO_ICS_SUPPORTED_PLACEHOLDERS: list = ["fn", "n:f", "n:g", "n:a", "age", "nickname", "year", "month", "day"] + def read_components(s: str) -> List[vobject.base.Component]: """Wrapper for vobject.readComponents""" @@ -362,6 +364,65 @@ def verify(file: str, encoding: str): return True +def replace_placeholders(text: str, placeholder_mapping: dict) -> str: + for placeholder in placeholder_mapping: + text = text.replace(placeholder, placeholder_mapping[placeholder]) + + # resolve {..|..} recursive + pattern = re.compile('(.*)(\\[)([^|]+)\\|(.+)(\\])(.*)') + logger.trace("item/convert_vcf_to_ics: resolve [..|..] starting with: %r", text) + while True: + match = pattern.match(text) + if not match: + # nothing more todo + break + else: + if match[3].startswith('!') and match[3].endswith('!'): + # not resolved variable + if '|' in match[4]: + # further recursion required + text = match[1] + match[2] + match[4] + match[5] + match[6] + logger.trace("item/convert_vcf_to_ics: resolve [..|..] match/replace/continue result: %r", text) + else: + text = match[1] + match[4] + match[6] + logger.trace("item/convert_vcf_to_ics: resolve [..|..] match/replace/final result: %r", text) + break + else: + # resolved variable + text = match[1] + match[3] + match[6] + logger.trace("item/convert_vcf_to_ics: resolve [..|..] match/replace(resolved) result: %r", text) + return text + + +def trigger_to_timedelta(trigger) -> Union[datetime.timedelta, None]: + # workaround as vobject is not supporting direct set of value + # limited implementatino of reverse function of timedeltaToString in vobject/icalendar.py + pattern = re.compile('([+-])?([0-9]+)([WDHM])$') + match = pattern.match(trigger) + if not match: + logger.error("item/convert_vcf_to_ics: trigger time value not valid: %r", trigger) + return None + + sign = 1 + if match[1] == "-": + sign = -1 + + value = int(match[2]) * sign + + td: Union[datetime.timedelta, None] = None + + if match[3] == "D": + td = datetime.timedelta(days=value) + elif match[3] == "M": + td = datetime.timedelta(minutes=value) + elif match[3] == "H": + td = datetime.timedelta(hours=value) + elif match[3] == "W": + td = datetime.timedelta(weeks=value) + + return td + + class Item: """Class for address book and calendar entries.""" @@ -504,63 +565,6 @@ class Item: self.component_name self._vobject_item = orig_vobject_item - def replace_placeholders(self, text: str, placeholder_mapping: dict) -> str: - for placeholder in placeholder_mapping: - text = text.replace(placeholder, placeholder_mapping[placeholder]) - - # resolve {..|..} recursive - pattern = re.compile('(.*)(\\[)([^|]+)\\|(.+)(\\])(.*)') - logger.trace("item/convert_vcf_to_ics: resolve [..|..] starting with: %r", text) - while True: - match = pattern.match(text) - if not match: - # nothing more todo - break - else: - if match[3].startswith('!') and match[3].endswith('!'): - # not resolved variable - if '|' in match[4]: - # further recursion required - text = match[1] + match[2] + match[4] + match[5] + match[6] - logger.trace("item/convert_vcf_to_ics: resolve [..|..] match/replace/continue result: %r", text) - else: - text = match[1] + match[4] + match[6] - logger.trace("item/convert_vcf_to_ics: resolve [..|..] match/replace/final result: %r", text) - break - else: - # resolved variable - text = match[1] + match[3] + match[6] - logger.trace("item/convert_vcf_to_ics: resolve [..|..] match/replace(resolved) result: %r", text) - return text - - def trigger_to_timedelta(self, trigger) -> Union[datetime.timedelta, None]: - # workaround as vobject is not supporting direct set of value - # limited implementatino of reverse function of timedeltaToString in vobject/icalendar.py - pattern = re.compile('([+-])?([0-9]+)([WDHM])$') - match = pattern.match(trigger) - if not match: - logger.error("item/convert_vcf_to_ics: trigger time value not valid: %r", trigger) - return None - - sign = 1 - if match[1] == "-": - sign = -1 - - value = int(match[2]) * sign - - td: Union[datetime.timedelta, None] = None - - if match[3] == "D": - td = datetime.timedelta(days=value) - elif match[3] == "M": - td = datetime.timedelta(minutes=value) - elif match[3] == "H": - td = datetime.timedelta(hours=value) - elif match[3] == "W": - td = datetime.timedelta(weeks=value) - - return td - def convert_vcf_to_ics(self, ShareActions: dict = {}) -> Union["Item", None]: logger.trace("item/convert_vcf_to_ics: ShareActions: %r", ShareActions) logger.trace("item/convert_vcf_to_ics: convert VCF to ICS (href): %r", self.href) @@ -650,14 +654,14 @@ class Item: if ShareActions is not None and 'config' in ShareActions: if 'conversion_bday_summary_template' in ShareActions['config']: summary = ShareActions['config']['conversion_bday_summary_template'] - summary = self.replace_placeholders(summary, placeholder_mapping) + summary = replace_placeholders(summary, placeholder_mapping) # create DESCRIPTION description = "BDAY=" + bdaySdesc # default if ShareActions is not None and 'config' in ShareActions: if 'conversion_bday_description_template' in ShareActions['config']: description = ShareActions['config']['conversion_bday_description_template'] - description = self.replace_placeholders(description, placeholder_mapping) + description = replace_placeholders(description, placeholder_mapping) # check ALARM alarm_trigger = "" # default @@ -719,9 +723,9 @@ class Item: for entry in alarm_trigger.split('|'): (trigger, alarm_description) = entry.split(';') logger.trace("item/convert_vcf_to_ics: alarm trigger entry: %r (trigger=%r description=%r)", entry, trigger, description) - td = self.trigger_to_timedelta(trigger) + td = trigger_to_timedelta(trigger) if td is not None: - alarm_description = self.replace_placeholders(alarm_description, placeholder_mapping) + alarm_description = replace_placeholders(alarm_description, placeholder_mapping) alarm_description_value = alarm_description.replace("{age}", str(age)) valarm = vevent.add('valarm') valarm.add('action').value = "DISPLAY" diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index 1b5020ca..dca7a89d 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -27,8 +27,8 @@ from http import client from typing import Any, Sequence, Union from urllib.parse import parse_qs -from radicale import (config, httputils, pathutils, rights, storage, types, - utils) +from radicale import (config, httputils, item, pathutils, rights, storage, + types, utils) from radicale.log import logger INTERNAL_TYPES: Sequence[str] = ("csv", "files", "none") @@ -135,11 +135,49 @@ def check_bday_max_age(data: Any) -> int: return value +def check_template(data: Any) -> str: + placeholder_mapping: dict = {} + for placeholder in item.VCF_TO_ICS_SUPPORTED_PLACEHOLDERS: + placeholder_mapping["{" + placeholder + "}"] = '!' + placeholder + '!' + + result = item.replace_placeholders(data, placeholder_mapping) + logger.trace("replace placeholders: %r -> %r", data, result) + pattern = re.compile('.*{.*}.*') + if pattern.search(result): + raise ValueError("template contains unsupported placeholder {..}: %r" % result) + return data + + +def check_template_alarm_trigger(data: Any) -> str: + if data is not None and data != '': + for entry in data.split('|'): + try: + (trigger, alarm_description) = entry.split(';') + except ValueError: + raise ValueError("alarm trigger template misses ;") + + if trigger is not None and trigger != '': + td = item.trigger_to_timedelta(trigger) + if td is None: + raise ValueError("alarm trigger template contains unsupported trigger: %r" % trigger) + else: + raise ValueError("alarm trigger template misses trigger") + + if alarm_description is not None and alarm_description != '': + try: + check_template(alarm_description) + except Exception as e: + raise e + else: + raise ValueError("alarm trigger template misses description") + return data + + ACTIONS_WHITELIST: dict = { 'config': { - 'conversion_bday_summary_template': str, - 'conversion_bday_description_template': str, - 'conversion_bday_alarm_trigger_template': str, + 'conversion_bday_summary_template': check_template, + 'conversion_bday_description_template': check_template, + 'conversion_bday_alarm_trigger_template': check_template_alarm_trigger, 'conversion_bday_age_max': check_bday_max_age, }, } @@ -872,12 +910,11 @@ class BaseSharing: if level1 in ACTIONS_WHITELIST: for level2 in request_data['Actions'][level1]: if level2 in ACTIONS_WHITELIST[level1]: - logger.trace(api_info + ": Actions validation: type='%r'", type(ACTIONS_WHITELIST[level1][level2])) if callable(ACTIONS_WHITELIST[level1][level2]): try: value = ACTIONS_WHITELIST[level1][level2](request_data['Actions'][level1][level2]) except ValueError: - hint = "'" + level1 + "': {'" + level2 + "'} is out-of-range" + hint = "'" + level1 + "': {'" + level2 + "'} is not supported" valid = False break pass