diff --git a/CHANGELOG.md b/CHANGELOG.md index 588c598a..9dcdc6ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,12 @@ # Changelog ## 3.7.5.dev +* Add: [sharing] conversion_bday_summary_template (customize summary) +* Add: [sharing] conversion_bday_description_template (customize description) +* Add: [sharing] conversion_bday_alarm_trigger_template (customize alarms) +* Add: [sharing] conversion_bday_categories (customize) +* Add: [sharing] conversion_bday_age_max (limit in case of "age" placeholder is used which blocks using RRULE) +* Extension: [sharing/bday conversion]: add STATUS + CLASS fields ## 3.7.4 * Fix: sharing: PROPFIND returns now empty owner element in case of a mapped share as clients try PROPFIND on this not accessable href diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index da78ebb9..7f6d958e 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -2287,6 +2287,8 @@ Default: `true` ##### default_permissions_create_token +_(>= 3.7.0)_ + Default permissions for create token-based sharing Default: `r` @@ -2295,12 +2297,77 @@ Supported: `rwEePp` ##### default_permissions_create_map +_(>= 3.7.0)_ + Default permissions for map-based sharing Default: `r` Supported: `rwEePp` +##### conversion_bday_summary_template + +_(>= 3.7.5)_ + +Global template for summary of conversion "bday" + +Default: `{{n:f} {n:g}|{fn}|{nickname}} ({year}) (BDAY)` + +Supported placeholders (data used from VCARD) + * `{year}`: year of birthday (RFC6350#6.2.5) + * `{month}`: month of birthday (RFC6350#6.2.5) + * `{day}`: day of birthday (RFC6350#6.2.5) + * `{fn}`: full name (RFC6350#6.2.1) + * `{n:f}`: family name (RFC6350#6.2.2) + * `{n:g}`: given name (RFC6350#6.2.2) + * `{n:a}`: additional name (RFC6350#6.2.2) + * `{nickname}`: nick name (RFC6350#6.2.3) + +Supported extra placeholders + * `{age}`: age, toggles to creation of single events instead using RRULE + +Fallback is supported if placeholders inside `[...|...]` (first successful resolved one is used) + +##### conversion_bday_description_template + +_(>= 3.7.5)_ + +Global template for description of conversion "bday" + +Default: `BDAY={year}-{month}-{day}` + +Supported placeholders see `conversion_bday_summary_template` + +##### conversion_bday_alarm_trigger_template + +_(>= 3.7.5)_ + +Global template for alarm trigger of conversion "bday" + +Default: `` + +Supported format: `TIMEDELTA;DESCRIPTION` (separated by `|` if more alarms should be generated) + +Supported format for `TIMEDELTA`: `[+-]?[0-9]+[WDHM]` + +Supported placeholders for `DESCRIPTION` see `conversion_bday_summary_template` + +##### conversion_bday_categories + +_(>= 3.7.5)_ + +Global categories of conversion "bday", separated by `,` + +Default: `Birthday` + +##### conversion_bday_age_max + +_(>= 3.7.5)_ + +Global max limit of "bday" age, only active in case of `{age}` is used as placeholder + +Default: `99` + ## Supported Clients Radicale has been tested with: diff --git a/SHARING.md b/SHARING.md index 29ce35da..6fd52791 100644 --- a/SHARING.md +++ b/SHARING.md @@ -43,7 +43,7 @@ Types of supported sharing configuration: * `TimestampCreated`: unixtime of creation * `TimestampUpdated`: unixtime of last update * `Properties`: overlay properties (limited set whitelisted) - * `Actions`: (reserved for future usage) + * `Actions`: specific configuration `Enabled*`: _owner_ AND _user_ have to enable a share to become usable @@ -246,6 +246,7 @@ Can be selected by `HTTP_ACCEPT` - default is equal to provided `CONTENT_TYPE` * `Enabled`: owner/user selected by authentication * `Hidden`: owner/user selected by authentication * `Properties`: properties to overlay + * `Actions`: specific configuration ### API Hooks @@ -315,7 +316,8 @@ Create a share by mapping a collection of an `Owner` to a token. | Permissions | str | optional (default:rp) | | Enabled | bool | optional (owner/default:False) | | Hidden | bool | optional (owner/default:True) | -| Properties | str | optional | +| Properties | str(dict) | optional | +| Actions | str(dict) | optional | * Output: text/plain|application/json @@ -364,7 +366,8 @@ Create a share by mapping a collection of an `Owner` to an `User`. | Permissions | str | optional (default:r) | | Enabled | bool | optional (owner/default:False) | | Hidden | bool | optional (owner/default:True) | -| Properties | optional | +| Properties | str(dict) | optional | +| Actions | str(dict) | optional | * Output: text/plain|application/json @@ -518,7 +521,8 @@ Execute delete+create in case `PathOrToken` needs to be changed. | Permissions | str | adjust | optional | not-permitted | | Enabled | bool | adjust | optional(owner) | optional(user) | | Hidden | bool | adjust | optional(owner) | optional(user) | -| Properties | str | adjust | optional | optional | +| Properties | str(dict) | adjust | optional | optional | +| Actions | str(dict) | adjust | optional | not-permitted | * Output: text/plain|application/json @@ -675,7 +679,6 @@ curl -u user:$userpw -d "$xml_pfc" -X PROPFIND http://localhost:5232/user/cal1-f Owner can create for itself or for particular user a virtual bday collection from an existing addressbook. - ### Examples Preconditions: @@ -687,6 +690,8 @@ Preconditions: #### Examples using API +##### Mapping + * Create as *map* ```bash @@ -712,6 +717,8 @@ END:VCALENDAR Via WebUI an additional (virtual) calendar collection appears +##### Mapping as token + * Create as *token* ```bash @@ -722,7 +729,7 @@ Status='success' PathOrToken='/.token/v1/lqqwqhZYTGi9uSPsixien_8G5jiSK0FfhNFRGG_t8UA0/' ``` - * Fetch *map* + * Fetch *token* ```bash ## Fetch VCALENDAR auto-created from VADDRESSBOOK diff --git a/config b/config index 0efb2ab0..77f42526 100644 --- a/config +++ b/config @@ -356,6 +356,23 @@ # Supported: rwEePp #default_permissions_create_map = r +# Global template for summary of conversion "bday" +#conversion_bday_summary_template = [{n:f} {n:g} |{fn}|{nickname}] ({year}) (BDAY) + +# Global template for description of conversion "bday" +#conversion_bday_description_template = BDAY={year}-{month}-{day} + +# Global template of alarm trigger of conversion "bday" +# Example: "-15H;BDAY tomorrow|9H;BDAY today" +#conversion_bday_alarm_trigger_template = "" + +# Global categories of conversion "bday" +#conversion_bday_categories = Birthday + +# Global max limit of "bday" age +# Only active in case of {age} is used as placeholder +#conversion_bday_age_max = 99 + [web] diff --git a/radicale/app/get.py b/radicale/app/get.py index 21bd0349..542ff4d2 100644 --- a/radicale/app/get.py +++ b/radicale/app/get.py @@ -136,9 +136,9 @@ class ApplicationPartGet(ApplicationBase): if share and share['Conversion'] == "bday": if isinstance(item, storage.BaseCollection): # convert VCF to ICS - answer = item.serialize(vcf_to_ics=True) + answer = item.serialize(vcf_to_ics=True, ShareActions=share['Actions']) else: - item_converted = item.convert_vcf_to_ics() + item_converted = item.convert_vcf_to_ics(ShareActions=share['Actions']) if item_converted is not None: answer = item_converted.serialize() else: diff --git a/radicale/app/propfind.py b/radicale/app/propfind.py index 4ba26020..1a574c68 100644 --- a/radicale/app/propfind.py +++ b/radicale/app/propfind.py @@ -230,7 +230,7 @@ def xml_propfind_response( element.text = item.etag else: if share_bday_automap: - item_converted = item.convert_vcf_to_ics() + item_converted = item.convert_vcf_to_ics(ShareActions=share['Actions']) if item_converted: element.text = item_converted.etag else: @@ -382,14 +382,14 @@ def xml_propfind_response( logger.trace("PROPFIND/xml_propfind_response/getcontentlength: start bday automap handling for collection") length = 0 for entry in item.get_all(): - item_ics = entry.convert_vcf_to_ics() + item_ics = entry.convert_vcf_to_ics(ShareActions=share['Actions']) if item_ics is None: continue length += len(item_ics.vobject_item.serialize().encode(encoding)) element.text = str(length) else: logger.trace("PROPFIND/xml_propfind_response/getcontentlength: start bday automap handling for single item") - item_converted = item.convert_vcf_to_ics() + item_converted = item.convert_vcf_to_ics(ShareActions=share['Actions']) if item_converted is not None: element.text = str(len(item_converted.serialize())) else: @@ -457,7 +457,7 @@ def xml_propfind_response( logger.trace("PROPFIND/xml_propfind_response/getcontentcount: start bday automap handling") items = [] for entry in item.get_all(): - item_ics = entry.convert_vcf_to_ics() + item_ics = entry.convert_vcf_to_ics(ShareActions=share['Actions']) if item_ics is None: continue items.append(item_ics.vobject_item) @@ -626,7 +626,7 @@ class ApplicationPartPropfind(ApplicationBase): for item, permission, raw_permissions in item_list: if self._sharing._enabled and share: if share['Conversion'] == "bday" and not isinstance(item, storage.BaseCollection): - if not item.convert_vcf_to_ics(): + if not item.convert_vcf_to_ics(ShareActions=share['Actions']): if len_item_list == 1: # only dedicated item requested return httputils.NOT_FOUND diff --git a/radicale/app/report.py b/radicale/app/report.py index 93461e2e..05fda541 100644 --- a/radicale/app/report.py +++ b/radicale/app/report.py @@ -267,7 +267,7 @@ def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element], # autoconvert retrieved_items_vcf_to_ics = [] for item, flag in retrieved_items: - item_ics = item.convert_vcf_to_ics() + item_ics = item.convert_vcf_to_ics(ShareActions=share['Actions']) if item_ics is None: continue else: diff --git a/radicale/config.py b/radicale/config.py index 394dac97..cf390a2d 100644 --- a/radicale/config.py +++ b/radicale/config.py @@ -593,7 +593,28 @@ DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([ ("default_permissions_create_map", { "value": "r", "help": "default permissions for map-based sharing", - "type": rights_permission})])), + "type": rights_permission}), + ("conversion_bday_summary_template", { + "value": sharing.SHARING_BDAY_SUMMARY_TEMPLATE_DEFAULT, + "help": "conversion bday summary template", + "type": sharing.check_template_not_empty}), + ("conversion_bday_description_template", { + "value": sharing.SHARING_BDAY_DESCRIPTION_TEMPLATE_DEFAULT, + "help": "conversion bday description template", + "type": sharing.check_template}), + ("conversion_bday_alarm_trigger_template", { + "value": "", + "help": "conversion bday alarm trigger template", + "type": sharing.check_template_alarm_trigger}), + ("conversion_bday_categories", { + "value": sharing.SHARING_BDAY_CATEGORIES_DEFAULT, + "help": "conversion bday categories", + "type": str}), + ("conversion_bday_age_max", { + "value": str(sharing.SHARING_BDAY_AGE_MAX_DEFAULT), + "help": "conversion bday age max", + "type": sharing.check_bday_max_age}), + ])), ("hook", OrderedDict([ ("type", { "value": "none", diff --git a/radicale/item/__init__.py b/radicale/item/__init__.py index a3fea54f..14d563c7 100644 --- a/radicale/item/__init__.py +++ b/radicale/item/__init__.py @@ -38,7 +38,7 @@ from typing import (Any, Callable, List, MutableMapping, Optional, Sequence, import vobject from radicale import storage # noqa:F401 -from radicale import pathutils, utils +from radicale import pathutils, sharing, utils from radicale.item import filter as radicale_filter from radicale.log import logger @@ -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,7 +565,8 @@ class Item: self.component_name self._vobject_item = orig_vobject_item - def convert_vcf_to_ics(self) -> 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: convert VCF to ICS (href): %r", self.href) logger.trace("item/convert_vcf_to_ics: convert VCF to ICS (vobject): %r", self.vobject_item) if self.vobject_item.name != "VCARD": @@ -529,23 +591,54 @@ class Item: else: pass + placeholder_mapping: dict = {} + bdayS = match[1] + match[2] + match[3] - bdaySdesc = match[1] + "-" + match[2] + "-" + match[3] bdayY = int(match[1]) bdayM = int(match[2]) bdayD = int(match[3]) + placeholder_mapping['{year}'] = match[1] + placeholder_mapping['{month}'] = match[2] + placeholder_mapping['{day}'] = match[3] + # create ICS if hasattr(self.vobject_item, "fn"): name = self.vobject_item.fn.value elif hasattr(self.vobject_item, "n"): - name = self.vobject_item.n.value + name = self.vobject_item.n.value.family + " " + self.vobject_item.n.value.given elif hasattr(self.vobject_item, "nickname"): name = self.vobject_item.nickname.value else: logger.trace("item/convert_vcf_to_ics: has bday but neither FN or N or NICKNAME (skip): %r", self.href) return None + if hasattr(self.vobject_item, "nickname") and self.vobject_item.nickname.value != "": + placeholder_mapping['{nickname}'] = self.vobject_item.nickname.value + else: + placeholder_mapping['{nickname}'] = '!nickname!' + + if hasattr(self.vobject_item, "fn") and self.vobject_item.fn.value != "": + placeholder_mapping['{fn}'] = self.vobject_item.fn.value + else: + placeholder_mapping['{nickname}'] = '!fn!' + + # rfc6350#6.2 FamilyName;GivenName;AdditionalNames;HonorificPrefixes;HonorificSuffixes + if hasattr(self.vobject_item, "n") and self.vobject_item.n.value.family != "": + placeholder_mapping['{n:f}'] = self.vobject_item.n.value.family + else: + placeholder_mapping['{n:f}'] = '!n:f!' + + if hasattr(self.vobject_item, "n") and self.vobject_item.n.value.given != "": + placeholder_mapping['{n:g}'] = self.vobject_item.n.value.given + else: + placeholder_mapping['{n:g}'] = '!n:g!' + + if hasattr(self.vobject_item, "n") and self.vobject_item.n.value.additional != "": + placeholder_mapping['{n:a}'] = self.vobject_item.n.value.additional + else: + placeholder_mapping['{n:a}'] = '!n:a!' + # create VCALENDAR item_ics = vobject.newFromBehavior('vcalendar') @@ -555,40 +648,134 @@ class Item: else: item_ics.add('prodid').value = PRODID_CONVERTED - # create EVENT - item_ics.add('vevent') + # prepare SUMMARY + if ShareActions is not None and 'config' in ShareActions and 'conversion_bday_summary_template' in ShareActions['config']: + summary = ShareActions['config']['conversion_bday_summary_template'] + elif ShareActions is not None and 'config_default' in ShareActions and 'conversion_bday_summary_template' in ShareActions['config_default']: + summary = ShareActions['config_default']['conversion_bday_summary_template'] + else: + summary = sharing.SHARING_BDAY_SUMMARY_TEMPLATE_DEFAULT # fallback + summary = replace_placeholders(summary, placeholder_mapping) - # set DTSTART - dtstart = datetime.date(bdayY, bdayM, bdayD) - item_ics.vevent.add('dtstart').value = dtstart + # prepare DESCRIPTION + if ShareActions is not None and 'config' in ShareActions and 'conversion_bday_description_template' in ShareActions['config']: + description = ShareActions['config']['conversion_bday_description_template'] + elif ShareActions is not None and 'config_default' in ShareActions and 'conversion_bday_description_template' in ShareActions['config_default']: + description = ShareActions['config_default']['conversion_bday_description_template'] + else: + description = sharing.SHARING_BDAY_DESCRIPTION_TEMPLATE_DEFAULT # fallback + description = replace_placeholders(description, placeholder_mapping) - # calculate and set DTEND - dtend = dtstart + datetime.timedelta(days=1) - item_ics.vevent.add('dtend').value = dtend + # create CATEGORIES + if ShareActions is not None and 'config' in ShareActions and 'conversion_bday_categories' in ShareActions['config']: + categories = ShareActions['config']['conversion_bday_categories'].split(',') + elif ShareActions is not None and 'config_default' in ShareActions and 'conversion_bday_categories' in ShareActions['config_default']: + categories = ShareActions['config_default']['conversion_bday_categories'].split(',') + else: + categories = sharing.SHARING_BDAY_CATEGORIES_DEFAULT.split(',') # fallback - # set UID + # check ALARM + if ShareActions is not None and 'config' in ShareActions and 'conversion_bday_alarm_trigger_template' in ShareActions['config']: + alarm_trigger = ShareActions['config']['conversion_bday_alarm_trigger_template'] + elif ShareActions is not None and 'config_default' in ShareActions and 'conversion_bday_alarm_trigger_template' in ShareActions['config_default']: + alarm_trigger = ShareActions['config_default']['conversion_bday_alarm_trigger_template'] + else: + alarm_trigger = "" # default + + vevent_enable_age = False + age_max = 0 + if "{age}" in summary or "{age}" in description or "age" in alarm_trigger: + if ShareActions is not None and 'config' in ShareActions and 'conversion_bday_age_max' in ShareActions['config']: + age_max = ShareActions['config']['conversion_bday_age_max'] + elif ShareActions is not None and 'config_default' in ShareActions and 'conversion_bday_age_max' in ShareActions['config_default']: + age_max = ShareActions['config_default']['conversion_bday_age_max'] + else: + age_max = sharing.SHARING_BDAY_AGE_MAX_DEFAULT # fallback + vevent_enable_age = True + + # create UID if hasattr(self.vobject_item, "uid"): pattern = re.compile('^(.*)-[0-9a-fA-F]{12}(.*)$') match = pattern.match(self.vobject_item.uid.value) if match: # replace part of UUID by bday - item_ics.vevent.add('uid').value = match[1] + '-' + 'bda0' + bdayS + match[2] + uid = match[1] + '-' + 'bda0' + bdayS + match[2] else: - item_ics.vevent.add('uid').value = self.vobject_item.uid.value + UID_SUFFIX + uid = self.vobject_item.uid.value + UID_SUFFIX else: - item_ics.vevent.add('uid').value = match[1] + match[2] + match[3] + "@" + name.replace(" ", "-") + UID_SUFFIX + uid = match[1] + match[2] + match[3] + "@" + name.replace(" ", "-") + UID_SUFFIX - # set SUMMARY - item_ics.vevent.add('summary').value = name + " (BDAY)" + age = 0 + while age <= age_max: + # create EVENT + vevent = item_ics.add('vevent') - # set RRULE - item_ics.vevent.add('rrule').value = "FREQ=YEARLY" + # set DTSTART + if vevent_enable_age: + dtstart = datetime.date(bdayY + age, bdayM, bdayD) + else: + dtstart = datetime.date(bdayY, bdayM, bdayD) + vevent.add('dtstart').value = dtstart - # add transparency - item_ics.vevent.add('transp').value = "TRANSPARENT" + # calculate and set DTEND + dtend = dtstart + datetime.timedelta(days=1) + vevent.add('dtend').value = dtend - # add description - item_ics.vevent.add('description').value = "BDAY=" + bdaySdesc + # set UID + if vevent_enable_age: + uid_value = uid + "-AGE-" + str(age) + else: + uid_value = uid + vevent.add('uid').value = uid_value + + # set SUMMARY + if vevent_enable_age: + summary_value = summary.replace("{age}", str(age)) + else: + summary_value = summary + vevent.add('summary').value = summary_value + + # set CATEGORIES + if categories is not None and categories != []: + vevent.add('categories').value = categories + + # set CLASS + vevent.add('class').value = "PRIVATE" + + # set STATUS + vevent.add('status').value = "CONFIRMED" + + # set VALARM + if alarm_trigger is not None and alarm_trigger != "": + 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 = trigger_to_timedelta(trigger) + if td is not None: + 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" + valarm.add('description').value = alarm_description_value + valarm.add('trigger').value = td + + # set RRULE + if not vevent_enable_age: + vevent.add('rrule').value = "FREQ=YEARLY" + + # add transparency + vevent.add('transp').value = "TRANSPARENT" + + # set DESCRIPTION + if vevent_enable_age: + description_value = description.replace("{age}", str(age)) + else: + description_value = description + if description != "": + vevent.add('description').value = description_value + + # increase age + age = age + 1 href = self.href if href is not None: diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index fb162ffc..bee138b0 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -24,11 +24,11 @@ import uuid from csv import DictWriter from datetime import datetime from http import client -from typing import Sequence, Union +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") @@ -123,6 +123,77 @@ TOKEN_PATTERN_V1: str = "v1/[a-zA-Z0-9_\\-]{44}" OVERLAY_PROPERTIES_WHITELIST: Sequence[str] = ("C:calendar-description", "ICAL:calendar-color", "CR:addressbook-description", "INF:addressbook-color", "D:displayname", "ICAL:calendar-order") +SHARING_BDAY_AGE_MAX_LIMIT: int = 199 # maximum age to prevent unexpected DoS by config +SHARING_BDAY_AGE_MAX_DEFAULT: int = 99 +SHARING_BDAY_SUMMARY_TEMPLATE_DEFAULT: str = "[{n:f} {n:g}|{fn}|{nickname}] ({year}) (BDAY)" +SHARING_BDAY_DESCRIPTION_TEMPLATE_DEFAULT: str = "BDAY={year}-{month}-{day}" +SHARING_BDAY_CATEGORIES_DEFAULT: str = 'Birthday' + + +def check_bday_max_age(data: Any) -> int: + value = int(data) + if value < 0: + raise ValueError("value is negative: %d" % value) + if value > SHARING_BDAY_AGE_MAX_LIMIT: + raise ValueError("value exceeds maximum (%d): %d" % (SHARING_BDAY_AGE_MAX_LIMIT, 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_not_empty(data: Any) -> str: + result = check_template(data) + if result == "": + raise ValueError("template not allowed to be empty") + 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_not_empty(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': check_template_not_empty, + 'conversion_bday_description_template': check_template, + 'conversion_bday_alarm_trigger_template': check_template_alarm_trigger, + 'conversion_bday_categories': str, + 'conversion_bday_age_max': check_bday_max_age, + }, + } + CONVERSIONS_WHITELIST: Sequence[str] = ("bday", "none") @@ -166,6 +237,11 @@ class BaseSharing: self.default_permissions_create_map = configuration.get("sharing", "default_permissions_create_map") self.permit_properties_overlay = configuration.get("sharing", "permit_properties_overlay") self.enforce_properties_overlay = configuration.get("sharing", "enforce_properties_overlay") + self.conversion_bday_summary_template = configuration.get("sharing", "conversion_bday_summary_template") + self.conversion_bday_description_template = configuration.get("sharing", "conversion_bday_description_template") + self.conversion_bday_alarm_trigger_template = configuration.get("sharing", "conversion_bday_alarm_trigger_template") + self.conversion_bday_categories = configuration.get("sharing", "conversion_bday_categories") + self.conversion_bday_age_max = configuration.get("sharing", "conversion_bday_age_max") logger.info("sharing.collection_by_map : %s", self.sharing_collection_by_map) logger.info("sharing.collection_by_token: %s", self.sharing_collection_by_token) @@ -175,6 +251,11 @@ class BaseSharing: logger.info("sharing.default_permissions_create_map : %r", self.default_permissions_create_map) logger.info("sharing.permit_properties_overlay: %s", self.permit_properties_overlay) logger.info("sharing.enforce_properties_overlay: %s", self.enforce_properties_overlay) + logger.info("sharing.conversion_bday_summary_template: %r", self.conversion_bday_summary_template) + logger.info("sharing.conversion_bday_description_template: %r", self.conversion_bday_description_template) + logger.info("sharing.conversion_bday_alarm_trigger_template: %r", self.conversion_bday_alarm_trigger_template) + logger.info("sharing.conversion_bday_categories: %r", self.conversion_bday_categories) + logger.info("sharing.conversion_bday_age_max: %s", self.conversion_bday_age_max) # database tasks self.sharing_db_type = configuration.get("sharing", "type") @@ -403,6 +484,73 @@ class BaseSharing: else: logger.trace("sharing/map: not active") + if share is not None: + if share['Conversion'] == "bday": + # autogenerate Actions if not existing + if share['Actions'] is None or 'config' not in share['Actions']: + share['Actions'] = { + 'config_default': { + 'conversion_bday_summary_template': self.conversion_bday_summary_template, + 'conversion_bday_description_template': self.conversion_bday_description_template, + 'conversion_bday_alarm_trigger_template': self.conversion_bday_alarm_trigger_template, + 'conversion_bday_categories': self.conversion_bday_categories, + 'conversion_bday_age_max': self.conversion_bday_age_max, + }, + } + else: + if 'config' in share['Actions']: + if 'conversion_bday_summary_template' in share['Actions']['config']: + # nothing to do + pass + else: + if 'config_default' not in share['Actions']: + share['Actions'].update({'config_default': {}}) + share['Actions']['config_default'].update( + {'conversion_bday_summary_template': self.conversion_bday_summary_template} + ) + + if 'conversion_bday_description_template' in share['Actions']['config']: + # nothing to do + pass + else: + if 'config_default' not in share['Actions']: + share['Actions'].update({'config_default': {}}) + share['Actions']['config_default'].update( + {'conversion_bday_description_template': self.conversion_bday_description_template} + ) + + if 'conversion_bday_alarm_trigger_template' in share['Actions']['config']: + # nothing to do + pass + else: + if 'config_default' not in share['Actions']: + share['Actions'].update({'config_default': {}}) + share['Actions']['config_default'].update( + {'conversion_bday_alarm_trigger_template': self.conversion_bday_alarm_trigger_template} + ) + + if 'conversion_bday_categories' in share['Actions']['config']: + # nothing to do + pass + else: + if 'config_default' not in share['Actions']: + share['Actions'].update({'config_default': {}}) + share['Actions']['config_default'].update( + {'conversion_bday_categories': self.conversion_bday_categories} + ) + + if 'conversion_bday_age_max' in share['Actions']['config']: + # nothing to do + pass + else: + if 'config_default' not in share['Actions']: + share['Actions'].update({'config_default': {}}) + share['Actions']['config_default'].update( + {'conversion_bday_age_max': self.conversion_bday_age_max} + ) + + logger.info("sharing/%s: resolved path %r->%r, user %r->%r, Permissions=%r Conversion=%r Actions=%r", share['ShareType'], share['PathOrToken'], share['PathMapped'], user, share['Owner'], share['Permissions'], share['Conversion'], share['Actions']) + return share # adjust a share @@ -789,7 +937,31 @@ class BaseSharing: return httputils.bad_request("Conversion not supported: %r" % Conversion) if 'Actions' in request_data: - return httputils.bad_request("Actions currently not supported (reserved for future needs)") + valid = True # default + hint = "" + for level1 in request_data['Actions']: + if level1 in ACTIONS_WHITELIST: + for level2 in request_data['Actions'][level1]: + if level2 in ACTIONS_WHITELIST[level1]: + if callable(ACTIONS_WHITELIST[level1][level2]): + try: + value = ACTIONS_WHITELIST[level1][level2](request_data['Actions'][level1][level2]) + except ValueError: + hint = "'" + level1 + "': {'" + level2 + "'} is not supported" + valid = False + break + pass + else: + hint = "'" + level1 + "': {'" + level2 + "'} is not supported" + valid = False + break + else: + hint = "'" + level1 + "' is not supported" + valid = False + break + if not valid: + return httputils.bad_request("Actions format not valid: " + hint) + Actions = request_data['Actions'] if 'Enabled' in request_data: Enabled = request_data['Enabled'] @@ -1112,7 +1284,8 @@ class BaseSharing: OwnerOrUser=user, User=User, Timestamp=Timestamp, - Properties=Properties) + Properties=Properties, + Actions=Actions) else: result = self.database_update_sharing( ShareType=ShareType, @@ -1124,7 +1297,8 @@ class BaseSharing: OwnerOrUser=user, User=User, Timestamp=Timestamp, - Properties=Properties) + Properties=Properties, + Actions=Actions) elif user == share['User']: # User is only allowed to update Properties diff --git a/radicale/sharing/csv.py b/radicale/sharing/csv.py index 5eb491cd..e1c97ed1 100644 --- a/radicale/sharing/csv.py +++ b/radicale/sharing/csv.py @@ -441,6 +441,8 @@ class Sharing(sharing.BaseSharing): field = field.replace("{'", '{"') # replace for JSON start {' -> {" field = field.replace("'}", '"}') # replace for JSON end '} -> "} field = field.replace("': '", '": "') # replace for JSON entry/value ': ' -> ": " + field = field.replace("': {", '": {') # replace for JSON entry/value ': { -> ": { + field = field.replace("': ", '": ') # replace for JSON entry/value ': -> ":(whitespace) field = field.replace("', '", '", "') # replace for JSON delimiter ', ' -> ", " logger.trace("json prep quote replacer match (after) : %s", field) try: diff --git a/radicale/storage/__init__.py b/radicale/storage/__init__.py index b6182a03..219732ff 100644 --- a/radicale/storage/__init__.py +++ b/radicale/storage/__init__.py @@ -223,7 +223,7 @@ class BaseCollection: """Get the HTTP-datetime of when the collection was modified.""" raise NotImplementedError - def serialize(self, vcf_to_ics: bool = False) -> str: + def serialize(self, vcf_to_ics: bool = False, ShareActions: dict = {}) -> str: """Get the unicode string representing the whole collection.""" if self.tag == "VCALENDAR": in_vcalendar = False @@ -288,7 +288,7 @@ class BaseCollection: items = [] for item in self.get_all(): logger.trace("storage/convert VCF to ICS: %r:", item) - item_ics = item.convert_vcf_to_ics() + item_ics = item.convert_vcf_to_ics(ShareActions=ShareActions) if item_ics is None: continue else: diff --git a/radicale/tests/static/contact2-with-bday.vcf b/radicale/tests/static/contact2-with-bday.vcf index daff0d92..4f24f403 100644 --- a/radicale/tests/static/contact2-with-bday.vcf +++ b/radicale/tests/static/contact2-with-bday.vcf @@ -2,7 +2,7 @@ BEGIN:VCARD VERSION:3.0 PRODID:+//IDN bitfire.at//DAVx5/3.3.5-ose ez-vcard/0.11.0 UID:contact2-with-bday -N:Test;N;;; +N:FamilyTest;GivenTest;AdditionalsTest;; FN:Test-FN NICKNAME:Test-NICKNAME BDAY:1970-01-01 diff --git a/radicale/tests/static/contact3-with-bday.vcf b/radicale/tests/static/contact3-with-bday.vcf index 9a40865c..a0ee2f2a 100644 --- a/radicale/tests/static/contact3-with-bday.vcf +++ b/radicale/tests/static/contact3-with-bday.vcf @@ -2,8 +2,7 @@ BEGIN:VCARD VERSION:3.0 PRODID:+//Manual/0.0.1 UID:05cf4901-e581-44ee-bb0a-1e1a875da44a -N:Test;N;C3;; +N:Family3Test;Given3Test;;; FN:Test-FN-C3 -NICKNAME:Test-NICKNAME-C3 BDAY:1990-01-01 END:VCARD diff --git a/radicale/tests/test_auth.py b/radicale/tests/test_auth.py index 408ad3f0..e7e15a47 100644 --- a/radicale/tests/test_auth.py +++ b/radicale/tests/test_auth.py @@ -301,7 +301,7 @@ class TestBaseAuthRequests(BaseTest): delay = .3 delay_min = delay * 0.9 # no random jitter during test delay_max = delay + 0.2 # no random jitter during test - if sys.platform == "darwin": # no reliable sleep times + if sys.platform == "darwin" or sys.platform == 'win32': # no reliable sleep times delay_max = delay_max * 1.5 time_begin = datetime.datetime.now() diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index 19c052e1..3d08ab67 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -4830,6 +4830,7 @@ permissions: RrWw""") assert "DTEND;VALUE=DATE:19700102" in answer assert "TRANSP:TRANSPARENT" in answer assert "DESCRIPTION:BDAY=1970-01-01" in answer + assert "CATEGORIES:Birthday" in answer # content type must be adjusted assert 'Content-Type' in headers assert 'text/calendar' in headers['Content-Type'] @@ -4879,6 +4880,9 @@ permissions: RrWw""") assert "DTEND;VALUE=DATE:19700102" in answer assert "TRANSP:TRANSPARENT" in answer assert "DESCRIPTION:BDAY=1970-01-01" in answer + assert "CATEGORIES:Birthday" in answer + assert "CLASS:PRIVATE" in answer + assert "STATUS:CONFIRMED" in answer # content type must be adjusted assert 'Content-Type' in headers assert 'text/calendar' in headers['Content-Type'] @@ -4931,6 +4935,635 @@ permissions: RrWw""") status, prop = response["D:getcontenttype"] assert "text/calendar" in str(prop.text) + def test_sharing_api_map_vcf_bday_template(self) -> None: + """share-by-map with conversion=bday template tests.""" + self.configure({"auth": {"type": "htpasswd", + "htpasswd_filename": self.htpasswd_file_path, + "htpasswd_encryption": "plain"}, + "sharing": { + "type": "csv", + "permit_create_map": True, + "permit_properties_overlay": "True", + "enforce_properties_overlay": "True", + "collection_by_map": "True"}, + "logging": {"request_header_on_debug": "False", + "response_content_on_debug": "True", + "response_header_on_debug": "True", + "request_content_on_debug": "True"}, + "rights": {"type": "owner_only"}}) + + json_dict: dict + + logging.info("\n*** prepare and test access") + + for db_type in list(filter(lambda item: item != "none", sharing.INTERNAL_TYPES)): + logging.info("\n*** test: %s", db_type) + self.configure({"sharing": {"type": db_type}}) + + path_mapped = "/owner/adressbook-" + db_type + ".vcf/" + path_shared_r = "/user/calendar-bday-abook-shared-by-owner-r-" + db_type + ".ics/" + self.create_addressbook(path_mapped, login="owner:ownerpw") + + contact2 = get_file_content("contact2-with-bday.vcf") + path2 = path_mapped + "/contact2-with-bday.vcf" + path_shared_2 = path_shared_r + "/contact2-with-bday.vcf" + self.put(path2, contact2, login="owner:ownerpw") + + contact3 = get_file_content("contact3-with-bday.vcf") + path3 = path_mapped + "/contact3-with-bday.vcf" + path_shared_3 = path_shared_r + "/contact3-with-bday.vcf" + self.put(path3, contact3, login="owner:ownerpw") + + # create map + logging.info("\n*** create map(bday) user/owner:r -> ok") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_shared_r + json_dict['Conversion'] = "bday" + json_dict['Permissions'] = "rP" + json_dict['Enabled'] = True + json_dict['Enabled'] = True + json_dict['Hidden'] = False + json_dict['Properties'] = {"D:displayname": "Test-BDAY"} + _, headers, answer = self._sharing_api_json("map", "create", check=200, login="owner:ownerpw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" + + # enable map by user + logging.info("\n*** enable map(bday) by user") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_shared_r + _, headers, answer = self._sharing_api_json("map", "enable", check=200, login="user:userpw", json_dict=json_dict) + + self.configure({"sharing": { + "conversion_bday_summary_template": "[{fn}|{n:f} {n:g} {n:a}|{nickname}] (BDAY)", + "conversion_bday_description_template": "BDAY={year}-{month}-{day}", + "conversion_bday_alarm_trigger_template": "-15H;BDAY tomorrow|9H;BDAY today", + }}) + + # verify content as user + logging.info("\n*** GET collection user format:default -> ok") + _, headers, answer = self.request("GET", path_shared_2, login="user:userpw") + assert "SUMMARY:Test-FN (BDAY)" in answer + + self.configure({"sharing": {"conversion_bday_summary_template": "[{fn}|{n:f} {n:g} {n:a}|{nickname}] (Birthday)"}}) + logging.info("\n*** GET collection user format with fn+Birthday -> ok") + _, headers, answer = self.request("GET", path_shared_2, login="user:userpw") + assert "SUMMARY:Test-FN (Birthday)" in answer + + self.configure({"sharing": {"conversion_bday_summary_template": "[{n:f} {n:g} {n:a}|{fn}|{nickname}] (Birthday)"}}) + logging.info("\n*** GET collection user format:n -> ok") + _, headers, answer = self.request("GET", path_shared_2, login="user:userpw") + assert "SUMMARY:FamilyTest GivenTest AdditionalsTest (Birthday)" in answer + + self.configure({"sharing": {"conversion_bday_summary_template": "[{nickname}|{n:f} {n:g} {n:a}|{fn}] (Birthday)"}}) + logging.info("\n*** GET collection user format:nickname -> ok") + _, headers, answer = self.request("GET", path_shared_2, login="user:userpw") + assert "SUMMARY:Test-NICKNAME (Birthday)" in answer + + self.configure({"sharing": {"conversion_bday_summary_template": "{nickname} (Birthday)"}}) + logging.info("\n*** GET collection user format:nickname not resolvable -> ok") + _, headers, answer = self.request("GET", path_shared_3, login="user:userpw") + assert "SUMMARY:!nickname! (Birthday)" in answer + + self.configure({"sharing": {"conversion_bday_summary_template": "[{nickname}|{nickname}|{fn}] (Birthday)"}}) + logging.info("\n*** GET collection user format:nickname with fn fallback -> ok") + _, headers, answer = self.request("GET", path_shared_3, login="user:userpw") + assert "SUMMARY:Test-FN-C3 (Birthday)" in answer + + self.configure({"sharing": {"conversion_bday_summary_template": "[{nickname}|{nickname}|{n:g} {n:f}] (Birthday)"}}) + logging.info("\n*** GET collection user format:nickname with n fallback -> ok") + _, headers, answer = self.request("GET", path_shared_3, login="user:userpw") + assert "SUMMARY:Given3Test Family3Test (Birthday)" in answer + + self.configure({"sharing": {"conversion_bday_summary_template": "[{nickname}|{nickname}|{n:f}, {n:g}] (Birthday)"}}) + logging.info("\n*** GET collection user format:nickname with n fallback -> ok") + _, headers, answer = self.request("GET", path_shared_3, login="user:userpw") + assert "SUMMARY:Family3Test\\, Given3Test (Birthday)" in answer + + self.configure({"sharing": {"conversion_bday_summary_template": "[{nickname}|{nickname}|{n:f} {n:g} {n:a}] (Birthday)"}}) + logging.info("\n*** GET collection user format:nickname with n fallback -> ok") + _, headers, answer = self.request("GET", path_shared_3, login="user:userpw") + assert "SUMMARY:Family3Test Given3Test !n:a! (Birthday)" in answer + assert "DESCRIPTION:BDAY=1990-01-01" in answer + + self.configure({"sharing": {"conversion_bday_description_template": "Birthday={year}{month}{day}"}}) + logging.info("\n*** GET collection user format: description -> ok") + _, headers, answer = self.request("GET", path_shared_3, login="user:userpw") + assert "DESCRIPTION:Birthday=19900101" in answer + + self.configure({"sharing": {"conversion_bday_description_template": "year={year} month={month} day={day}\nfn='{fn}'\nn:g='{n:g}'\nn:f='{n:f}'\nn:a='{n:a}'"}}) + logging.info("\n*** GET collection user format: description -> ok") + _, headers, answer = self.request("GET", path_shared_3, login="user:userpw") + assert "DESCRIPTION:year=1990 month=01 day=01" in answer + assert "DESCRIPTION:BDAY tomorrow" in answer + assert "DESCRIPTION:BDAY today" in answer + assert "TRIGGER:-PT15H" in answer + assert "TRIGGER:PT9H" in answer + + self.configure({"sharing": {"conversion_bday_alarm_trigger_template": "-12H;Birthday tomorrow of {fn}|12H;Birthday today of {n:g} {n:f}"}}) + logging.info("\n*** GET collection user format: description -> ok") + _, headers, answer = self.request("GET", path_shared_3, login="user:userpw") + assert "DESCRIPTION:Birthday tomorrow of Test-FN-C3" in answer + assert "DESCRIPTION:Birthday today of Given3Test Family3Test" in answer + assert "TRIGGER:-PT12H" in answer + assert "TRIGGER:PT12H" in answer + + self.configure({"sharing": {"conversion_bday_categories": "Birthday,Geburtstag"}}) + logging.info("\n*** GET collection user format: description -> ok") + _, headers, answer = self.request("GET", path_shared_3, login="user:userpw") + assert "DESCRIPTION:Birthday tomorrow of Test-FN-C3" in answer + assert "DESCRIPTION:Birthday today of Given3Test Family3Test" in answer + assert "CATEGORIES:Birthday,Geburtstag" in answer + + self.configure({ + "sharing": {"conversion_bday_description_template": "", + "conversion_bday_alarm_trigger_template": "", + } + }) + logging.info("\n*** GET collection user format: no description -> ok") + _, headers, answer = self.request("GET", path_shared_3, login="user:userpw") + assert "DESCRIPTION" not in answer + + logging.info("\n*** configuration test: conversion_bday_summary_template not supported") + try: + self.configure({"sharing": { + "conversion_bday_description_template": "year={year} month={month} day={day}\nfn='{fn}'\nn:g='{n:g}'\nn:f='{n:f}'\nn:a='{notsupported}'", + }}) + except RuntimeError: + pass + else: + raise + + logging.info("\n*** configuration test: conversion_bday_description_template not supported") + try: + self.configure({"sharing": { + "conversion_bday_summary_template": "[{nickname}|{nickname}|{n:f} {n:g} {n:a}] {notsupportedplaceholder}", + }}) + except RuntimeError: + pass + else: + raise + + logging.info("\n*** configuration test: conversion_bday_summary_template empty") + try: + self.configure({"sharing": { + "conversion_bday_summary_template": "", + }}) + except RuntimeError: + pass + else: + raise + + logging.info("\n*** configuration test: conversion_bday_description_template empty") + try: + self.configure({"sharing": { + "conversion_bday_description_template": "", + }}) + except RuntimeError: + pass + else: + pass + + logging.info("\n*** configuration test: conversion_bday_alarm_trigger_template not supported") + try: + self.configure({"sharing": { + "conversion_bday_alarm_trigger_template": "-12T;Birthday tomorrow of {fn}" + }}) + except RuntimeError: + pass + else: + raise + + logging.info("\n*** configuration test: conversion_bday_alarm_trigger_template not supported") + try: + self.configure({"sharing": { + "conversion_bday_alarm_trigger_template": "BROKEN;Birthday tomorrow of {fn}" + }}) + except RuntimeError: + pass + else: + raise + + logging.info("\n*** configuration test: conversion_bday_alarm_trigger_template not supported") + try: + self.configure({"sharing": { + "conversion_bday_alarm_trigger_template": "-+BROKEN;Birthday tomorrow of {fn}" + }}) + except RuntimeError: + pass + else: + raise + + logging.info("\n*** configuration test: conversion_bday_alarm_trigger_template not supported") + try: + self.configure({"sharing": { + "conversion_bday_alarm_trigger_template": "+0;Birthday tomorrow of {fn}" + }}) + except RuntimeError: + pass + else: + raise + + logging.info("\n*** configuration test: conversion_bday_alarm_trigger_template not supported") + try: + self.configure({"sharing": { + "conversion_bday_alarm_trigger_template": "-12H" + }}) + except RuntimeError: + pass + else: + raise + + logging.info("\n*** configuration test: conversion_bday_alarm_trigger_template not supported") + try: + self.configure({"sharing": { + "conversion_bday_alarm_trigger_template": "-12H;" + }}) + except RuntimeError: + pass + else: + raise + + # update template + logging.info("\n*** update map(bday) user/owner:r with invalid config -> 400") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_shared_r + json_dict['Actions'] = {"config": { + "conversion_bday_alarm_trigger_template": "-12H;" + }} + _, headers, answer = self._sharing_api_json("map", "update", check=400, login="owner:ownerpw", json_dict=json_dict) + + def test_sharing_api_map_vcf_bday_age_template(self) -> None: + """share-by-map with conversion=bday template tests with age.""" + self.configure({"auth": {"type": "htpasswd", + "htpasswd_filename": self.htpasswd_file_path, + "htpasswd_encryption": "plain"}, + "sharing": { + "type": "csv", + "permit_create_map": True, + "permit_properties_overlay": "True", + "enforce_properties_overlay": "True", + "collection_by_map": "True"}, + "logging": {"request_header_on_debug": "False", + "response_content_on_debug": "True", + "response_header_on_debug": "True", + "request_content_on_debug": "True"}, + "rights": {"type": "owner_only"}}) + + json_dict: dict + + logging.info("\n*** prepare and test access") + + for db_type in list(filter(lambda item: item != "none", sharing.INTERNAL_TYPES)): + logging.info("\n*** test: %s", db_type) + self.configure({"sharing": {"type": db_type}}) + + path_mapped = "/owner/adressbook-" + db_type + ".vcf/" + path_shared_r = "/user/calendar-bday-abook-shared-by-owner-r-" + db_type + ".ics/" + self.create_addressbook(path_mapped, login="owner:ownerpw") + + contact2 = get_file_content("contact2-with-bday.vcf") + path2 = path_mapped + "/contact2-with-bday.vcf" + path_shared_2 = path_shared_r + "/contact2-with-bday.vcf" + self.put(path2, contact2, login="owner:ownerpw") + + # create map + logging.info("\n*** create map(bday) user/owner:r -> ok") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_shared_r + json_dict['Conversion'] = "bday" + json_dict['Permissions'] = "rP" + json_dict['Enabled'] = True + json_dict['Enabled'] = True + json_dict['Hidden'] = False + json_dict['Properties'] = {"D:displayname": "Test-BDAY"} + _, headers, answer = self._sharing_api_json("map", "create", check=200, login="owner:ownerpw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" + + # enable map by user + logging.info("\n*** enable map(bday) by user") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_shared_r + _, headers, answer = self._sharing_api_json("map", "enable", check=200, login="user:userpw", json_dict=json_dict) + + self.configure({"sharing": { + "conversion_bday_summary_template": "[{fn}|{n:f} {n:g} {n:a}|{nickname}] (BDAY)", + "conversion_bday_description_template": "BDAY={year}-{month}-{day}", + }}) + + logging.info("\n*** configuration test: conversion_bday_age_max < 0") + try: + self.configure({"sharing": { + "conversion_bday_age_max": -1, + }}) + except RuntimeError: + pass + else: + raise + + logging.info("\n*** configuration test: conversion_bday_age_max > MAX") + try: + self.configure({"sharing": { + "conversion_bday_age_max": (sharing.SHARING_BDAY_AGE_MAX_LIMIT + 1), + }}) + except RuntimeError: + pass + else: + raise + + # verify content as user + logging.info("\n*** GET collection user format:default -> ok") + _, headers, answer = self.request("GET", path_shared_2, login="user:userpw") + assert "SUMMARY:Test-FN (BDAY)" in answer + + self.configure({"sharing": { + "conversion_bday_summary_template": "[{fn}|{n:f} {n:g} {n:a}|{nickname}] ({age}. Birthday)", + }}) + logging.info("\n*** GET collection user format:text -> ok") + _, headers, answer = self.request("GET", path_shared_2, login="user:userpw") + assert "SUMMARY:Test-FN (0. Birthday)" in answer + assert "SUMMARY:Test-FN (1. Birthday)" in answer + assert "SUMMARY:Test-FN (99. Birthday)" in answer + assert "SUMMARY:Test-FN (100. Birthday)" not in answer + + self.configure({"sharing": { + "conversion_bday_summary_template": "[{fn}|{n:f} {n:g} {n:a}|{nickname}] (BDAY)", + "conversion_bday_description_template": "AGE={age}", + }}) + logging.info("\n*** GET collection user format:text -> ok") + _, headers, answer = self.request("GET", path_shared_2, login="user:userpw") + assert "SUMMARY:Test-FN (BDAY)" in answer + assert "DESCRIPTION:AGE=0" in answer + assert "DESCRIPTION:AGE=1" in answer + assert "DESCRIPTION:AGE=99" in answer + assert "DESCRIPTION:AGE=100" not in answer + + self.configure({"sharing": { + "conversion_bday_summary_template": "[{fn}|{n:f} {n:g} {n:a}|{nickname}] (BDAY)", + "conversion_bday_description_template": "BDAY={year}-{month}-{day}", + "conversion_bday_alarm_trigger_template": "-15H;alarm {fn} {age}. birthday", + }}) + logging.info("\n*** GET collection user format:text -> ok") + _, headers, answer = self.request("GET", path_shared_2, login="user:userpw") + assert "SUMMARY:Test-FN (BDAY)" in answer + assert "DESCRIPTION:BDAY=1970-01-01" in answer + assert "DESCRIPTION:alarm Test-FN 0. birthday" in answer + assert "DESCRIPTION:alarm Test-FN 1. birthday" in answer + assert "DESCRIPTION:alarm Test-FN 99. birthday" in answer + assert "DESCRIPTION:alarm Test-FN 100. birthday" not in answer + + # update template + logging.info("\n*** update map(bday) user/owner:r -> ok") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_shared_r + json_dict['Actions'] = {"config": { + "conversion_bday_age_max": 5, + }} + _, headers, answer = self._sharing_api_json("map", "update", check=200, login="owner:ownerpw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" + + logging.info("\n*** GET collection user format:text -> ok") + _, headers, answer = self.request("GET", path_shared_2, login="user:userpw") + assert "SUMMARY:Test-FN (BDAY)" in answer + assert "DESCRIPTION:BDAY=1970-01-01" in answer + assert "DESCRIPTION:alarm Test-FN 0. birthday" in answer + assert "DESCRIPTION:alarm Test-FN 1. birthday" in answer + assert "DESCRIPTION:alarm Test-FN 5. birthday" in answer + assert "DESCRIPTION:alarm Test-FN 6. birthday" not in answer + assert "DESCRIPTION:alarm Test-FN 99. birthday" not in answer + assert "DESCRIPTION:alarm Test-FN 100. birthday" not in answer + + self.configure({"sharing": { + "conversion_bday_summary_template": "[{fn}|{n:f} {n:g} {n:a}|{nickname}] (BDAY)", + "conversion_bday_description_template": "AGE={age}", + "conversion_bday_alarm_trigger_template": "", + }}) + logging.info("\n*** GET collection user limit age to 5 -> ok") + _, headers, answer = self.request("GET", path_shared_2, login="user:userpw") + assert "SUMMARY:Test-FN (BDAY)" in answer + assert "DESCRIPTION:AGE=0" in answer + assert "DESCRIPTION:AGE=1" in answer + assert "DESCRIPTION:AGE=5" in answer + assert "DESCRIPTION:AGE=6" not in answer + assert "DESCRIPTION:AGE=99" not in answer + assert "DESCRIPTION:AGE=100" not in answer + + # update template with invalid data test + logging.info("\n*** update map(bday) user/owner:r -> age_max not negative") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_shared_r + json_dict['Actions'] = {"config": { + "conversion_bday_age_max": -1, + }} + _, headers, answer = self._sharing_api_json("map", "update", check=400, login="owner:ownerpw", json_dict=json_dict) + + # update template with invalid data test + logging.info("\n*** update map(bday) user/owner:r -> age_max exceeds MAX") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_shared_r + json_dict['Actions'] = {"config": { + "conversion_bday_age_max": (sharing.SHARING_BDAY_AGE_MAX_LIMIT + 1), + }} + _, headers, answer = self._sharing_api_json("map", "update", check=400, login="owner:ownerpw", json_dict=json_dict) + + # update template with valid data test + logging.info("\n*** update map(bday) user/owner:r -> age_max ok") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_shared_r + json_dict['Actions'] = {"config": { + "conversion_bday_age_max": 0, + }} + _, headers, answer = self._sharing_api_json("map", "update", check=200, login="owner:ownerpw", json_dict=json_dict) + + # update template with valid data test + logging.info("\n*** update map(bday) user/owner:r -> age_max < MAX") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_shared_r + json_dict['Actions'] = {"config": { + "conversion_bday_age_max": sharing.SHARING_BDAY_AGE_MAX_LIMIT, + }} + _, headers, answer = self._sharing_api_json("map", "update", check=200, login="owner:ownerpw", json_dict=json_dict) + + logging.info("\n*** update map(bday) user/owner:r -> unsupported level 1") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_shared_r + json_dict['Actions'] = {"level1": { + "level2": "test", + }} + _, headers, answer = self._sharing_api_json("map", "update", check=400, login="owner:ownerpw", json_dict=json_dict) + + logging.info("\n*** update map(bday) user/owner:r -> unsupported level 2") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_shared_r + json_dict['Actions'] = {"config": { + "level2": "test", + }} + _, headers, answer = self._sharing_api_json("map", "update", check=400, login="owner:ownerpw", json_dict=json_dict) + + def test_sharing_api_map_vcf_bday_per_share_template(self) -> None: + """share-by-map with conversion=bday template per share tests.""" + self.configure({"auth": {"type": "htpasswd", + "htpasswd_filename": self.htpasswd_file_path, + "htpasswd_encryption": "plain"}, + "sharing": { + "type": "csv", + "permit_create_map": True, + "permit_properties_overlay": "True", + "enforce_properties_overlay": "True", + "collection_by_map": "True"}, + "logging": {"request_header_on_debug": "False", + "response_content_on_debug": "True", + "response_header_on_debug": "True", + "request_content_on_debug": "True"}, + "rights": {"type": "owner_only"}}) + + json_dict: dict + + logging.info("\n*** prepare and test access") + + for db_type in list(filter(lambda item: item != "none", sharing.INTERNAL_TYPES)): + logging.info("\n*** test: %s", db_type) + self.configure({"sharing": {"type": db_type}}) + + path_mapped_2 = "/owner/adressbook2-" + db_type + ".vcf/" + path_mapped_3 = "/owner/adressbook3-" + db_type + ".vcf/" + + path_shared_2r = "/user/calendar-bday-abook2-shared-by-owner-r-" + db_type + ".ics/" + path_shared_3r = "/user/calendar-bday-abook3-shared-by-owner-r-" + db_type + ".ics/" + + self.create_addressbook(path_mapped_2, login="owner:ownerpw") + self.create_addressbook(path_mapped_3, login="owner:ownerpw") + + contact2 = get_file_content("contact2-with-bday.vcf") + path2 = path_mapped_2 + "/contact2-with-bday.vcf" + path_shared_2 = path_shared_2r + "/contact2-with-bday.vcf" + self.put(path2, contact2, login="owner:ownerpw") + + contact3 = get_file_content("contact3-with-bday.vcf") + path3 = path_mapped_3 + "/contact3-with-bday.vcf" + path_shared_3 = path_shared_3r + "/contact3-with-bday.vcf" + self.put(path3, contact3, login="owner:ownerpw") + + # create map + logging.info("\n*** create map(bday) user/owner:r -> ok") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped_2 + json_dict['PathOrToken'] = path_shared_2r + json_dict['Conversion'] = "bday" + json_dict['Permissions'] = "rP" + json_dict['Enabled'] = True + json_dict['Enabled'] = True + json_dict['Hidden'] = False + json_dict['Properties'] = {"D:displayname": "Test-BDAY2"} + json_dict['Actions'] = {"config": { + "conversion_bday_summary_template": "{fn} (BDAY2)", + "conversion_bday_description_template": "BDAY2={year}-{month}-{day}", + }} + _, headers, answer = self._sharing_api_json("map", "create", check=200, login="owner:ownerpw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" + + logging.info("\n*** create map(bday) user/owner:r -> ok") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped_3 + json_dict['PathOrToken'] = path_shared_3r + json_dict['Conversion'] = "bday" + json_dict['Permissions'] = "rP" + json_dict['Enabled'] = True + json_dict['Enabled'] = True + json_dict['Hidden'] = False + json_dict['Properties'] = {"D:displayname": "Test-Birthday3"} + json_dict['Actions'] = {"config": { + "conversion_bday_summary_template": "{fn} (Birthday3)", + "conversion_bday_description_template": "Birthday3={year}-{month}-{day}", + }} + _, headers, answer = self._sharing_api_json("map", "create", check=200, login="owner:ownerpw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" + + # enable map by user + logging.info("\n*** enable map(bday) by user") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped_2 + json_dict['PathOrToken'] = path_shared_2r + _, headers, answer = self._sharing_api_json("map", "enable", check=200, login="user:userpw", json_dict=json_dict) + + logging.info("\n*** enable map(bday) by user") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped_3 + json_dict['PathOrToken'] = path_shared_3r + _, headers, answer = self._sharing_api_json("map", "enable", check=200, login="user:userpw", json_dict=json_dict) + + # verify content as user + logging.info("\n*** GET collection user template 2 -> ok") + _, headers, answer = self.request("GET", path_shared_2, login="user:userpw") + assert "SUMMARY:Test-FN (BDAY2)" in answer + + logging.info("\n*** GET collection user template 3 -> ok") + _, headers, answer = self.request("GET", path_shared_3, login="user:userpw") + assert "SUMMARY:Test-FN-C3 (Birthday3)" in answer + + # update template + logging.info("\n*** update map(bday) user/owner:r -> ok") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped_2 + json_dict['PathOrToken'] = path_shared_2r + json_dict['Actions'] = {"config": { + "conversion_bday_summary_template": "{fn} (BDAY2x)", + "conversion_bday_description_template": "BDAY2x={year}-{month}-{day}", + }} + _, headers, answer = self._sharing_api_json("map", "update", check=200, login="owner:ownerpw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" + + # verify content as user + logging.info("\n*** GET collection user template 2 -> ok") + _, headers, answer = self.request("GET", path_shared_2, login="user:userpw") + assert "SUMMARY:Test-FN (BDAY2x)" in answer + + # update template + logging.info("\n*** update map(bday) user/owner:r with wrong Action -> problem") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped_2 + json_dict['PathOrToken'] = path_shared_2r + json_dict['Actions'] = {"config": { + "conversion_bday_summary_template_UNSUPPORTED": "{fn} (BDAY2x)", + "conversion_bday_description_template_UNSUPPORTED": "BDAY2x={year}-{month}-{day}", + }} + _, headers, answer = self._sharing_api_json("map", "update", check=400, login="owner:ownerpw", json_dict=json_dict) + def test_sharing_api_map_vcf_bday_complex(self) -> None: """share-by-map with conversion=bday complex tests.""" self.configure({"auth": {"type": "htpasswd", @@ -5161,7 +5794,7 @@ permissions: RrWw""") _, answer = self.get(path_mapped, login="owner:ownerpw") assert "contact1" in answer assert "contact2" in answer - assert "NICKNAME-C3" in answer + assert "Family3Test" in answer # create map logging.info("\n*** create bday owner to itself -> ok") @@ -5277,6 +5910,7 @@ permissions: RrWw""") "htpasswd_encryption": "plain"}, "sharing": { "type": "csv", + "conversion_bday_summary_template": "{fn} (BDAY)", "permit_create_token": True, "permit_properties_overlay": "True", "enforce_properties_overlay": "True", @@ -5328,7 +5962,7 @@ permissions: RrWw""") _, answer = self.get(path_mapped, login="owner:ownerpw") assert "contact1" in answer assert "contact2" in answer - assert "NICKNAME-C3" in answer + assert "Family3Test" in answer # create map logging.info("\n*** create token with bday conversion (default permissions) -> ok") @@ -5349,8 +5983,8 @@ permissions: RrWw""") logging.info("\n*** GET bday with token") _, answer = self.get(path_shared) assert "VCARD" not in answer - assert "Test-FN-C3 (BDAY)" in answer - assert "Test-FN (BDAY)" in answer + assert "Test-FN-C3 (BDAY)" in answer # contact2 + assert "Test-FN (BDAY)" in answer # contact1 # verify content as owner logging.info("\n*** GET collection owner -> ok")