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", {
"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",

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") + ")"
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"

View File

@@ -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 <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 = {
'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