Merge pull request #2026 from pbiering/sharing-bday

Sharing bday
This commit is contained in:
Peter Bieringer
2026-03-16 06:48:15 +01:00
committed by GitHub
16 changed files with 1072 additions and 126 deletions

View File

@@ -12,8 +12,8 @@ jobs:
python-version: ['3.14']
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
- name: Install Test dependencies
@@ -32,7 +32,6 @@ jobs:
container:
image: i386/python:${{ matrix.python-version }}
volumes:
- /home/runner/work/_temp:/__e/node20
- /home/runner/work/_temp:/__e/node24
steps:
- name: Update system
@@ -41,10 +40,6 @@ jobs:
- name: Fix checkout and upload-artifact
run: |
apt-get install -y nodejs npm
mkdir -p /__e/node20/bin
ln -sf $(which node) /__e/node20/bin/node
ln -sf $(which npm) /__e/node20/bin/npm
ln -sf $(which npx) /__e/node20/bin/npx
mkdir -p /__e/node24/bin
ln -sf $(which node) /__e/node24/bin/node
ln -sf $(which npm) /__e/node24/bin/npm
@@ -52,8 +47,8 @@ jobs:
- name: Install git
run: |
apt-get install -y git
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
- name: Install Python dependencies
run: |
apt-get install -y tox rust-all cargo
@@ -76,8 +71,8 @@ jobs:
python-version: ['3.9']
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
- name: Install Test dependencies
@@ -94,8 +89,8 @@ jobs:
python-version: ['3.14']
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
- name: Install Test dependencies
@@ -112,8 +107,8 @@ jobs:
python-version: ['3.9']
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
- name: Install Test dependencies
@@ -137,8 +132,8 @@ jobs:
python-version: 'pypy-3.11'
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
- name: Install Test dependencies
@@ -152,8 +147,8 @@ jobs:
if: github.event_name == 'push'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: '3.14'
- name: Install Test dependencies
@@ -191,8 +186,8 @@ jobs:
runs-on: ubuntu-latest
needs: test-ubuntu-python-newest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: 24
- name: JS Type Check
@@ -213,8 +208,8 @@ jobs:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: '3.14'
- name: Install tox
@@ -227,8 +222,8 @@ jobs:
runs-on: ubuntu-latest
needs: test-ubuntu-python-newest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: '3.12'
- name: Install uv

View File

@@ -2120,6 +2120,14 @@ Share collection by map
Default: `false`
##### collection_by_bday
_(>= 3.7.0)_
Share collection by bday (conversion on-the-fly)
Default: `false`
##### permit_create_token
_(>= 3.7.0)_
@@ -2142,6 +2150,17 @@ Default: `false`
* If `False` it can be explicitly granted by *rights* permissions: `M`
* If `True` it can be explicitly forbidden by *rights* permissions: `m`
##### permit_create_bday
_(>= 3.7.0)_
Permit create of bday-based sharing
Default: `false`
* If `False` it can be explicitly granted by *rights* permissions: `B`
* If `True` it can be explicitly forbidden by *rights* permissions: `b`
##### permit_properties_overlay
_(>= 3.7.0)_
@@ -2408,6 +2427,8 @@ The following `permissions` are recognized:
* **t:** deny create of token-based sharing of collection in case `permit_create_token=True` _(>= 3.7.0)_
* **M:** permit create of map-based sharing of collection in case `permit_create_map= False` _(>= 3.7.0)_
* **m:** deny create of map-based sharing of collection in case `permit_create_map=True` _(>= 3.7.0)_
* **B:** permit create of bday-based sharing of addressbook collection in case `permit_create_bday= False` _(>= 3.7.0)_
* **b:** deny create of bday-based sharing of addressbook collection in case `permit_create_bday=True` _(>= 3.7.0)_
### Storage

View File

