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

View File

@@ -2120,6 +2120,14 @@ Share collection by map
Default: `false` Default: `false`
##### collection_by_bday
_(>= 3.7.0)_
Share collection by bday (conversion on-the-fly)
Default: `false`
##### permit_create_token ##### permit_create_token
_(>= 3.7.0)_ _(>= 3.7.0)_
@@ -2142,6 +2150,17 @@ Default: `false`
* If `False` it can be explicitly granted by *rights* permissions: `M` * If `False` it can be explicitly granted by *rights* permissions: `M`
* If `True` it can be explicitly forbidden 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 ##### permit_properties_overlay
_(>= 3.7.0)_ _(>= 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)_ * **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:** 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)_ * **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 ### Storage

View File

@@ -122,11 +122,12 @@ Types of supported sharing configuration:
* `ShareType`: type of share * `ShareType`: type of share
* `token`: token-based share (do not require user authentication) * `token`: token-based share (do not require user authentication)
* `map`: map-based share (requires 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) * `PathOrToken`: token or "virtual" collection, has to be unique (PRIMARY KEY)
* `PathMapped`: target collection * `PathMapped`: target collection
* `Owner`: owner of the share * `Owner`: owner of the share
* `User`: user 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 * `EnabledByOwner`: control by owner
* `EnabledByUser`: control by user * `EnabledByUser`: control by user
* `HiddenByOwner`: control by owner * `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-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 #### Permission Control
* `permit_create_map` * `permit_create_map`
* supported *rights* permissions: `Mm` * supported *rights* permissions: `Mm`
* `permit_create_bday`
* supported *rights* permissions: `Bb`
#### Workflow #### Workflow
@@ -243,7 +249,7 @@ Shows what kind of ShareTypes are supported
* form->text * form->text
```bash ```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 ApiVersion=1
Status='success' Status='success'
FeatureEnabledCollectionByMap=True FeatureEnabledCollectionByMap=True
@@ -256,18 +262,20 @@ PermittedCreateCollectionByToken=True
``` ```
bash 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, "ApiVersion": 1,
"Status": "success", "Status": "success",
"FeatureEnabledCollectionByMap": true, "FeatureEnabledCollectionByMap": true,
"PermittedCreateCollectionByMap": true, "PermittedCreateCollectionByMap": true,
"FeatureEnabledCollectionByToken": true, "FeatureEnabledCollectionByToken": true,
"PermittedCreateCollectionByToken": true "PermittedCreateCollectionByToken": true,
"FeatureEnabledCollectionByBday": true,
"PermittedCreateCollectionByBday": true
} }
``` ```
##### API Hook "(token|map)/create" ##### API Hook "(token|map|bday)/create"
* Authorization * Authorization
* Authenticated user is `Owner` * Authenticated user is `Owner`
@@ -303,7 +311,7 @@ Create a share by mapping a collection of an `Owner` to a token.
* form->text * form->text
```bash ```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 ApiVersion=1
Status='success' Status='success'
PathOrToken='v1/VQR7AmsVRi2ZlFj_JwGpFx-ES5Goyku-gP_YkLh1zUw=' PathOrToken='v1/VQR7AmsVRi2ZlFj_JwGpFx-ES5Goyku-gP_YkLh1zUw='
@@ -312,11 +320,11 @@ PathOrToken='v1/VQR7AmsVRi2ZlFj_JwGpFx-ES5Goyku-gP_YkLh1zUw='
* json->json * json->json
```bash ```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="} {"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`. 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` * `PathMapped` is not existing already as a share target for same `User`
* Authenticated user as `Owner` has at least read access to `PathMapped` * Authenticated user as `Owner` has at least read access to `PathMapped`
* Provided `User` has at least read access to `PathOrToken` * Provided `User` has at least read access to `PathOrToken`
* *map*
* Global permitted by `permit_create_map = True` or `rights` permission `m` * Global permitted by `permit_create_map = True` or `rights` permission `m`
* Global denied by `permit_create_map = False` 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 * Input
@@ -346,7 +359,7 @@ Create a share by mapping a collection of an `Owner` to an `User`.
* form->text * form->text
```bash ```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 ApiVersion=1
Status='success' Status='success'
``` ```
@@ -354,11 +367,11 @@ Status='success'
* json->json * json->json
```bash ```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"} {"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. 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") * form->text ("all")
```bash ```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 ApiVersion=1
Lines=1 Lines=1
Status='success' Status='success'
@@ -390,7 +403,7 @@ Content[0]="map;/user/cal1-from-owner/;/owner/testcalendar1/;owner;user;r;True;T
* form->csv ("map" only) * form->csv ("map" only)
```bash ```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 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; 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` * json->json ("all"), parsed with `jq`
```bash ```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, "ApiVersion": 1,
"Lines": 2, "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`. Delete a share selected by `PathOrToken`.
@@ -460,7 +473,7 @@ Delete a share selected by `PathOrToken`.
* form->text * form->text
```bash ```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 ApiVersion=1
Status='success' Status='success'
``` ```
@@ -468,11 +481,11 @@ Status='success'
* json->json * json->json
```bash ```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"} {"ApiVersion": 1, "Status": "success"}
``` ```
##### API Hook "(token|map)/update" ##### API Hook "(token|map|bday)/update"
Update a share selected by `PathOrToken`. Update a share selected by `PathOrToken`.
@@ -500,7 +513,7 @@ Execute delete+create in case `PathOrToken` needs to be changed.
* form->text * form->text
```bash ```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 ApiVersion=1
Status='success' Status='success'
``` ```
@@ -508,11 +521,11 @@ Status='success'
* json->json * json->json
```bash ```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"} {"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` 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 * form->text
```bash ```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 ApiVersion=1
Status='success' Status='success'
```bash ```bash
@@ -539,7 +552,7 @@ Status='success'
* json->json * json->json
```bash ```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"} {"ApiVersion": 1, "Status": "success"}
``` ```
@@ -595,32 +608,66 @@ xml_ppc='<?xml version="1.0"?>
```bash ```bash
## Retrieve collection color of owner (no color set) ## 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 ## 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 ## 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) ## 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 ## 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) ## 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 ## 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) ## 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 ## 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) ## 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 # Share collection by map
#collection_by_map = false #collection_by_map = false
# Share collection by bday (conversion on-the-fly)
#collection_by_bday = false
# Permit create of token-based sharing # Permit create of token-based sharing
# If False it can be explicitly granted by permissions: t # If False it can be explicitly granted by permissions: t
# If True it can be explicitly forbidden 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 # If True it can be explicitly forbidden by permissions: M
#permit_create_map = false #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 # Permit properties overlay
# If False it can be explicitly granted by share permissions: P # If False it can be explicitly granted by share permissions: P
# If True it can be explicitly forbidden 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} "ETag": item.etag}
if content_disposition: if content_disposition:
headers["Content-Disposition"] = content_disposition headers["Content-Disposition"] = content_disposition
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() answer = item.serialize()
return client.OK, headers, answer, None 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) logger.debug("TRACE/PROPFIND/xml_propfind: sharetype=%r item.path=%r", sharetype, uri)
for entry in shares: for entry in shares:
if logger.isEnabledFor(logging.DEBUG): 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 entry is not None:
if shares[entry]['PathMapped'] == uri: if logger.isEnabledFor(logging.DEBUG):
if sharetype is None or shares[entry]['ShareType'] == sharetype: 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] share = shares[entry]
if logger.isEnabledFor(logging.DEBUG): 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 break
share_bday_automap = False
if share and share['ShareType'] == "bday":
share_bday_automap = True
if share: if share:
# backmap # backmap
uri = uri.replace(share['PathMapped'], share['PathOrToken']) 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) href.text = xmlutils.make_href(base_prefix, uri)
response.append(href) response.append(href)
@@ -155,12 +164,16 @@ def xml_propfind_response(
if is_collection: if is_collection:
if is_leaf: if is_leaf:
props.append(xmlutils.make_clark("D:displayname")) props.append(xmlutils.make_clark("D:displayname"))
if share_bday_automap:
# bday share has no sync-token support
pass
else:
props.append(xmlutils.make_clark("D:sync-token")) props.append(xmlutils.make_clark("D:sync-token"))
if collection.tag == "VCALENDAR": if collection.tag == "VCALENDAR" or share_bday_automap:
props.append(xmlutils.make_clark("CS:getctag")) props.append(xmlutils.make_clark("CS:getctag"))
props.append( props.append(
xmlutils.make_clark("C:supported-calendar-component-set")) 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("CS:getctag"))
props.append( props.append(
xmlutils.make_clark("CR:supported-address-data")) xmlutils.make_clark("CR:supported-address-data"))
@@ -205,6 +218,8 @@ def xml_propfind_response(
if share: if share:
# backmap # backmap
child_element.text = child_element.text.replace(share['PathMapped'], share['PathOrToken']) 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) element.append(child_element)
elif tag == xmlutils.make_clark("C:supported-calendar-component-set"): elif tag == xmlutils.make_clark("C:supported-calendar-component-set"):
human_tag = xmlutils.make_human_tag(tag) human_tag = xmlutils.make_human_tag(tag)
@@ -214,6 +229,9 @@ def xml_propfind_response(
components = components_text.split(",") components = components_text.split(",")
else: else:
components = ["VTODO", "VEVENT", "VJOURNAL"] components = ["VTODO", "VEVENT", "VJOURNAL"]
if share_bday_automap:
# enforce VEVENT-only
components = ["VEVENT"]
for component in components: for component in components:
comp = ET.Element(xmlutils.make_clark("C:comp")) comp = ET.Element(xmlutils.make_clark("C:comp"))
comp.set("name", component) comp.set("name", component)
@@ -221,7 +239,7 @@ def xml_propfind_response(
else: else:
is404 = True is404 = True
elif tag == xmlutils.make_clark("CR:supported-address-data"): 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 # Advertise supported vCard versions per RFC 6352 section 6.2.2
# vCard 4.0 requires vobject >= 1.0.0 # vCard 4.0 requires vobject >= 1.0.0
versions: Sequence[str] = (("4.0", "3.0") versions: Sequence[str] = (("4.0", "3.0")
@@ -243,6 +261,8 @@ def xml_propfind_response(
if share: if share:
# backmap # backmap
child_element.text = child_element.text.replace(share['Owner'], share['User']) 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) element.append(child_element)
else: else:
element.append(ET.Element( element.append(ET.Element(
@@ -254,6 +274,7 @@ def xml_propfind_response(
if "P" in share['Permissions']: if "P" in share['Permissions']:
privileges.append("D:write-properties") privileges.append("D:write-properties")
if "w" in share['Permissions']: if "w" in share['Permissions']:
if not share_bday_automap:
privileges.append("D:write-content") privileges.append("D:write-content")
elif write: elif write:
privileges.append("D:all") privileges.append("D:all")
@@ -271,11 +292,12 @@ def xml_propfind_response(
"D:principal-search-property-set", "D:principal-search-property-set",
"D:principal-property-search"] "D:principal-property-search"]
if is_collection and is_leaf: if is_collection and is_leaf:
if not share_bday_automap:
reports.append("D:sync-collection") reports.append("D:sync-collection")
if collection.tag == "VADDRESSBOOK": if collection.tag == "VADDRESSBOOK" and not share_bday_automap:
reports.append("CR:addressbook-multiget") reports.append("CR:addressbook-multiget")
reports.append("CR:addressbook-query") 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-multiget")
reports.append("C:calendar-query") reports.append("C:calendar-query")
for human_tag in reports: for human_tag in reports:
@@ -288,6 +310,17 @@ def xml_propfind_response(
element.append(supported_report) element.append(supported_report)
elif tag == xmlutils.make_clark("D:getcontentlength"): elif tag == xmlutils.make_clark("D:getcontentlength"):
if not is_collection or is_leaf: if not is_collection or is_leaf:
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))) element.text = str(len(item.serialize().encode(encoding)))
else: else:
is404 = True is404 = True
@@ -306,6 +339,9 @@ def xml_propfind_response(
if is_leaf: if is_leaf:
element.text = xmlutils.MIMETYPES[ element.text = xmlutils.MIMETYPES[
collection.tag] collection.tag]
if share_bday_automap:
# overwrite
element.text = xmlutils.MIMETYPES["VCALENDAR"]
else: else:
is404 = True is404 = True
elif tag == xmlutils.make_clark("D:resourcetype"): elif tag == xmlutils.make_clark("D:resourcetype"):
@@ -314,11 +350,11 @@ def xml_propfind_response(
xmlutils.make_clark("D:principal")) xmlutils.make_clark("D:principal"))
element.append(child_element) element.append(child_element)
if is_leaf: if is_leaf:
if collection.tag == "VADDRESSBOOK": if collection.tag == "VADDRESSBOOK" and not share_bday_automap:
child_element = ET.Element( child_element = ET.Element(
xmlutils.make_clark("CR:addressbook")) xmlutils.make_clark("CR:addressbook"))
element.append(child_element) element.append(child_element)
elif collection.tag == "VCALENDAR": elif collection.tag == "VCALENDAR" or share_bday_automap:
child_element = ET.Element( child_element = ET.Element(
xmlutils.make_clark("C:calendar")) xmlutils.make_clark("C:calendar"))
element.append(child_element) element.append(child_element)
@@ -335,11 +371,24 @@ def xml_propfind_response(
displayname = share['Properties']["D:displayname"] displayname = share['Properties']["D:displayname"]
if displayname is not None: if displayname is not None:
element.text = displayname element.text = displayname
if share_bday_automap:
element.text += " (BDAY)"
else: else:
is404 = True is404 = True
elif tag == xmlutils.make_clark("RADICALE:getcontentcount"): elif tag == xmlutils.make_clark("RADICALE:getcontentcount"):
# Only for internal use by the web interface # Only for internal use by the web interface
if isinstance(item, storage.BaseCollection) and not collection.is_principal: if isinstance(item, storage.BaseCollection) and not collection.is_principal:
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())) element.text = str(sum(1 for x in item.get_all()))
else: else:
is404 = True is404 = True
@@ -351,6 +400,8 @@ def xml_propfind_response(
displayname = collection.path displayname = collection.path
if displayname is not None: if displayname is not None:
element.text = displayname element.text = displayname
if share_bday_automap:
element.text += " (BDAY)"
else: else:
is404 = True is404 = True
elif tag == xmlutils.make_clark("CS:getctag"): elif tag == xmlutils.make_clark("CS:getctag"):
@@ -385,6 +436,9 @@ def xml_propfind_response(
elif tag == xmlutils.make_clark("D:getcontenttype"): elif tag == xmlutils.make_clark("D:getcontenttype"):
assert not isinstance(item, storage.BaseCollection) assert not isinstance(item, storage.BaseCollection)
element.text = xmlutils.get_content_type(item, encoding) 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"): elif tag == xmlutils.make_clark("D:resourcetype"):
# resourcetype must be returned empty for non-collection elements # resourcetype must be returned empty for non-collection elements
pass pass
@@ -491,6 +545,12 @@ class ApplicationPartPropfind(ApplicationBase):
# put item back # put item back
items_iter = itertools.chain([item], items_iter) items_iter = itertools.chain([item], items_iter)
for item, permission in list(self._collect_allowed_items(items_iter, user)): for item, permission in list(self._collect_allowed_items(items_iter, user)):
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)) allowed_items.append((item, permission, None))
if self._sharing._enabled: if self._sharing._enabled:
if http_depth == "1": if http_depth == "1":

View File

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

View File

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

View File

@@ -26,14 +26,15 @@ Module for address books and calendar entries (see ``Item``).
import binascii import binascii
import contextlib import contextlib
import datetime
import logging
import math import math
import os import os
import re import re
from datetime import datetime, timedelta
from hashlib import sha256 from hashlib import sha256
from itertools import chain from itertools import chain
from typing import (Any, Callable, List, MutableMapping, Optional, Sequence, from typing import (Any, Callable, List, MutableMapping, Optional, Sequence,
Tuple) Tuple, Union)
import vobject import vobject
@@ -42,6 +43,11 @@ from radicale import pathutils, utils
from radicale.item import filter as radicale_filter from radicale.item import filter as radicale_filter
from radicale.log import logger 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]: def read_components(s: str) -> List[vobject.base.Component]:
"""Wrapper for vobject.readComponents""" """Wrapper for vobject.readComponents"""
@@ -159,7 +165,7 @@ def check_and_sanitize_items(
# an event with DTEND and DURATION:PT0S # an event with DTEND and DURATION:PT0S
if (hasattr(component, "dtend") and if (hasattr(component, "dtend") and
hasattr(component, "duration") 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 " logger.debug("Quirks: Removing zero duration from %s in "
"object %r", component_name, component_uid) "object %r", component_name, component_uid)
del component.duration 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 return radicale_filter.TIMESTAMP_MIN, radicale_filter.TIMESTAMP_MAX
start = end = None 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: is_recurrence: bool) -> bool:
nonlocal start, end nonlocal start, end
if start is None or range_start < start: 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 end = range_end
return False return False
def infinity_fn(range_start: datetime) -> bool: def infinity_fn(range_start: datetime.datetime) -> bool:
nonlocal start, end nonlocal start, end
if start is None or range_start < start: if start is None or range_start < start:
start = range_start start = range_start
@@ -498,3 +504,114 @@ class Item:
self.time_range self.time_range
self.component_name self.component_name
self._vobject_item = orig_vobject_item 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) - 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: 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) - 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: Permissions only supported so far in share permissions:
- P: permit properties overlay in case permit_properties_overlay=False (>= 3.7.0) - 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", INTERNAL_TYPES: Sequence[str] = ("authenticated", "owner_write", "owner_only",
"from_file") "from_file")
INTERNAL_PERMISSIONS: str = "RriWwDdOoTtMmPpEe" INTERNAL_PERMISSIONS: str = "RriWwDdOoTtMmPpEeBb"
def load(configuration: "config.Configuration") -> "BaseRights": 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') 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) # token: share by secret token (does not require authentication)
# map : share by mapping collection of one user to another as virtual # 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" # all : only supported for "list" and "info"
API_HOOKS_V1: Sequence[str] = ('list', 'create', 'delete', 'update', 'hide', 'unhide', 'enable', 'disable', '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, "Lines": int,
"FeatureEnabledCollectionByMap": bool, "FeatureEnabledCollectionByMap": bool,
"FeatureEnabledCollectionByToken": bool, "FeatureEnabledCollectionByToken": bool,
"FeatureEnabledCollectionByBday": bool,
"PermittedCreateCollectionByMap": bool, "PermittedCreateCollectionByMap": bool,
"PermittedCreateCollectionByToken": bool, "PermittedCreateCollectionByToken": bool,
"PermittedCreateCollectionByBday": bool,
"ShareType": str, "ShareType": str,
"PathOrToken": str, "PathOrToken": str,
"PathMapped": str, "PathMapped": str,
@@ -142,16 +145,20 @@ class BaseSharing:
# Sharing # Sharing
self.sharing_collection_by_map = configuration.get("sharing", "collection_by_map") 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_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_token = configuration.get("sharing", "permit_create_token")
self.permit_create_map = configuration.get("sharing", "permit_create_map") 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_token = configuration.get("sharing", "default_permissions_create_token")
self.default_permissions_create_map = configuration.get("sharing", "default_permissions_create_map") self.default_permissions_create_map = configuration.get("sharing", "default_permissions_create_map")
self.permit_properties_overlay = configuration.get("sharing", "permit_properties_overlay") self.permit_properties_overlay = configuration.get("sharing", "permit_properties_overlay")
self.enforce_properties_overlay = configuration.get("sharing", "enforce_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_map : %s", self.sharing_collection_by_map)
logger.info("sharing.collection_by_token: %s", self.sharing_collection_by_token) 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_token: %s", self.permit_create_token)
logger.info("sharing.permit_create_map : %s", self.permit_create_map) 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_token: %r", self.default_permissions_create_token)
logger.info("sharing.default_permissions_create_map : %r", self.default_permissions_create_map) 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.permit_properties_overlay: %s", self.permit_properties_overlay)
@@ -161,7 +168,7 @@ class BaseSharing:
self.sharing_db_type = configuration.get("sharing", "type") self.sharing_db_type = configuration.get("sharing", "type")
logger.info("sharing.database_type: %s", self.sharing_db_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") logger.info("sharing disabled as no feature is enabled")
self._enabled = False self._enabled = False
return return
@@ -341,6 +348,20 @@ class BaseSharing:
HiddenByOwner=Hidden, HiddenByOwner=Hidden,
HiddenByUser=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 return sharing_collection_list
# resolves a path to a share # resolves a path to a share
@@ -366,6 +387,13 @@ class BaseSharing:
if logger.isEnabledFor(logging.DEBUG): if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/sharing/map: not active") 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 share is not None:
if self.permit_properties_overlay: if self.permit_properties_overlay:
if share['Permissions'] and "p" not in share['Permissions']: if share['Permissions'] and "p" not in share['Permissions']:
@@ -466,6 +494,43 @@ class BaseSharing:
logger.debug("TRACE/sharing/map: not active") logger.debug("TRACE/sharing/map: not active")
return None 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 *** # *** POST API ***
def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: str) -> types.WSGIResponse: def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: str) -> types.WSGIResponse:
# Late import to avoid circular dependency in config # Late import to avoid circular dependency in config
@@ -480,28 +545,28 @@ class BaseSharing:
``user`` is empty for anonymous users. ``user`` is empty for anonymous users.
Request: Request:
action: (token|map/list action: (token|map|bday)/list
PathOrToken: <path|token> (optional for filter) PathOrToken: <path|token> (optional for filter)
action: (token|map)/create action: (token|map|bday)/create
PathMapped: <path> (mandatory) PathMapped: <path> (mandatory)
Permissions: <Permissions> (default: r) Permissions: <Permissions> (default: r)
token -> returns <token> token -> returns <token>
map map|bday
PathOrToken: <path> (mandatory) PathOrToken: <path> (mandatory)
User: <target_user> (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) PathOrToken: <path|token> (mandatory)
token token
map map|bday
PathMapped: <path> (mandator) PathMapped: <path> (mandatory)
User: <target_user> User: <target_user>
Response: output format depending on ACCEPT header Response: output format depending on ACCEPT header
@@ -561,6 +626,11 @@ class BaseSharing:
logger.warning(api_info + ": not enabled by config (collection_by_map)") logger.warning(api_info + ": not enabled by config (collection_by_map)")
return httputils.NOT_FOUND 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 # check for valid API hooks
if action not in API_HOOKS_V1: if action not in API_HOOKS_V1:
if logger.isEnabledFor(logging.DEBUG): if logger.isEnabledFor(logging.DEBUG):
@@ -756,7 +826,7 @@ class BaseSharing:
answer['ApiVersion'] = 1 answer['ApiVersion'] = 1
Timestamp = int((datetime.now() - datetime(1970, 1, 1)).total_seconds()) 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': if not action == 'info':
# API is not enabled # API is not enabled
logger.warning(api_info + ": API is not enabled") logger.warning(api_info + ": API is not enabled")
@@ -881,7 +951,7 @@ class BaseSharing:
if logger.isEnabledFor(logging.DEBUG): if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/" + api_info + ": result=%r", result) logger.debug("TRACE/" + api_info + ": result=%r", result)
elif ShareType == "map": elif ShareType in ["map", "bday"]:
# check preconditions # check preconditions
if PathOrToken is None: if PathOrToken is None:
return httputils.bad_request("Missing PathOrToken") return httputils.bad_request("Missing PathOrToken")
@@ -911,6 +981,7 @@ class BaseSharing:
logger.warning(api_info + ": access to PathMapped=%r not allowed for owner %r", PathMapped, user) logger.warning(api_info + ": access to PathMapped=%r not allowed for owner %r", PathMapped, user)
return httputils.NOT_ALLOWED return httputils.NOT_ALLOWED
if ShareType == "map":
if self.permit_create_map is False: if self.permit_create_map is False:
if "m" not in access.permissions: 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) logger.warning(api_info + ": access to PathMapped=%r not allowed for owner %r (permit=False but explicit grant misses 'm')", PathMapped, user)
@@ -920,6 +991,16 @@ class BaseSharing:
logger.warning(api_info + ": access to PathMapped=%r not allowed for owner %r (permit=True but denied by 'M')", PathMapped, user) 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 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) access = Access(self._rights, User, PathOrToken)
if not access.check("r"): if not access.check("r"):
logger.warning(api_info + ": access to PathOrToken=%r not allowed for User=%r", PathOrToken, User) logger.warning(api_info + ": access to PathOrToken=%r not allowed for User=%r", PathOrToken, User)
@@ -978,7 +1059,7 @@ class BaseSharing:
if logger.isEnabledFor(logging.DEBUG): if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/" + api_info + ": start") 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) logger.warning(api_info + ": unsupported for ShareType=%r", ShareType)
return httputils.bad_request("Invalid share type") return httputils.bad_request("Invalid share type")
@@ -1023,6 +1104,22 @@ class BaseSharing:
logger.warning(api_info + ": access to %r not allowed for user %r", PathMapped, user) logger.warning(api_info + ": access to %r not allowed for user %r", PathMapped, user)
return httputils.NOT_ALLOWED return httputils.NOT_ALLOWED
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( result = self.database_update_sharing(
ShareType=ShareType, ShareType=ShareType,
PathMapped=PathMapped, PathMapped=PathMapped,
@@ -1088,7 +1185,7 @@ class BaseSharing:
if logger.isEnabledFor(logging.DEBUG): if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/" + api_info + ": start") 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) logger.warning(api_info + ": unsupported for ShareType=%r", ShareType)
return httputils.bad_request("Invalid share type") return httputils.bad_request("Invalid share type")
@@ -1133,13 +1230,16 @@ class BaseSharing:
if ShareType in ["all", "token"]: if ShareType in ["all", "token"]:
answer['FeatureEnabledCollectionByToken'] = self.sharing_collection_by_token answer['FeatureEnabledCollectionByToken'] = self.sharing_collection_by_token
answer['PermittedCreateCollectionByToken'] = self.permit_create_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 # action: TOGGLE
elif action in API_SHARE_TOGGLES_V1: elif action in API_SHARE_TOGGLES_V1:
if logger.isEnabledFor(logging.DEBUG): if logger.isEnabledFor(logging.DEBUG):
logger.debug("TRACE/sharing/API/POST/" + action) 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) logger.warning(api_info + ": unsupported for ShareType=%r", ShareType)
return httputils.bad_request("Invalid share type") return httputils.bad_request("Invalid share type")

View File

@@ -241,6 +241,17 @@ class Sharing(sharing.BaseSharing):
# must be unique systemwide # must be unique systemwide
logger.error("sharing/map/create: entry already exists: PathMapped=%r User=%r", PathMapped, User) logger.error("sharing/map/create: entry already exists: PathMapped=%r User=%r", PathMapped, User)
return {"status": "conflict"} 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: else:
return {"status": "error"} 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 json
import logging
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from hashlib import sha256 from hashlib import sha256
from typing import (Callable, ContextManager, Dict, Iterable, Iterator, List, 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.""" """Get the HTTP-datetime of when the collection was modified."""
raise NotImplementedError raise NotImplementedError
def serialize(self) -> str: def serialize(self, vcf_to_ics: bool = False) -> str:
"""Get the unicode string representing the whole collection.""" """Get the unicode string representing the whole collection."""
if self.tag == "VCALENDAR": if self.tag == "VCALENDAR":
in_vcalendar = False in_vcalendar = False
@@ -282,6 +283,22 @@ class BaseCollection:
vtimezones + components + vtimezones + components +
template[template_insert_pos:]) template[template_insert_pos:])
if self.tag == "VADDRESSBOOK": if self.tag == "VADDRESSBOOK":
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 "".join((item.serialize() for item in self.get_all()))
return "" 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"}, "htpasswd_encryption": "plain"},
"sharing": { "sharing": {
"collection_by_map": "True", "collection_by_map": "True",
"collection_by_bday": "True",
"collection_by_token": "True"}, "collection_by_token": "True"},
"rights": {"type": "owner_only"}}) "rights": {"type": "owner_only"}})
@@ -333,13 +334,16 @@ class TestSharingApiSanity(BaseTest):
# path with valid API and hook and all enabled # path with valid API and hook and all enabled
self.configure({"sharing": { self.configure({"sharing": {
"collection_by_map": "True", "collection_by_map": "True",
"collection_by_bday": "True",
"collection_by_token": "True"} "collection_by_token": "True"}
}) })
for sharetype in sharing.SHARE_TYPES: for sharetype in sharing.SHARE_TYPES:
path = "/.sharing/v1/" + sharetype + "/" + action path = "/.sharing/v1/" + sharetype + "/" + action
# invalid API # invalid API
logging.info("\n*** check hook -> 404 (invalid)")
_, headers, _ = self.request("POST", path + "NA", check=404, login="owner:ownerpw") _, headers, _ = self.request("POST", path + "NA", check=404, login="owner:ownerpw")
# valid API # valid API
logging.info("\n*** check hook -> 400 (valid but no data)")
_, headers, _ = self.request("POST", path, check=400, login="owner:ownerpw") _, headers, _ = self.request("POST", path, check=400, login="owner:ownerpw")
logging.info("\n*** check API hook: info/token -> 200") logging.info("\n*** check API hook: info/token -> 200")
@@ -353,8 +357,10 @@ class TestSharingApiSanity(BaseTest):
# When turning on permission to create # When turning on permission to create
self.configure({"sharing": { self.configure({"sharing": {
"collection_by_map": "True", "collection_by_map": "True",
"collection_by_bday": "True",
"collection_by_token": "True", "collection_by_token": "True",
"permit_create_map": "True", "permit_create_map": "True",
"permit_create_bday": "True",
"permit_create_token": "True"} "permit_create_token": "True"}
}) })
logging.info("\n*** check API hook: info/all") logging.info("\n*** check API hook: info/all")
@@ -363,8 +369,10 @@ class TestSharingApiSanity(BaseTest):
answer_dict = json.loads(answer) answer_dict = json.loads(answer)
assert answer_dict['FeatureEnabledCollectionByMap'] is True, f'FeatureEnabledCollectionByMap {db_type}' assert answer_dict['FeatureEnabledCollectionByMap'] is True, f'FeatureEnabledCollectionByMap {db_type}'
assert answer_dict['FeatureEnabledCollectionByToken'] is True, f'FeatureEnabledCollectionByToken {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['PermittedCreateCollectionByMap'] is True, f'PermittedCreateCollectionByMap {db_type}'
assert answer_dict['PermittedCreateCollectionByToken'] is True, f'PermittedCreateCollectionByToken {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: def test_sharing_api_list_with_auth(self) -> None:
"""POST/list with authentication.""" """POST/list with authentication."""
@@ -374,8 +382,10 @@ class TestSharingApiSanity(BaseTest):
"sharing": { "sharing": {
"type": "csv", "type": "csv",
"permit_create_map": True, "permit_create_map": True,
"permit_create_bday": True,
"permit_create_token": True, "permit_create_token": True,
"collection_by_map": "True", "collection_by_map": "True",
"collection_by_bday": "True",
"collection_by_token": "True"}, "collection_by_token": "True"},
"logging": {"request_header_on_debug": "true", "logging": {"request_header_on_debug": "true",
"request_content_on_debug": "True"}, "request_content_on_debug": "True"},
@@ -478,6 +488,7 @@ class TestSharingApiSanity(BaseTest):
"permit_create_map": True, "permit_create_map": True,
"permit_create_token": True, "permit_create_token": True,
"collection_by_map": "True", "collection_by_map": "True",
"collection_by_bday": "True",
"collection_by_token": "True"}, "collection_by_token": "True"},
"logging": {"request_header_on_debug": "False", "logging": {"request_header_on_debug": "False",
"response_content_on_debug": "True", "response_content_on_debug": "True",
@@ -4170,3 +4181,493 @@ permissions: RrWw""")
assert answer_dict['Lines'] == 1 assert answer_dict['Lines'] == 1
assert 'ICAL:calendar-color' not in answer_dict['Content'][0]['Properties'] assert 'ICAL:calendar-color' not in answer_dict['Content'][0]['Properties']
assert 'C:calendar-description' 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