sharing/bday: parameter validation

This commit is contained in:
Peter Bieringer
2026-05-30 18:00:57 +02:00
parent ebb359c5cc
commit 2360a50195
3 changed files with 112 additions and 71 deletions

View File

@@ -597,15 +597,15 @@ DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([
("conversion_bday_summary_template", { ("conversion_bday_summary_template", {
"value": "[{n:f} {n:g} {n:a}|{fn}|{nickname}] (BDAY)", "value": "[{n:f} {n:g} {n:a}|{fn}|{nickname}] (BDAY)",
"help": "conversion bday summary template", "help": "conversion bday summary template",
"type": str}), "type": sharing.check_template}),
("conversion_bday_description_template", { ("conversion_bday_description_template", {
"value": "BDAY={year}-{month}-{day}", "value": "BDAY={year}-{month}-{day}",
"help": "conversion bday description template", "help": "conversion bday description template",
"type": str}), "type": sharing.check_template}),
("conversion_bday_alarm_trigger_template", { ("conversion_bday_alarm_trigger_template", {
"value": "", "value": "",
"help": "conversion bday alarm trigger template", "help": "conversion bday alarm trigger template",
"type": str}), "type": sharing.check_template_alarm_trigger}),
("conversion_bday_age_max", { ("conversion_bday_age_max", {
"value": "99", "value": "99",
"help": "conversion bday age max", "help": "conversion bday age max",

View File

@@ -47,6 +47,8 @@ PRODID_CONVERTED = u"-//Radicale//NONSGML " + utils.package_version("radicale")
PRODID_SUFFIX = " (auto-converted by Radicale " + utils.package_version("radicale") + ")" PRODID_SUFFIX = " (auto-converted by Radicale " + utils.package_version("radicale") + ")"
UID_SUFFIX = "-auto-converted-by-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]: def read_components(s: str) -> List[vobject.base.Component]:
"""Wrapper for vobject.readComponents""" """Wrapper for vobject.readComponents"""
@@ -362,6 +364,65 @@ def verify(file: str, encoding: str):
return True 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 Item:
"""Class for address book and calendar entries.""" """Class for address book and calendar entries."""
@@ -504,63 +565,6 @@ class Item:
self.component_name self.component_name
self._vobject_item = orig_vobject_item 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]: 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: ShareActions: %r", ShareActions)
logger.trace("item/convert_vcf_to_ics: convert VCF to ICS (href): %r", self.href) 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 ShareActions is not None and 'config' in ShareActions:
if 'conversion_bday_summary_template' in ShareActions['config']: if 'conversion_bday_summary_template' in ShareActions['config']:
summary = ShareActions['config']['conversion_bday_summary_template'] summary = ShareActions['config']['conversion_bday_summary_template']
summary = self.replace_placeholders(summary, placeholder_mapping) summary = replace_placeholders(summary, placeholder_mapping)
# create DESCRIPTION # create DESCRIPTION
description = "BDAY=" + bdaySdesc # default description = "BDAY=" + bdaySdesc # default
if ShareActions is not None and 'config' in ShareActions: if ShareActions is not None and 'config' in ShareActions:
if 'conversion_bday_description_template' in ShareActions['config']: if 'conversion_bday_description_template' in ShareActions['config']:
description = ShareActions['config']['conversion_bday_description_template'] description = ShareActions['config']['conversion_bday_description_template']
description = self.replace_placeholders(description, placeholder_mapping) description = replace_placeholders(description, placeholder_mapping)
# check ALARM # check ALARM
alarm_trigger = "" # default alarm_trigger = "" # default
@@ -719,9 +723,9 @@ class Item:
for entry in alarm_trigger.split('|'): for entry in alarm_trigger.split('|'):
(trigger, alarm_description) = entry.split(';') (trigger, alarm_description) = entry.split(';')
logger.trace("item/convert_vcf_to_ics: alarm trigger entry: %r (trigger=%r description=%r)", entry, trigger, description) 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: 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)) alarm_description_value = alarm_description.replace("{age}", str(age))
valarm = vevent.add('valarm') valarm = vevent.add('valarm')
valarm.add('action').value = "DISPLAY" valarm.add('action').value = "DISPLAY"

View File

@@ -27,8 +27,8 @@ from http import client
from typing import Any, Sequence, Union from typing import Any, Sequence, Union
from urllib.parse import parse_qs from urllib.parse import parse_qs
from radicale import (config, httputils, pathutils, rights, storage, types, from radicale import (config, httputils, item, pathutils, rights, storage,
utils) types, utils)
from radicale.log import logger from radicale.log import logger
INTERNAL_TYPES: Sequence[str] = ("csv", "files", "none") INTERNAL_TYPES: Sequence[str] = ("csv", "files", "none")
@@ -135,11 +135,49 @@ def check_bday_max_age(data: Any) -> int:
return value 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 <trigger>;<alarm description>")
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 = { ACTIONS_WHITELIST: dict = {
'config': { 'config': {
'conversion_bday_summary_template': str, 'conversion_bday_summary_template': check_template,
'conversion_bday_description_template': str, 'conversion_bday_description_template': check_template,
'conversion_bday_alarm_trigger_template': str, 'conversion_bday_alarm_trigger_template': check_template_alarm_trigger,
'conversion_bday_age_max': check_bday_max_age, 'conversion_bday_age_max': check_bday_max_age,
}, },
} }
@@ -872,12 +910,11 @@ class BaseSharing:
if level1 in ACTIONS_WHITELIST: if level1 in ACTIONS_WHITELIST:
for level2 in request_data['Actions'][level1]: for level2 in request_data['Actions'][level1]:
if level2 in ACTIONS_WHITELIST[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]): if callable(ACTIONS_WHITELIST[level1][level2]):
try: try:
value = ACTIONS_WHITELIST[level1][level2](request_data['Actions'][level1][level2]) value = ACTIONS_WHITELIST[level1][level2](request_data['Actions'][level1][level2])
except ValueError: except ValueError:
hint = "'" + level1 + "': {'" + level2 + "'} is out-of-range" hint = "'" + level1 + "': {'" + level2 + "'} is not supported"
valid = False valid = False
break break
pass pass