@@ -122,11 +122,12 @@ Types of supported sharing configuration:
* `ShareType`: type of share
* `token`: token-based share (do not require user authentication)
* `map`: map-based share (requires user authentication)
* `bday`: map-based share (requires user authentication) with on-the-fly auto-conversion
* `PathOrToken`: token or "virtual" collection, has to be unique (PRIMARY KEY)
* `PathMapped`: target collection
* `Owner`: owner of the share
* `User`: user of the share
* `Permissions`: effective permission of the share
* `Permissions`: effective permission of the share (*bday* is always read-only)
* `EnabledByOwner`: control by owner
* `EnabledByUser`: control by user
* `HiddenByOwner`: control by owner
@@ -157,10 +158,15 @@ File-based configuration store is using encoded `PathOrToken` as filename for ea
Map-based sharing can be accessed as usual after authentication and authorization.
* *map* is a standard sharing of one collection to another user
* *bday* is special sharing auto-mapping on-the-fly a VADDRESSBOOK to a VCALENDAR of all entries containing a `BDAY`
#### Permission Control
* `permit_create_map`
* supported *rights* permissions: `Mm`
* `permit_create_bday`
* supported *rights* permissions: `Bb`
#### Workflow
@@ -243,7 +249,7 @@ Shows what kind of ShareTypes are supported
* form->text
```bash
curl -u user:pass -H "accept: text/plain" -d "" http://localhost:5232/.sharing/v1/all/info
curl -u user:$userpw -H "accept: text/plain" -d "" http://localhost:5232/.sharing/v1/all/info
ApiVersion=1
Status='success'
FeatureEnabledCollectionByMap=True
@@ -256,18 +262,20 @@ PermittedCreateCollectionByToken=True
```
bash
curl -u user:pass --silent -H "accept: application/json" -d "" http://localhost:5232/.sharing/v1/all/info | jq
curl -u user:$userpw --silent -H "accept: application/json" -d "" http://localhost:5232/.sharing/v1/all/info | jq
{
"ApiVersion": 1,
"Status": "success",
"FeatureEnabledCollectionByMap": true,
"PermittedCreateCollectionByMap": true,
"FeatureEnabledCollectionByToken": true,
"PermittedCreateCollectionByToken": true
"PermittedCreateCollectionByToken": true,
"FeatureEnabledCollectionByBday": true,
"PermittedCreateCollectionByBday": true
}
```
##### API Hook "(token|map)/create"
##### API Hook "(token|map|bday)/create"
* Authorization
* Authenticated user is `Owner`
@@ -303,7 +311,7 @@ Create a share by mapping a collection of an `Owner` to a token.
* form->text
```bash
curl -u user:pass -d "PathMapped=/user/testcalendar1/" -d "Enabled=True" -d "Hidden=False" http://localhost:5232/.sharing/v1/token/create
curl -u user:$userpw -d "PathMapped=/user/testcalendar1/" -d "Enabled=True" -d "Hidden=False" http://localhost:5232/.sharing/v1/token/create
ApiVersion=1
Status='success'
PathOrToken='v1/VQR7AmsVRi2ZlFj_JwGpFx-ES5Goyku-gP_YkLh1zUw='
@@ -312,11 +320,11 @@ PathOrToken='v1/VQR7AmsVRi2ZlFj_JwGpFx-ES5Goyku-gP_YkLh1zUw='
* json->json
```bash
curl -u user:pass -H "Content-Type: application/json" -d '{ "PathMapped": "/user/testcalendar1/", "Enabled": true, "Hidden": false}' http://localhost:5232/.sharing/v1/token/create
curl -u user:$userpw -H "Content-Type: application/json" -d '{ "PathMapped": "/user/testcalendar1/", "Enabled": true, "Hidden": false}' http://localhost:5232/.sharing/v1/token/create
{"ApiVersion": 1, "Status": "success", "PathOrToken": "v1/aMsmGqOsRwSH-2-6tEa8EMr4RMYzMU7WvPmjnp5qDnw="}
```
###### API Hook "map/create"
###### API Hook "(map|bday)/create"
Create a share by mapping a collection of an `Owner` to an `User`.
@@ -325,8 +333,13 @@ Create a share by mapping a collection of an `Owner` to an `User`.
* `PathMapped` is not existing already as a share target for same `User`
* Authenticated user as `Owner` has at least read access to `PathMapped`
* Provided `User` has at least read access to `PathOrToken`
* Global permitted by `permit_create_map = True` or `rights` permission `m`
* Global denied by `permit_create_map = False` or `rights` permission `M`
* *map*
* Global permitted by `permit_create_map = True` or `rights` permission `m`
* Global denied by `permit_create_map = False` or `rights` permission `M`
* *bday*
* Global permitted by `permit_create_map = True` or `rights` permission `b`
* Global denied by `permit_create_map = False` or `rights` permission `B`
* `PathMapped` is a VCALENDAR collection
* Input
@@ -346,7 +359,7 @@ Create a share by mapping a collection of an `Owner` to an `User`.
* form->text
```bash
curl -u owner:pass -d "PathOrToken=/user/cal1-from-owner/" -d "PathMapped=/owner/testcalendar1/" -d "User=user" -d "Enabled=True" -d "Hidden=False" http://localhost:5232/.sharing/v1/map/create
curl -u owner:$ownerpw -d "PathOrToken=/user/cal1-from-owner/" -d "PathMapped=/owner/testcalendar1/" -d "User=user" -d "Enabled=True" -d "Hidden=False" http://localhost:5232/.sharing/v1/map/create
ApiVersion=1
Status='success'
```
@@ -354,11 +367,11 @@ Status='success'
* json->json
```bash
curl -u owner:pass -H "Content-Type: application/json" -d '{ "PathOrToken": "/user/cal1-from-owner/", "PathMapped": "/owner/testcalendar1/", "User" : "user", "Enabled": true, "Hidden": false}' http://localhost:5232/.sharing/v1/map/create
curl -u owner:$ownerpw -H "Content-Type: application/json" -d '{ "PathOrToken": "/user/cal1-from-owner/", "PathMapped": "/owner/testcalendar1/", "User" : "user", "Enabled": true, "Hidden": false}' http://localhost:5232/.sharing/v1/map/create
{"ApiVersion": 1, "Status": "success"}
```
##### API Hook "(map|token|all)/list"
##### API Hook "(all|token|map|bday)/list"
List shares (optional with filter) either owned or assigned as user.
@@ -379,7 +392,7 @@ List shares (optional with filter) either owned or assigned as user.
* form->text ("all")
```bash
curl -u user:pass -d "" http://localhost:5232/.sharing/v1/map/list://localhost:5232/.sharing/v1/map/list
curl -u user:$userpw -d "" http://localhost:5232/.sharing/v1/map/list://localhost:5232/.sharing/v1/map/list
ApiVersion=1
Lines=1
Status='success'
@@ -390,7 +403,7 @@ Content[0]="map;/user/cal1-from-owner/;/owner/testcalendar1/;owner;user;r;True;T
* form->csv ("map" only)
```bash
curl -H "accept: text/csv" -u user:pass -d "" http://localhost:5232/.sharing/v1/map/list://localhost:5232/.sharing/v1/map/list
curl -H "accept: text/csv" -u user:$userpw -d "" http://localhost:5232/.sharing/v1/map/list://localhost:5232/.sharing/v1/map/list
ShareType;PathOrToken;PathMapped;Owner;User;Permissions;EnabledByOwner;EnabledByUser;HiddenByOwner;HiddenByUser;TimestampCreated;TimestampUpdated;Properties
map;/user/cal1-from-owner/;/owner/testcalendar1/;owner;user;r;True;False;False;True;1772747277;1772747277;
```
@@ -398,7 +411,7 @@ map;/user/cal1-from-owner/;/owner/testcalendar1/;owner;user;r;True;False;False;T
* json->json ("all"), parsed with `jq`
```bash
curl -s -H "Content-Type: application/json" -u user:pass -d "{}" http://localhost:5232/.sharing/v1/all/list | jq
curl -s -H "Content-Type: application/json" -u user:$userpw -d "{}" http://localhost:5232/.sharing/v1/all/list | jq
{
"ApiVersion": 1,
"Lines": 2,
@@ -439,7 +452,7 @@ curl -s -H "Content-Type: application/json" -u user:pass -d "{}" http://localhos
```
##### API Hook "(map|token)/delete"
##### API Hook "(token|map|bday)/delete"
Delete a share selected by `PathOrToken`.
@@ -460,7 +473,7 @@ Delete a share selected by `PathOrToken`.
* form->text
```bash
curl -u owner:pass -d "PathOrToken=/user/cal1-from-owner/" http://localhost:5232/.sharing/v1/map/delete
curl -u owner:$ownerpw -d "PathOrToken=/user/cal1-from-owner/" http://localhost:5232/.sharing/v1/map/delete
ApiVersion=1
Status='success'
```
@@ -468,11 +481,11 @@ Status='success'
* json->json
```bash
curl -u user:pass -H "Content-Type: application/json" -d '{ "PathOrToken": "v1/DUSl_J5rRlWx3fy8YRXpH22FFllplkOTpcSwfGtpvkc="}' http://localhost:5232/.sharing/v1/token/delete
curl -u user:$userpw -H "Content-Type: application/json" -d '{ "PathOrToken": "v1/DUSl_J5rRlWx3fy8YRXpH22FFllplkOTpcSwfGtpvkc="}' http://localhost:5232/.sharing/v1/token/delete
{"ApiVersion": 1, "Status": "success"}
```
##### API Hook "(token|map)/update"
##### API Hook "(token|map|bday)/update"
Update a share selected by `PathOrToken`.
@@ -500,7 +513,7 @@ Execute delete+create in case `PathOrToken` needs to be changed.
* form->text
```bash
curl -u user:pass -d "PathOrToken=/user/cal1-from-owner/" -d "Enabled=True" -d "Hidden=False" http://localhost:5232/.sharing/v1/map/update
curl -u user:$userpw -d "PathOrToken=/user/cal1-from-owner/" -d "Enabled=True" -d "Hidden=False" http://localhost:5232/.sharing/v1/map/update
ApiVersion=1
Status='success'
```
@@ -508,11 +521,11 @@ Status='success'
* json->json
```bash
curl -u user:pass -H "Content-Type: application/json" -d '{ "PathOrToken": "/user/cal1-from-owner/", "Enabled": true, "Hidden": false}' http://localhost:5232/.sharing/v1/map/update
curl -u user:$userpw -H "Content-Type: application/json" -d '{ "PathOrToken": "/user/cal1-from-owner/", "Enabled": true, "Hidden": false}' http://localhost:5232/.sharing/v1/map/update
{"ApiVersion": 1, "Status": "success"}
```
##### API Hooks "(map|token)/(enable|disable|hide|unhide)"
##### API Hooks "(token|map|bday)/(enable|disable|hide|unhide)"
Toggle enable|disable|hide|unhide of `Owner` or `User` of a share selected by `PathOrToken`
@@ -531,7 +544,7 @@ Toggle enable|disable|hide|unhide of `Owner` or `User` of a share selected by `P
* form->text
```bash
curl -u user:pass -d "PathOrToken=/user/cal1-from-owner/" http://localhost:5232/.sharing/v1/map/enable
curl -u user:$userpw -d "PathOrToken=/user/cal1-from-owner/" http://localhost:5232/.sharing/v1/map/enable
ApiVersion=1
Status='success'
```bash
@@ -539,7 +552,7 @@ Status='success'
* json->json
```bash
curl -u user:pass -H "Content-Type: application/json" -d '{ "PathOrToken": "/user/cal1-from-owner/"}' http://localhost:5232/.sharing/v1/map/unhide
curl -u user:$userpw -H "Content-Type: application/json" -d '{ "PathOrToken": "/user/cal1-from-owner/"}' http://localhost:5232/.sharing/v1/map/unhide
{"ApiVersion": 1, "Status": "success"}
```
@@ -595,32 +608,66 @@ xml_ppc='<?xml version="1.0"?>
```bash
## Retrieve collection color of owner (no color set)
curl -u owner:pass -d "$xml_pfc" -X PROPFIND http://localhost:5232/owner/testcalendar1/
curl -u owner:$ownerpw -d "$xml_pfc" -X PROPFIND http://localhost:5232/owner/testcalendar1/
## Create read-only share for user
curl -u owner:pass -d "PathOrToken=/user/cal1-from-owner/" -d "PathMapped=/owner/testcalendar1/" -d "User=user" -d "Enabled=True" -d "Hidden=False" http://localhost:5232/.sharing/v1/map/create
curl -u owner:$ownerpw -d "PathOrToken=/user/cal1-from-owner/" -d "PathMapped=/owner/testcalendar1/" -d "User=user" -d "Enabled=True" -d "Hidden=False" http://localhost:5232/.sharing/v1/map/create
## Accept (enable+unhide) share by user
curl -u user:pass -d "PathOrToken=/user/cal1-from-owner/" -d "Enabled=True" -d "Hidden=False" http://localhost:5232/.sharing/v1/map/update
curl -u user:$userpw -d "PathOrToken=/user/cal1-from-owner/" -d "Enabled=True" -d "Hidden=False" http://localhost:5232/.sharing/v1/map/update
## Retrieve collection color of share by user (no color set)
curl -u user:pass -d "$xml_pfc" -X PROPFIND http://localhost:5232/user/cal1-from-owner/
curl -u user:$userpw -d "$xml_pfc" -X PROPFIND http://localhost:5232/user/cal1-from-owner/
## Set property overlay by user
curl -u user:pass -d "PathOrToken=/user/cal1-from-owner/" -d 'Properties="ICAL:calendar-color"="#CCCCCC"' http://localhost:5232/.sharing/v1/map/update
curl -u user:$userpw -d "PathOrToken=/user/cal1-from-owner/" -d 'Properties="ICAL:calendar-color"="#CCCCCC"' http://localhost:5232/.sharing/v1/map/update
## Retrieve collection color of share by user (color set)
curl -u user:pass -d "$xml_pfc" -X PROPFIND http://localhost:5232/user/cal1-from-owner/
curl -u user:$userpw -d "$xml_pfc" -X PROPFIND http://localhost:5232/user/cal1-from-owner/
## Delete property overlay by user
curl -u user:pass -d "PathOrToken=/user/cal1-from-owner/" -d 'Properties=' http://localhost:5232/.sharing/v1/map/update
curl -u user:$userpw -d "PathOrToken=/user/cal1-from-owner/" -d 'Properties=' http://localhost:5232/.sharing/v1/map/update
## Retrieve collection color of share by user (no color set)
curl -u user:pass -d "$xml_pfc" -X PROPFIND http://localhost:5232/user/cal1-from-owner/
url -u user:$userpw -d "$xml_pfc" -X PROPFIND http://localhost:5232/user/cal1-from-owner/
## Add property overlay by user using PROPPATCH
curl -u user:pass -d "$xml_ppc" -X PROPPATCH http://localhost:5232/user/cal1-from-owner/
curl -u user:$userpw -d "$xml_ppc" -X PROPPATCH http://localhost:5232/user/cal1-from-owner/
## Retrieve collection color of share by user (color set)
curl -u user:pass -d "$xml_pfc" -X PROPFIND http://localhost:5232/user/cal1-from-owner/
curl -u user:$userpw -d "$xml_pfc" -X PROPFIND http://localhost:5232/user/cal1-from-owner/
```
## Virtual "bday" collection
Owner can create for itself or for particular user a virtual bday collection from an existing addressbook.
### Examples
Preconditions:
* Collection with type *adressbook* is existing
* Config options enabled in section `sharing`:
* `collection_by_bday`
* `permit_create_bday`
#### Examples using API
* Create
```bash
## Create sharing of type "bday"
curl -u owner:$ownerpw -d "PathOrToken=/owner/bday-of-addressbook/" -d "PathMapped=/owner/addressbook/" -d "User=owner" http://localhost:5232/.sharing/v1/bday/create
## Enable
curl -u owner:$ownerpw -d "PathOrToken=/owner/bday-of-addressbook/" http://localhost:5232/.sharing/v1/bday/enable
## Unhide
curl -u owner:$ownerpw -d "PathOrToken=/owner/bday-of-addressbook/" http://localhost:5232/.sharing/v1/bday/unhide
```
* Check
Use e.g. WebUI, an additional (virtual) calendar collection appears

8
config
View File

@@ -320,6 +320,9 @@
# Share collection by map
#collection_by_map = false
# Share collection by bday (conversion on-the-fly)
#collection_by_bday = false
# Permit create of token-based sharing
# If False it can be explicitly granted by permissions: t
# If True it can be explicitly forbidden by permissions: T
@@ -330,6 +333,11 @@
# If True it can be explicitly forbidden by permissions: M
#permit_create_map = false
# Permit create of bday-based sharing
# If False it can be explicitly granted by permissions: b
# If True it can be explicitly forbidden by permissions: B
#permit_create_bday = false
# Permit properties overlay
# If False it can be explicitly granted by share permissions: P
# If True it can be explicitly forbidden by share permissions: p

View File

@@ -117,5 +117,9 @@ class ApplicationPartGet(ApplicationBase):
"ETag": item.etag}
if content_disposition:
headers["Content-Disposition"] = content_disposition
answer = item.serialize()
if isinstance(item, storage.BaseCollection) and self._sharing._enabled and share and share['ShareType'] == "bday":
# convert VCF to ICS
answer = item.serialize(vcf_to_ics=True)
else:
answer = item.serialize()
return client.OK, headers, answer, None

View File

@@ -112,18 +112,27 @@ def xml_propfind_response(
logger.debug("TRACE/PROPFIND/xml_propfind: sharetype=%r item.path=%r", sharetype, uri)
for entry in shares:
if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/PROPFIND/xml_propfind: entry=%r", entry)
logger.debug("TRACE/PROPFIND/xml_propfind: check entry=%r", entry)
if entry is not None:
if shares[entry]['PathMapped'] == uri:
if sharetype is None or shares[entry]['ShareType'] == sharetype:
if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/PROPFIND/xml_propfind: PathMapped=%r uri=%r", shares[entry]['PathMapped'], uri)
if uri.startswith(shares[entry]['PathMapped']):
if sharetype is not None and shares[entry]['ShareType'] == sharetype:
share = shares[entry]
if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/PROPFIND/xml_propfind: share=%r", share)
logger.debug("TRACE/PROPFIND/xml_propfind: found share=%r", share)
break
share_bday_automap = False
if share and share['ShareType'] == "bday":
share_bday_automap = True
if share:
# backmap
uri = uri.replace(share['PathMapped'], share['PathOrToken'])
if share_bday_automap and not uri.endswith("/"):
uri = uri.rstrip(".vcf") + ".ics"
href.text = xmlutils.make_href(base_prefix, uri)
response.append(href)
@@ -155,12 +164,16 @@ def xml_propfind_response(
if is_collection:
if is_leaf:
props.append(xmlutils.make_clark("D:displayname"))
props.append(xmlutils.make_clark("D:sync-token"))
if collection.tag == "VCALENDAR":
if share_bday_automap:
# bday share has no sync-token support
pass
else:
props.append(xmlutils.make_clark("D:sync-token"))
if collection.tag == "VCALENDAR" or share_bday_automap:
props.append(xmlutils.make_clark("CS:getctag"))
props.append(
xmlutils.make_clark("C:supported-calendar-component-set"))
if collection.tag == "VADDRESSBOOK":
if collection.tag == "VADDRESSBOOK" and not share_bday_automap:
props.append(xmlutils.make_clark("CS:getctag"))
props.append(
xmlutils.make_clark("CR:supported-address-data"))
@@ -205,6 +218,8 @@ def xml_propfind_response(
if share:
# backmap
child_element.text = child_element.text.replace(share['PathMapped'], share['PathOrToken'])
if share_bday_automap:
child_element.text = child_element.text.rstrip(".vcf") + ".ics"
element.append(child_element)
elif tag == xmlutils.make_clark("C:supported-calendar-component-set"):
human_tag = xmlutils.make_human_tag(tag)
@@ -214,6 +229,9 @@ def xml_propfind_response(
components = components_text.split(",")
else:
components = ["VTODO", "VEVENT", "VJOURNAL"]
if share_bday_automap:
# enforce VEVENT-only
components = ["VEVENT"]
for component in components:
comp = ET.Element(xmlutils.make_clark("C:comp"))
comp.set("name", component)
@@ -221,7 +239,7 @@ def xml_propfind_response(
else:
is404 = True
elif tag == xmlutils.make_clark("CR:supported-address-data"):
if is_collection and is_leaf and collection.tag == "VADDRESSBOOK":
if is_collection and is_leaf and collection.tag == "VADDRESSBOOK" and not share_bday_automap:
# Advertise supported vCard versions per RFC 6352 section 6.2.2
# vCard 4.0 requires vobject >= 1.0.0
versions: Sequence[str] = (("4.0", "3.0")
@@ -243,6 +261,8 @@ def xml_propfind_response(
if share:
# backmap
child_element.text = child_element.text.replace(share['Owner'], share['User'])
if share_bday_automap:
child_element.text = child_element.text.rstrip(".vcf") + ".ics"
element.append(child_element)
else:
element.append(ET.Element(
@@ -254,7 +274,8 @@ def xml_propfind_response(
if "P" in share['Permissions']:
privileges.append("D:write-properties")
if "w" in share['Permissions']:
privileges.append("D:write-content")
if not share_bday_automap:
privileges.append("D:write-content")
elif write:
privileges.append("D:all")
privileges.append("D:write")
@@ -271,11 +292,12 @@ def xml_propfind_response(
"D:principal-search-property-set",
"D:principal-property-search"]
if is_collection and is_leaf:
reports.append("D:sync-collection")
if collection.tag == "VADDRESSBOOK":
if not share_bday_automap:
reports.append("D:sync-collection")
if collection.tag == "VADDRESSBOOK" and not share_bday_automap:
reports.append("CR:addressbook-multiget")
reports.append("CR:addressbook-query")
elif collection.tag == "VCALENDAR":
elif collection.tag == "VCALENDAR" or share_bday_automap:
reports.append("C:calendar-multiget")
reports.append("C:calendar-query")
for human_tag in reports:
@@ -288,7 +310,18 @@ def xml_propfind_response(
element.append(supported_report)
elif tag == xmlutils.make_clark("D:getcontentlength"):
if not is_collection or is_leaf:
element.text = str(len(item.serialize().encode(encoding)))
if collection.tag == "VADDRESSBOOK" and share_bday_automap and isinstance(item, storage.BaseCollection):
if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/PROPFIND/xml_propfind_response/getcontentlength: start bday automap handling")
length = 0
for entry in item.get_all():
item_ics = entry.convert_vcf_to_ics()
if item_ics is None:
continue
length += len(item_ics.vobject_item.serialize().encode(encoding))
element.text = str(length)
else:
element.text = str(len(item.serialize().encode(encoding)))
else:
is404 = True
elif tag == xmlutils.make_clark("D:owner"):
@@ -306,6 +339,9 @@ def xml_propfind_response(
if is_leaf:
element.text = xmlutils.MIMETYPES[
collection.tag]
if share_bday_automap:
# overwrite
element.text = xmlutils.MIMETYPES["VCALENDAR"]
else:
is404 = True
elif tag == xmlutils.make_clark("D:resourcetype"):
@@ -314,11 +350,11 @@ def xml_propfind_response(
xmlutils.make_clark("D:principal"))
element.append(child_element)
if is_leaf:
if collection.tag == "VADDRESSBOOK":
if collection.tag == "VADDRESSBOOK" and not share_bday_automap:
child_element = ET.Element(
xmlutils.make_clark("CR:addressbook"))
element.append(child_element)
elif collection.tag == "VCALENDAR":
elif collection.tag == "VCALENDAR" or share_bday_automap:
child_element = ET.Element(
xmlutils.make_clark("C:calendar"))
element.append(child_element)
@@ -335,12 +371,25 @@ def xml_propfind_response(
displayname = share['Properties']["D:displayname"]
if displayname is not None:
element.text = displayname
if share_bday_automap:
element.text += " (BDAY)"
else:
is404 = True
elif tag == xmlutils.make_clark("RADICALE:getcontentcount"):
# Only for internal use by the web interface
if isinstance(item, storage.BaseCollection) and not collection.is_principal:
element.text = str(sum(1 for x in item.get_all()))
if collection.tag == "VADDRESSBOOK" and share_bday_automap:
if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/PROPFIND/xml_propfind_response/getcontentcount: start bday automap handling")
items = []
for entry in item.get_all():
item_ics = entry.convert_vcf_to_ics()
if item_ics is None:
continue
items.append(item_ics.vobject_item)
element.text = str(sum(1 for x in items))
else:
element.text = str(sum(1 for x in item.get_all()))
else:
is404 = True
elif tag == xmlutils.make_clark("D:displayname"):
@@ -351,6 +400,8 @@ def xml_propfind_response(
displayname = collection.path
if displayname is not None:
element.text = displayname
if share_bday_automap:
element.text += " (BDAY)"
else:
is404 = True
elif tag == xmlutils.make_clark("CS:getctag"):
@@ -385,6 +436,9 @@ def xml_propfind_response(
elif tag == xmlutils.make_clark("D:getcontenttype"):
assert not isinstance(item, storage.BaseCollection)
element.text = xmlutils.get_content_type(item, encoding)
if share_bday_automap:
# overwrite
element.text = xmlutils.MIMETYPES["VCALENDAR"]
elif tag == xmlutils.make_clark("D:resourcetype"):
# resourcetype must be returned empty for non-collection elements
pass
@@ -491,7 +545,13 @@ class ApplicationPartPropfind(ApplicationBase):
# put item back
items_iter = itertools.chain([item], items_iter)
for item, permission in list(self._collect_allowed_items(items_iter, user)):
allowed_items.append((item, permission, None))
if self._sharing._enabled and share:
if share['ShareType'] == "bday" and not isinstance(item, storage.BaseCollection):
if not item.convert_vcf_to_ics():
continue
allowed_items.append((item, permission, share['ShareType']))
else:
allowed_items.append((item, permission, None))
if self._sharing._enabled:
if http_depth == "1":
if logger.isEnabledFor(logging.DEBUG):

View File

@@ -159,6 +159,13 @@ def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element],
"""
if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/REPORT/xml_report: base_prefix=%r path=%r", base_prefix, path)
share_bday_automap = False
if share and share['ShareType'] == "bday":
share_bday_automap = True
if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/REPORT/xml_report(1): share=%r", share)
multistatus = ET.Element(xmlutils.make_clark("D:multistatus"))
if xml_request is None:
return client.MULTI_STATUS, multistatus
@@ -173,12 +180,9 @@ def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element],
logger.warning("Unsupported REPORT method %r on %r requested",
xmlutils.make_human_tag(root.tag), path)
return client.MULTI_STATUS, multistatus
if (root.tag == xmlutils.make_clark("C:calendar-multiget") and
collection.tag != "VCALENDAR" or
root.tag == xmlutils.make_clark("CR:addressbook-multiget") and
collection.tag != "VADDRESSBOOK" or
root.tag == xmlutils.make_clark("D:sync-collection") and
collection.tag not in ("VADDRESSBOOK", "VCALENDAR")):
if ((root.tag == xmlutils.make_clark("C:calendar-multiget") and collection.tag != "VCALENDAR" and not share_bday_automap) or
(root.tag == xmlutils.make_clark("CR:addressbook-multiget") and collection.tag != "VADDRESSBOOK") or
(root.tag == xmlutils.make_clark("D:sync-collection") and collection.tag not in ("VADDRESSBOOK", "VCALENDAR"))):
logger.warning("Invalid REPORT method %r on %r requested",
xmlutils.make_human_tag(root.tag), path)
return client.FORBIDDEN, xmlutils.webdav_error("D:supported-report")
@@ -259,6 +263,23 @@ def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element],
collection_tag = collection.tag
# !!! Don't access storage after this !!!
unlock_storage_fn()
if share and share['ShareType'] == "bday":
collection_tag = "VCALENDAR"
# autoconvert
retrieved_items_vcf_to_ics = []
for item, flag in retrieved_items:
item_ics = item.convert_vcf_to_ics()
if item_ics is None:
continue
else:
pass
item_ics.href = item.href
retrieved_items_vcf_to_ics.append((item_ics, flag))
retrieved_items = retrieved_items_vcf_to_ics
logging.debug("TRACE/REPORT/retrieved_items: %r", retrieved_items)
if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/REPORT/xml_report(2): share=%r", share)
n_vevents = 0
while retrieved_items:
@@ -712,12 +733,25 @@ def xml_item_response(base_prefix: str, href: str,
found_props: Sequence[ET.Element] = (),
not_found_props: Sequence[ET.Element] = (),
found_item: bool = True, share: Union[dict, None] = None) -> ET.Element:
response = ET.Element(xmlutils.make_clark("D:response"))
if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/REPORT/xml_item_response: found=%s share=%r", found_item, share)
share_bday_automap = False
if share and share['ShareType'] == "bday":
share_bday_automap = True
href_element = ET.Element(xmlutils.make_clark("D:href"))
href_element.text = xmlutils.make_href(base_prefix, href)
if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/REPORT/xml_report: href=%r", href_element.text)
if share:
# backmap
href_element.text = href_element.text.replace(share['PathMapped'], share['PathOrToken'])
if share_bday_automap:
href_element.text = href_element.text.rstrip(".vcf") + ".ics"
response.append(href_element)
if found_item:
@@ -759,6 +793,9 @@ def retrieve_items(
if share:
# map back to owner
hreference = hreference.replace(share['PathOrToken'], share['PathMapped'])
if share['ShareType'] == "bday":
if not hreference.endswith('/'):
hreference = hreference.rstrip(".ics") + ".vcf"
try:
name = pathutils.name_from_path(hreference, collection)
except ValueError as e:

View File

@@ -487,6 +487,10 @@ DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([
"value": "false",
"help": "enable sharing of collection by map",
"type": bool}),
("collection_by_bday", {
"value": "false",
"help": "enable sharing of collection by bday (conversion on-the-fly)",
"type": bool}),
("permit_create_token", {
"value": "false",
"help": "permit create of token-based sharing",
@@ -495,6 +499,10 @@ DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([
"value": "false",
"help": "permit create of map-based sharing",
"type": bool}),
("permit_create_bday", {
"value": "false",
"help": "permit create of bday-based sharing",
"type": bool}),
("permit_properties_overlay", {
"value": "false",
"help": "permit properties overlay",

View File

@@ -26,14 +26,15 @@ Module for address books and calendar entries (see ``Item``).
import binascii
import contextlib
import datetime
import logging
import math
import os
import re
from datetime import datetime, timedelta
from hashlib import sha256
from itertools import chain
from typing import (Any, Callable, List, MutableMapping, Optional, Sequence,
Tuple)
Tuple, Union)
import vobject
@@ -42,6 +43,11 @@ from radicale import pathutils, utils
from radicale.item import filter as radicale_filter
from radicale.log import logger
# Product ID for auto-conversion
PRODID_CONVERTED = u"-//Radicale//NONSGML " + utils.package_version("radicale") + "//EN (auto-converted)"
PRODID_SUFFIX = " (auto-converted by Radicale " + utils.package_version("radicale") + ")"
UID_SUFFIX = "-auto-converted-by-Radicale"
def read_components(s: str) -> List[vobject.base.Component]:
"""Wrapper for vobject.readComponents"""
@@ -159,7 +165,7 @@ def check_and_sanitize_items(
# an event with DTEND and DURATION:PT0S
if (hasattr(component, "dtend") and
hasattr(component, "duration") and
component.duration.value == timedelta(0)):
component.duration.value == datetime.timedelta(0)):
logger.debug("Quirks: Removing zero duration from %s in "
"object %r", component_name, component_uid)
del component.duration
@@ -314,7 +320,7 @@ def find_time_range(vobject_item: vobject.base.Component, tag: str
return radicale_filter.TIMESTAMP_MIN, radicale_filter.TIMESTAMP_MAX
start = end = None
def range_fn(range_start: datetime, range_end: datetime,
def range_fn(range_start: datetime.datetime, range_end: datetime.datetime,
is_recurrence: bool) -> bool:
nonlocal start, end
if start is None or range_start < start:
@@ -323,7 +329,7 @@ def find_time_range(vobject_item: vobject.base.Component, tag: str
end = range_end
return False
def infinity_fn(range_start: datetime) -> bool:
def infinity_fn(range_start: datetime.datetime) -> bool:
nonlocal start, end
if start is None or range_start < start:
start = range_start
@@ -498,3 +504,114 @@ class Item:
self.time_range
self.component_name
self._vobject_item = orig_vobject_item
def convert_vcf_to_ics(self) -> Union["Item", None]:
if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/item/convert_vcf_to_ics: convert VCF to ICS (href): %r", self.href)
logger.debug("TRACE/item/convert_vcf_to_ics: convert VCF to ICS (vobject): %r", self.vobject_item)
if self.vobject_item.name != "VCARD":
if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/item/convert_vcf_to_ics: item is not a VCARD (skip): %r", self.href)
return None
else:
if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/item/convert_vcf_to_ics: item is a VCARD (ok): %r", self.href)
if not hasattr(self.vobject_item, "bday"):
if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/item/convert_vcf_to_ics: miss bday (skip): %r", self.href)
return None
else:
pass
bday = self.vobject_item.bday
if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/item/convert_vcf_to_ics: has bday (ok): %r -> %r", self.href, bday.value)
pattern = re.compile('^([0-9]{4})-?([0-9]{2})-?([0-9]{2})$')
match = pattern.match(bday.value)
if not match:
if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/item/convert_vcf_to_ics: has unsupported bday: %r -> %r", self.href, bday.value)
return None
else:
pass
bdayS = match[1] + match[2] + match[3]
bdayY = int(match[1])
bdayM = int(match[2])
bdayD = int(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
elif hasattr(self.vobject_item, "nickname"):
name = self.vobject_item.nickname.value
else:
if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/item/convert_vcf_to_ics: has bday but neither FN or N or NICKNAME (skip): %r", self.href)
return None
# create VCALENDAR
item_ics = vobject.newFromBehavior('vcalendar')
# set PRODID
if hasattr(self.vobject_item, "prodid"):
item_ics.add('prodid').value = self.vobject_item.prodid.value + PRODID_SUFFIX
else:
item_ics.add('prodid').value = PRODID_CONVERTED
# create EVENT
item_ics.add('vevent')
# set DTSTART
dtstart = datetime.date(bdayY, bdayM, bdayD)
item_ics.vevent.add('dtstart').value = dtstart
# calculate and set DTEND
dtend = dtstart + datetime.timedelta(days=1)
item_ics.vevent.add('dtend').value = dtend
# set 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]
else:
item_ics.vevent.add('uid').value = self.vobject_item.uid.value + UID_SUFFIX
else:
item_ics.vevent.add('uid').value = match[1] + match[2] + match[3] + "@" + name.replace(" ", "-") + UID_SUFFIX
# set SUMMARY
item_ics.vevent.add('summary').value = name + " (BDAY)"
# set RRULE
item_ics.vevent.add('rrule').value = "FREQ=YEARLY"
# add transparency
item_ics.vevent.add('transp').value = "TRANSPARENT"
href = self.href
if href is not None:
href = href.rstrip(".vcf") + ".ics"
etag = self.etag
# replace 14 leading chars of etag "<hexdigits>" by special format bda0YYYYMMDD00
etag = '"bda0' + bdayS + '00' + etag[15:]
item_new: Item = Item(
collection=self.collection,
etag=etag,
href=href,
vobject_item=item_ics)
if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/storage: item generated/vobject: %r", item_ics.serialize())
logger.debug("TRACE/storage: item orig /etag : %r", self.etag)
logger.debug("TRACE/storage: item generated/etag : %r", item_new.etag)
logger.debug("TRACE/storage: item generated/href : %r", item_new.href)
return item_new

View File

@@ -35,6 +35,8 @@ Permissions:
- t: deny create of token-based sharing of collection in case permit_create_token=True (>= 3.7.0)
- M: permit create of map-based sharing of collection in case permit_create_map=False (>= 3.7.0)
- m: deny create of map-based sharing of collection in case permit_create_map=True (>= 3.7.0)
- B: permit create of bday-based sharing of addressbook collection in case permit_create_bday=False (>= 3.7.0)
- b: deny create of bday-based sharing of addressbook collection in case permit_create_bday=True (>= 3.7.0)
Permissions only supported so far in share permissions:
- P: permit properties overlay in case permit_properties_overlay=False (>= 3.7.0)
@@ -53,7 +55,7 @@ from radicale import config, utils
INTERNAL_TYPES: Sequence[str] = ("authenticated", "owner_write", "owner_only",
"from_file")
INTERNAL_PERMISSIONS: str = "RriWwDdOoTtMmPpEe"
INTERNAL_PERMISSIONS: str = "RriWwDdOoTtMmPpEeBb"
def load(configuration: "config.Configuration") -> "BaseRights":

View File

@@ -67,11 +67,12 @@ DB_TYPES_V1: dict[str, type] = {
DB_FIELDS_V1_USER_PERMITTED: Sequence[str] = ('EnabledByUser', 'HiddenByUser', 'Properties')
SHARE_TYPES: Sequence[str] = ('token', 'map', 'all')
SHARE_TYPES: Sequence[str] = ('token', 'map', 'bday', 'all')
SHARE_TYPES_V1: Sequence[str] = ('token', 'map')
SHARE_TYPES_V1: Sequence[str] = ('token', 'map', 'bday')
# token: share by secret token (does not require authentication)
# map : share by mapping collection of one user to another as virtual
# bday : share by mapping addressbook-collection of one user to another as virtual calendar-collection
# all : only supported for "list" and "info"
API_HOOKS_V1: Sequence[str] = ('list', 'create', 'delete', 'update', 'hide', 'unhide', 'enable', 'disable', 'info')
@@ -93,8 +94,10 @@ API_TYPES_V1: dict[str, type] = {
"Lines": int,
"FeatureEnabledCollectionByMap": bool,
"FeatureEnabledCollectionByToken": bool,
"FeatureEnabledCollectionByBday": bool,
"PermittedCreateCollectionByMap": bool,
"PermittedCreateCollectionByToken": bool,
"PermittedCreateCollectionByBday": bool,
"ShareType": str,
"PathOrToken": str,
"PathMapped": str,
@@ -142,16 +145,20 @@ class BaseSharing:
# Sharing
self.sharing_collection_by_map = configuration.get("sharing", "collection_by_map")
self.sharing_collection_by_token = configuration.get("sharing", "collection_by_token")
self.sharing_collection_by_bday = configuration.get("sharing", "collection_by_bday")
self.permit_create_token = configuration.get("sharing", "permit_create_token")
self.permit_create_map = configuration.get("sharing", "permit_create_map")
self.permit_create_bday = configuration.get("sharing", "permit_create_bday")
self.default_permissions_create_token = configuration.get("sharing", "default_permissions_create_token")
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")
logger.info("sharing.collection_by_map : %s", self.sharing_collection_by_map)
logger.info("sharing.collection_by_token: %s", self.sharing_collection_by_token)
logger.info("sharing.collection_by_bday : %s", self.sharing_collection_by_bday)
logger.info("sharing.permit_create_token: %s", self.permit_create_token)
logger.info("sharing.permit_create_map : %s", self.permit_create_map)
logger.info("sharing.permit_create_bday : %s", self.permit_create_bday)
logger.info("sharing.default_permissions_create_token: %r", self.default_permissions_create_token)
logger.info("sharing.default_permissions_create_map : %r", self.default_permissions_create_map)
logger.info("sharing.permit_properties_overlay: %s", self.permit_properties_overlay)
@@ -161,7 +168,7 @@ class BaseSharing:
self.sharing_db_type = configuration.get("sharing", "type")
logger.info("sharing.database_type: %s", self.sharing_db_type)
if ((self.sharing_collection_by_map is False) and (self.sharing_collection_by_token is False)):
if ((self.sharing_collection_by_map is False) and (self.sharing_collection_by_token is False) and (self.sharing_collection_by_bday is False)):
logger.info("sharing disabled as no feature is enabled")
self._enabled = False
return
@@ -341,6 +348,20 @@ class BaseSharing:
HiddenByOwner=Hidden,
HiddenByUser=Hidden)
if not self.sharing_collection_by_bday:
if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/sharing/bday: not active")
else:
# retrieve collections depending on filter
sharing_collection_list += self.database_list_sharing(
ShareType="bday",
OwnerOrUser=User,
User=User,
EnabledByOwner=Enabled,
EnabledByUser=Enabled,
HiddenByOwner=Hidden,
HiddenByUser=Hidden)
return sharing_collection_list
# resolves a path to a share
@@ -366,6 +387,13 @@ class BaseSharing:
if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/sharing/map: not active")
if self.sharing_collection_by_bday:
if share is None:
share = self.sharing_collection_by_bday_resolver(path, user)
else:
if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/sharing/bday: not active")
if share is not None:
if self.permit_properties_overlay:
if share['Permissions'] and "p" not in share['Permissions']:
@@ -466,6 +494,43 @@ class BaseSharing:
logger.debug("TRACE/sharing/map: not active")
return None
# resolves a bday "path" to a share
def sharing_collection_by_bday_resolver(self, path: str, user: str) -> Union[dict, None]:
""" returning dict with PathMapped, Owner, Permissions or None if invalid"""
if self.sharing_collection_by_bday:
if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/sharing/bday/resolver: check path: %r", path)
result = self.database_get_sharing(
ShareType="bday",
PathOrToken=path,
User=user)
if result:
pass
else:
# fallback to parent path
parent_path = pathutils.parent_path(path)
if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/sharing/bday/resolver: check parent path: %r", parent_path)
result = self.database_get_sharing(
ShareType="bday",
PathOrToken=parent_path,
User=user)
if result:
result['PathMapped'] = path.replace(parent_path, result['PathMapped'])
if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/sharing/bday/resolver: PathMapped=%r Permissions=%r by parent_path=%r", result['PathMapped'], result['Permissions'], parent_path)
else:
if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/sharing/bday: not found")
return None
logger.info("Sharing/%s: resolved path %r->%r, user %r->%r, permissions %r", "bday", path, result['PathMapped'], user, result['Owner'], result['Permissions'])
return result
else:
if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/sharing/bday: not active")
return None
# *** POST API ***
def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: str) -> types.WSGIResponse:
# Late import to avoid circular dependency in config
@@ -480,28 +545,28 @@ class BaseSharing:
``user`` is empty for anonymous users.
Request:
action: (token|map/list
action: (token|map|bday)/list
PathOrToken: <path|token> (optional for filter)
action: (token|map)/create
action: (token|map|bday)/create
PathMapped: <path> (mandatory)
Permissions: <Permissions> (default: r)
token -> returns <token>
map
map|bday
PathOrToken: <path> (mandatory)
User: <target_user> (mandatory)
action: (token|map)/update
action: (token|map|bday)/update
action: (token|map)/(delete|disable|enable|hide|unhide)
action: (token|map|bday)/(delete|disable|enable|hide|unhide)
PathOrToken: <path|token> (mandatory)
token
map
PathMapped: <path> (mandator)
map|bday
PathMapped: <path> (mandatory)
User: <target_user>
Response: output format depending on ACCEPT header
@@ -561,6 +626,11 @@ class BaseSharing:
logger.warning(api_info + ": not enabled by config (collection_by_map)")
return httputils.NOT_FOUND
if not self.sharing_collection_by_bday and ShareType == "bday":
# API "token" is not enabled
logger.warning(api_info + ": not enabled by config (collection_by_bday)")
return httputils.NOT_FOUND
# check for valid API hooks
if action not in API_HOOKS_V1:
if logger.isEnabledFor(logging.DEBUG):
@@ -756,7 +826,7 @@ class BaseSharing:
answer['ApiVersion'] = 1
Timestamp = int((datetime.now() - datetime(1970, 1, 1)).total_seconds())
if not self.sharing_collection_by_map and not self.sharing_collection_by_token:
if not self.sharing_collection_by_map and not self.sharing_collection_by_token and not self.sharing_collection_by_bday:
if not action == 'info':
# API is not enabled
logger.warning(api_info + ": API is not enabled")
@@ -881,7 +951,7 @@ class BaseSharing:
if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/" + api_info + ": result=%r", result)
elif ShareType == "map":
elif ShareType in ["map", "bday"]:
# check preconditions
if PathOrToken is None:
return httputils.bad_request("Missing PathOrToken")
@@ -911,14 +981,25 @@ class BaseSharing:
logger.warning(api_info + ": access to PathMapped=%r not allowed for owner %r", PathMapped, user)
return httputils.NOT_ALLOWED
if self.permit_create_map is False:
if "m" not in access.permissions:
logger.warning(api_info + ": access to PathMapped=%r not allowed for owner %r (permit=False but explicit grant misses 'm')", PathMapped, user)
return httputils.NOT_ALLOWED
else:
if "M" in access.permissions:
logger.warning(api_info + ": access to PathMapped=%r not allowed for owner %r (permit=True but denied by 'M')", PathMapped, user)
return httputils.NOT_ALLOWED
if ShareType == "map":
if self.permit_create_map is False:
if "m" not in access.permissions:
logger.warning(api_info + ": access to PathMapped=%r not allowed for owner %r (permit=False but explicit grant misses 'm')", PathMapped, user)
return httputils.NOT_ALLOWED
else:
if "M" in access.permissions:
logger.warning(api_info + ": access to PathMapped=%r not allowed for owner %r (permit=True but denied by 'M')", PathMapped, user)
return httputils.NOT_ALLOWED
elif ShareType == "bday":
if self.permit_create_bday is False:
if "b" not in access.permissions:
logger.warning(api_info + ": access to PathMapped=%r not allowed for owner %r (permit=False but explicit grant misses 'b')", PathMapped, user)
return httputils.NOT_ALLOWED
else:
if "B" in access.permissions:
logger.warning(api_info + ": access to PathMapped=%r not allowed for owner %r (permit=True but denied by 'B')", PathMapped, user)
return httputils.NOT_ALLOWED
access = Access(self._rights, User, PathOrToken)
if not access.check("r"):
@@ -978,7 +1059,7 @@ class BaseSharing:
if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/" + api_info + ": start")
if ShareType not in ["token", "map"]:
if ShareType not in ["token", "map", "bday"]:
logger.warning(api_info + ": unsupported for ShareType=%r", ShareType)
return httputils.bad_request("Invalid share type")
@@ -1023,17 +1104,33 @@ class BaseSharing:
logger.warning(api_info + ": access to %r not allowed for user %r", PathMapped, user)
return httputils.NOT_ALLOWED
result = self.database_update_sharing(
ShareType=ShareType,
PathMapped=PathMapped,
Permissions=Permissions,
EnabledByOwner=Enabled,
HiddenByOwner=Hidden,
PathOrToken=PathOrToken,
OwnerOrUser=user,
User=User,
Timestamp=Timestamp,
Properties=Properties)
if user == share['User']:
# self-owned share
result = self.database_update_sharing(
ShareType=ShareType,
PathMapped=PathMapped,
Permissions=Permissions,
EnabledByOwner=Enabled,
EnabledByUser=Enabled,
HiddenByOwner=Hidden,
HiddenByUser=Hidden,
PathOrToken=PathOrToken,
OwnerOrUser=user,
User=User,
Timestamp=Timestamp,
Properties=Properties)
else:
result = self.database_update_sharing(
ShareType=ShareType,
PathMapped=PathMapped,
Permissions=Permissions,
EnabledByOwner=Enabled,
HiddenByOwner=Hidden,
PathOrToken=PathOrToken,
OwnerOrUser=user,
User=User,
Timestamp=Timestamp,
Properties=Properties)
elif user == share['User']:
# User is only allowed to update Properties
@@ -1088,7 +1185,7 @@ class BaseSharing:
if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/" + api_info + ": start")
if ShareType not in ["token", "map"]:
if ShareType not in ["token", "map", "bday"]:
logger.warning(api_info + ": unsupported for ShareType=%r", ShareType)
return httputils.bad_request("Invalid share type")
@@ -1133,13 +1230,16 @@ class BaseSharing:
if ShareType in ["all", "token"]:
answer['FeatureEnabledCollectionByToken'] = self.sharing_collection_by_token
answer['PermittedCreateCollectionByToken'] = self.permit_create_token
if ShareType in ["all", "bday"]:
answer['FeatureEnabledCollectionByBday'] = self.sharing_collection_by_bday
answer['PermittedCreateCollectionByBday'] = self.permit_create_bday
# action: TOGGLE
elif action in API_SHARE_TOGGLES_V1:
if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/sharing/API/POST/" + action)
if ShareType not in ["token", "map"]:
if ShareType not in ["token", "map", "bday"]:
logger.warning(api_info + ": unsupported for ShareType=%r", ShareType)
return httputils.bad_request("Invalid share type")

View File

@@ -241,6 +241,17 @@ class Sharing(sharing.BaseSharing):
# must be unique systemwide
logger.error("sharing/map/create: entry already exists: PathMapped=%r User=%r", PathMapped, User)
return {"status": "conflict"}
elif ShareType == "bday":
if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/sharing/bday/create: PathOrToken=%r Owner=%r PathMapped=%r User=%r Permissions=%r", PathOrToken, Owner, PathMapped, User, Permissions)
# check for duplicate map entry
for row in self._sharing_cache:
if row['ShareType'] != "bday":
continue
if row['PathMapped'] == PathMapped and row['User'] == User and row['PathOrToken'] == PathOrToken:
# must be unique systemwide
logger.error("sharing/bday/create: entry already exists: PathMapped=%r User=%r", PathMapped, User)
return {"status": "conflict"}
else:
return {"status": "error"}

View File

@@ -25,6 +25,7 @@ Take a look at the class ``BaseCollection`` if you want to implement your own.
"""
import json
import logging
import xml.etree.ElementTree as ET
from hashlib import sha256
from typing import (Callable, ContextManager, Dict, Iterable, Iterator, List,
@@ -223,7 +224,7 @@ class BaseCollection:
"""Get the HTTP-datetime of when the collection was modified."""
raise NotImplementedError
def serialize(self) -> str:
def serialize(self, vcf_to_ics: bool = False) -> str:
"""Get the unicode string representing the whole collection."""
if self.tag == "VCALENDAR":
in_vcalendar = False
@@ -282,7 +283,23 @@ class BaseCollection:
vtimezones + components +
template[template_insert_pos:])
if self.tag == "VADDRESSBOOK":
return "".join((item.serialize() for item in self.get_all()))
if vcf_to_ics:
if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/storage: convert VCF to ICS")
items = []
for item in self.get_all():
if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/storage/convert VCF to ICS: %r:", item)
item_ics = item.convert_vcf_to_ics()
if item_ics is None:
continue
else:
pass
items.append(item_ics.vobject_item)
return "".join((item.serialize() for item in items))
else:
return "".join((item.serialize() for item in self.get_all()))
return ""

View File

@@ -0,0 +1,9 @@
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;;;
FN:Test-FN
NICKNAME:Test-NICKNAME
BDAY:1970-01-01
END:VCARD

View File

@@ -0,0 +1,9 @@
BEGIN:VCARD
VERSION:3.0
PRODID:+//Manual/0.0.1
UID:05cf4901-e581-44ee-bb0a-1e1a875da44a
N:Test;N;C3;;
FN:Test-FN-C3
NICKNAME:Test-NICKNAME-C3
BDAY:1990-01-01
END:VCARD

View File

@@ -256,6 +256,7 @@ class TestSharingApiSanity(BaseTest):
"htpasswd_encryption": "plain"},
"sharing": {
"collection_by_map": "True",
"collection_by_bday": "True",
"collection_by_token": "True"},
"rights": {"type": "owner_only"}})
@@ -333,13 +334,16 @@ class TestSharingApiSanity(BaseTest):
# path with valid API and hook and all enabled
self.configure({"sharing": {
"collection_by_map": "True",
"collection_by_bday": "True",
"collection_by_token": "True"}
})
for sharetype in sharing.SHARE_TYPES:
path = "/.sharing/v1/" + sharetype + "/" + action
# invalid API
logging.info("\n*** check hook -> 404 (invalid)")
_, headers, _ = self.request("POST", path + "NA", check=404, login="owner:ownerpw")
# valid API
logging.info("\n*** check hook -> 400 (valid but no data)")
_, headers, _ = self.request("POST", path, check=400, login="owner:ownerpw")
logging.info("\n*** check API hook: info/token -> 200")
@@ -353,8 +357,10 @@ class TestSharingApiSanity(BaseTest):
# When turning on permission to create
self.configure({"sharing": {
"collection_by_map": "True",
"collection_by_bday": "True",
"collection_by_token": "True",
"permit_create_map": "True",
"permit_create_bday": "True",
"permit_create_token": "True"}
})
logging.info("\n*** check API hook: info/all")
@@ -363,8 +369,10 @@ class TestSharingApiSanity(BaseTest):
answer_dict = json.loads(answer)
assert answer_dict['FeatureEnabledCollectionByMap'] is True, f'FeatureEnabledCollectionByMap {db_type}'
assert answer_dict['FeatureEnabledCollectionByToken'] is True, f'FeatureEnabledCollectionByToken {db_type}'
assert answer_dict['FeatureEnabledCollectionByBday'] is True, f'FeatureEnabledCollectionByBday {db_type}'
assert answer_dict['PermittedCreateCollectionByMap'] is True, f'PermittedCreateCollectionByMap {db_type}'
assert answer_dict['PermittedCreateCollectionByToken'] is True, f'PermittedCreateCollectionByToken {db_type}'
assert answer_dict['PermittedCreateCollectionByBday'] is True, f'PermittedCreateCollectionByBday {db_type}'
def test_sharing_api_list_with_auth(self) -> None:
"""POST/list with authentication."""
@@ -374,8 +382,10 @@ class TestSharingApiSanity(BaseTest):
"sharing": {
"type": "csv",
"permit_create_map": True,
"permit_create_bday": True,
"permit_create_token": True,
"collection_by_map": "True",
"collection_by_bday": "True",
"collection_by_token": "True"},
"logging": {"request_header_on_debug": "true",
"request_content_on_debug": "True"},
@@ -478,6 +488,7 @@ class TestSharingApiSanity(BaseTest):
"permit_create_map": True,
"permit_create_token": True,
"collection_by_map": "True",
"collection_by_bday": "True",
"collection_by_token": "True"},
"logging": {"request_header_on_debug": "False",
"response_content_on_debug": "True",
@@ -4170,3 +4181,493 @@ permissions: RrWw""")
assert answer_dict['Lines'] == 1
assert 'ICAL:calendar-color' not in answer_dict['Content'][0]['Properties']
assert 'C:calendar-description' not in answer_dict['Content'][0]['Properties']
def test_sharing_api_bday_basic(self) -> None:
"""share-by-bday basic tests."""
self.configure({"auth": {"type": "htpasswd",
"htpasswd_filename": self.htpasswd_file_path,
"htpasswd_encryption": "plain"},
"sharing": {
"type": "csv",
"permit_create_bday": True,
"permit_properties_overlay": "True",
"enforce_properties_overlay": "True",
"collection_by_bday": "True"},
"logging": {"request_header_on_debug": "False",
"response_content_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")
contact = get_file_content("contact1.vcf")
path = path_mapped + "/contact1.vcf"
self.put(path, contact, login="owner:ownerpw")
contact = get_file_content("contact2-with-bday.vcf")
path = path_mapped + "/contact2-with-bday.vcf"
self.put(path, contact, login="owner:ownerpw")
contact = get_file_content("contact3-with-bday.vcf")
path = path_mapped + "/contact3-with-bday.vcf"
self.put(path, contact, login="owner:ownerpw")
# check PROPFIND as owner
logging.info("\n*** PROPFIND collection owner -> ok")
_, responses = self.propfind(path_mapped, """\
<?xml version="1.0" encoding="utf-8"?>
<propfind xmlns="DAV:">
<propname />
</propfind>""", login="owner:ownerpw")
logging.info("response: %r", responses)
response = responses[path_mapped]
assert not isinstance(response, int)
assert "CR:supported-address-data" in response
# execute GET as owner
logging.info("\n*** GET VCF collection owner -> ok")
_, answer = self.get(path_mapped, login="owner:ownerpw")
assert "contact1" in answer
assert "contact2" in answer
# create map
logging.info("\n*** create bday user/owner:r -> ok")
json_dict = {}
json_dict['User'] = "user"
json_dict['PathMapped'] = path_mapped
json_dict['PathOrToken'] = path_shared_r
json_dict['Permissions'] = "r"
json_dict['Enabled'] = True
json_dict['Hidden'] = False
_, headers, answer = self._sharing_api_json("bday", "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 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("bday", "enable", check=200, login="user:userpw", json_dict=json_dict)
# check PROPFIND item as user
logging.info("\n*** PROPFIND item as user -> calendar")
response = self._propfind_allprop(path_shared_r, login="user:userpw")
logging.debug("response: %r", response)
assert "CR:supported-address-data" not in response
assert "D:sync-token" not in response
assert "C:supported-calendar-component-set" in response
assert "D:current-user-privilege-set" in response
# check PROPFIND/priviledges item as user
logging.info("\n*** PROPFIND/priviledges item as user -> calendar")
priviledges_list = self._propfind_priviledges(path_shared_r, login="user:userpw")
assert "D:read" in priviledges_list
assert "D:write-content" not in priviledges_list
assert "D:write-properties" in priviledges_list
assert "D:write" not in priviledges_list
assert "D:all" not in priviledges_list
# verify content as user
logging.info("\n*** GET collection user -> ok")
_, answer = self.get(path_shared_r, login="user:userpw")
assert "BEGIN:VCARD" not in answer
assert "BEGIN:VCALENDAR" in answer
assert "RRULE:FREQ=YEARLY" in answer
assert "DTSTART;VALUE=DATE:19700101" in answer
assert "DTEND;VALUE=DATE:19700102" in answer
assert "TRANSP:TRANSPARENT" in answer
# verify report as user
logging.info("\n*** REPORT collection user -> ok")
_, responses = self.report(path_shared_r, """\
<?xml version="1.0" encoding="utf-8" ?>
<C:calendar-query xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:prop xmlns:D="DAV:">
<D:getetag/>
</D:prop>
</C:calendar-query>""", login="user:userpw")
logging.debug("resonses: %r", responses)
assert path_shared_r + "contact2-with-bday.ics" in responses
assert path_shared_r + "contact3-with-bday.ics" in responses
assert path_shared_r + "contact1.ics" not in responses
# get elements as user
logging.info("\n*** REPORT collection entries user -> ok")
_, responses = self.report(path_shared_r, """\
<?xml version="1.0"?>
<C:calendar-multiget xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<prop>
<getetag />
<C:calendar-data />
</prop>
<href>""" + path_shared_r + "contact2-with-bday.ics" + """</href>
<href>""" + path_shared_r + "contact3-with-bday.ics" + """</href>
</C:calendar-multiget>""", login="user:userpw")
logging.debug("resonses: %r", responses)
assert path_shared_r + "contact2-with-bday.ics" in responses
assert path_shared_r + "contact3-with-bday.ics" in responses
assert path_shared_r + "contact1.ics" not in responses
# timerange filter elements as user
logging.info("\n*** REPORT collection entries with timerange user -> ok")
_, responses = self.report(path_shared_r, """\
<?xml version="1.0"?>
<C:calendar-query xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<prop>
<getcontenttype />
<getetag />
<C:calendar-data />
</prop>
<C:filter>
<C:comp-filter name="VCALENDAR">
<C:comp-filter name="VEVENT">
<C:time-range start="20251124T000000Z" end="20260715T000000Z" />
</C:comp-filter>
</C:comp-filter>
</C:filter>
</C:calendar-query>""", login="user:userpw")
logging.debug("resonses: %r", responses)
assert path_shared_r + "contact2-with-bday.ics" in responses
assert path_shared_r + "contact3-with-bday.ics" in responses
assert path_shared_r + "contact1.ics" not in responses
logging.info("\n*** PROPFIND collection entries user -> ok")
_, responses = self.propfind(path_shared_r, """\
<?xml version="1.0"?>
<propfind xmlns="DAV:">
<prop>
<getcontenttype />
<resourcetype />
<getetag />
</prop>
</propfind>""", login="user:userpw", HTTP_DEPTH="1")
logging.debug("resonses: %r", responses)
assert path_shared_r + "contact2-with-bday.ics" in responses
assert path_shared_r + "contact3-with-bday.ics" in responses
assert path_shared_r + "contact1.ics" not in responses
response = responses[path_shared_r + "contact2-with-bday.ics"]
assert not isinstance(response, int)
status, prop = response["D:getcontenttype"]
assert "text/calendar" in str(prop.text)
def test_sharing_api_bday_complex(self) -> None:
"""share-by-bday complex tests."""
self.configure({"auth": {"type": "htpasswd",
"htpasswd_filename": self.htpasswd_file_path,
"htpasswd_encryption": "plain"},
"sharing": {
"type": "csv",
"permit_create_bday": True,
"permit_create_map": True,
"permit_properties_overlay": "True",
"enforce_properties_overlay": "True",
"collection_by_map": "True",
"collection_by_bday": "True"},
"logging": {"request_header_on_debug": "False",
"response_content_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_user = "/user/"
path_shared_bday = path_user + "calendar-bday-abook-shared-by-owner-r-" + db_type + ".ics/"
path_shared_map = path_user + "abook-shared-by-owner-r-" + db_type + ".vcf/"
self.create_addressbook(path_mapped, login="owner:ownerpw")
contact = get_file_content("contact1.vcf")
path = path_mapped + "/contact1.vcf"
self.put(path, contact, login="owner:ownerpw")
contact = get_file_content("contact2-with-bday.vcf")
path = path_mapped + "/contact2-with-bday.vcf"
self.put(path, contact, login="owner:ownerpw")
contact = get_file_content("contact3-with-bday.vcf")
path = path_mapped + "/contact3-with-bday.vcf"
self.put(path, contact, login="owner:ownerpw")
# check PROPFIND as owner
logging.info("\n*** PROPFIND collection owner -> ok")
_, responses = self.propfind(path_mapped, """\
<?xml version="1.0" encoding="utf-8"?>
<propfind xmlns="DAV:">
<propname />
</propfind>""", login="owner:ownerpw")
logging.info("response: %r", responses)
response = responses[path_mapped]
assert not isinstance(response, int)
assert "CR:supported-address-data" in response
# execute GET as owner
logging.info("\n*** GET VCF collection owner -> ok")
_, answer = self.get(path_mapped, login="owner:ownerpw")
assert "contact1" in answer
assert "contact2" in answer
# create bday
logging.info("\n*** create bday user/owner:r -> ok")
json_dict = {}
json_dict['User'] = "user"
json_dict['PathMapped'] = path_mapped
json_dict['PathOrToken'] = path_shared_bday
json_dict['Permissions'] = "r"
json_dict['Enabled'] = True
json_dict['Hidden'] = False
_, headers, answer = self._sharing_api_json("bday", "create", check=200, login="owner:ownerpw", json_dict=json_dict)
answer_dict = json.loads(answer)
assert answer_dict['Status'] == "success"
# enable bday by user
logging.info("\n*** enable bday by user")
json_dict = {}
json_dict['User'] = "user"
json_dict['PathMapped'] = path_mapped
json_dict['PathOrToken'] = path_shared_bday
_, headers, answer = self._sharing_api_json("bday", "enable", check=200, login="user:userpw", json_dict=json_dict)
# unhide bday by user
logging.info("\n*** unhide bday by user")
json_dict = {}
json_dict['User'] = "user"
json_dict['PathMapped'] = path_mapped
json_dict['PathOrToken'] = path_shared_bday
_, headers, answer = self._sharing_api_json("bday", "unhide", check=200, login="user:userpw", json_dict=json_dict)
# create map
logging.info("\n*** create map user/owner:r -> ok")
json_dict = {}
json_dict['User'] = "user"
json_dict['PathMapped'] = path_mapped
json_dict['PathOrToken'] = path_shared_map
json_dict['Permissions'] = "r"
json_dict['Enabled'] = True
json_dict['Hidden'] = False
_, 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 by user")
json_dict = {}
json_dict['User'] = "user"
json_dict['PathMapped'] = path_mapped
json_dict['PathOrToken'] = path_shared_map
_, headers, answer = self._sharing_api_json("map", "enable", check=200, login="user:userpw", json_dict=json_dict)
# unhide map by user
logging.info("\n*** unhide map by user")
json_dict = {}
json_dict['User'] = "user"
json_dict['PathMapped'] = path_mapped
json_dict['PathOrToken'] = path_shared_map
_, headers, answer = self._sharing_api_json("map", "unhide", check=200, login="user:userpw", json_dict=json_dict)
# check PROPFIND item as user
logging.info("\n*** PROPFIND all as user")
_, responses = self.propfind(path_user, """\
<?xml version="1.0"?>
<propfind xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:CR="urn:ietf:params:xml:ns:carddav" xmlns:CS="http://calendarserver.org/ns/" xmlns:ICAL="http://apple.com/ns/ical/" xmlns:RADICALE="http://radicale.org/ns/" xmlns:ns3="http://inf-it.com/ns/ab/">
<prop>
<resourcetype />
<RADICALE:displayname />
<ICAL:calendar-color />
<ns3:addressbook-color />
<C:calendar-description />
<C:supported-calendar-component-set />
<CR:addressbook-description />
<CS:source />
<RADICALE:getcontentcount />
<getcontentlength />
</prop>
</propfind>""", login="user:userpw", HTTP_DEPTH="1")
# logging.debug("responses: %r", responses)
response = responses[path_shared_map]
assert not isinstance(response, int)
logging.debug("response %r: %r", path_shared_map, response)
assert "C:supported-calendar-component-set" in response
response = responses[path_shared_bday]
assert not isinstance(response, int)
logging.debug("response %r: %r", path_shared_bday, response)
assert "C:supported-calendar-component-set" in response
# check PROPFIND item as user
logging.info("\n*** PROPFIND all as user (reduced)")
_, responses = self.propfind(path_user, """\
<?xml version="1.0"?>
<propfind xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:CR="urn:ietf:params:xml:ns:carddav" xmlns:CS="http://calendarserver.org/ns/" xmlns:ICAL="http://apple.com/ns/ical/" xmlns:RADICALE="http://radicale.org/ns/" xmlns:ns3="http://inf-it.com/ns/ab/">
<prop>
<resourcetype />
<C:calendar-description />
<CR:addressbook-description />
<RADICALE:getcontentcount />
<getcontentlength />
</prop>
</propfind>""", login="user:userpw", HTTP_DEPTH="1")
# logging.debug("responses: %r", responses)
response = responses[path_shared_bday]
assert not isinstance(response, int)
logging.debug("response %r: %r", path_shared_bday, response)
assert "D:getcontentlength" in response
assert "RADICALE:getcontentcount" in response
status, prop = response["D:getcontentlength"]
assert int(str(prop.text)) > 600
status, prop = response["RADICALE:getcontentcount"]
assert int(str(prop.text)) == 2
def test_sharing_api_bday_self(self) -> None:
"""share-by-bday to self tests."""
self.configure({"auth": {"type": "htpasswd",
"htpasswd_filename": self.htpasswd_file_path,
"htpasswd_encryption": "plain"},
"sharing": {
"type": "csv",
"permit_create_bday": True,
"permit_properties_overlay": "True",
"enforce_properties_overlay": "True",
"collection_by_bday": "True"},
"logging": {"request_header_on_debug": "False",
"response_content_on_debug": "True",
"request_content_on_debug": "True"},
"rights": {"type": "owner_only"}})
json_dict: dict
logging.info("\n*** prepare and test access")
path_owner = "/owner/"
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 = path_owner + "abook-" + db_type + ".vcf/"
path_shared = path_owner + "cal-bday-abook-" + db_type + ".ics/"
self.create_addressbook(path_mapped, login="owner:ownerpw")
contact = get_file_content("contact1.vcf")
path = path_mapped + "/contact1.vcf"
self.put(path, contact, login="owner:ownerpw")
contact = get_file_content("contact2-with-bday.vcf")
path = path_mapped + "/contact2-with-bday.vcf"
self.put(path, contact, login="owner:ownerpw")
contact = get_file_content("contact3-with-bday.vcf")
path = path_mapped + "/contact3-with-bday.vcf"
self.put(path, contact, login="owner:ownerpw")
# check PROPFIND as owner
logging.info("\n*** PROPFIND collection owner -> ok")
_, responses = self.propfind(path_mapped, """\
<?xml version="1.0" encoding="utf-8"?>
<propfind xmlns="DAV:">
<propname />
</propfind>""", login="owner:ownerpw")
logging.info("response: %r", responses)
response = responses[path_mapped]
assert not isinstance(response, int)
assert "CR:supported-address-data" in response
# execute GET as owner
logging.info("\n*** GET VCF collection owner -> ok")
_, answer = self.get(path_mapped, login="owner:ownerpw")
assert "contact1" in answer
assert "contact2" in answer
assert "NICKNAME-C3" in answer
# create map
logging.info("\n*** create bday owner to itself -> ok")
json_dict = {}
json_dict['User'] = "owner"
json_dict['PathMapped'] = path_mapped
json_dict['PathOrToken'] = path_shared
json_dict['Enabled'] = False
json_dict['Hidden'] = True
_, headers, answer = self._sharing_api_json("bday", "create", check=200, login="owner:ownerpw", json_dict=json_dict)
# check PROPFIND item as owner
logging.info("\n*** PROPFIND all as owner")
_, responses = self.propfind(path_owner, """\
<?xml version="1.0"?>
<propfind xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:CR="urn:ietf:params:xml:ns:carddav" xmlns:CS="http://calendarserver.org/ns/" xmlns:ICAL="http://apple.com/ns/ical/" xmlns:RADICALE="http://radicale.org/ns/" xmlns:ns3="http://inf-it.com/ns/ab/">
<prop>
<resourcetype />
<RADICALE:displayname />
<ICAL:calendar-color />
<ns3:addressbook-color />
<C:calendar-description />
<C:supported-calendar-component-set />
<CR:addressbook-description />
<CS:source />
<RADICALE:getcontentcount />
<getcontentlength />
</prop>
</propfind>""", login="owner:ownerpw", HTTP_DEPTH="1")
# logging.debug("responses: %r", responses)
response = responses[path_mapped]
assert not isinstance(response, int)
logging.debug("response %r: %r", path_mapped, response)
assert "C:supported-calendar-component-set" in response
assert path_shared not in responses
# enable + unhide
logging.info("\n*** enable+unhide bday owner to itself -> ok")
json_dict = {}
json_dict['PathOrToken'] = path_shared
json_dict['Enabled'] = True
json_dict['Hidden'] = False
_, headers, answer = self._sharing_api_json("bday", "update", check=200, login="owner:ownerpw", json_dict=json_dict)
# check PROPFIND item as owner
logging.info("\n*** PROPFIND all as owner")
_, responses = self.propfind(path_owner, """\
<?xml version="1.0"?>
<propfind xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:CR="urn:ietf:params:xml:ns:carddav" xmlns:CS="http://calendarserver.org/ns/" xmlns:ICAL="http://apple.com/ns/ical/" xmlns:RADICALE="http://radicale.org/ns/" xmlns:ns3="http://inf-it.com/ns/ab/">
<prop>
<resourcetype />
<RADICALE:displayname />
<ICAL:calendar-color />
<ns3:addressbook-color />
<C:calendar-description />
<C:supported-calendar-component-set />
<CR:addressbook-description />
<CS:source />
<RADICALE:getcontentcount />
<getcontentlength />
</prop>
</propfind>""", login="owner:ownerpw", HTTP_DEPTH="1")
# logging.debug("responses: %r", responses)
response = responses[path_mapped]
assert not isinstance(response, int)
logging.debug("response %r: %r", path_mapped, response)
assert "C:supported-calendar-component-set" in response
assert path_shared in responses
# check PROPFIND item as owner
logging.info("\n*** PROPFIND item as owner -> calendar")
response = self._propfind_allprop(path_shared, login="owner:ownerpw")
logging.debug("response: %r", response)
assert "CR:supported-address-data" not in response
assert "D:sync-token" not in response
assert "C:supported-calendar-component-set" in response
assert "D:current-user-privilege-set" in response