45
.github/workflows/test.yml
vendored
45
.github/workflows/test.yml
vendored
@@ -12,8 +12,8 @@ jobs:
|
||||
python-version: ['3.14']
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Install Test dependencies
|
||||
@@ -32,7 +32,6 @@ jobs:
|
||||
container:
|
||||
image: i386/python:${{ matrix.python-version }}
|
||||
volumes:
|
||||
- /home/runner/work/_temp:/__e/node20
|
||||
- /home/runner/work/_temp:/__e/node24
|
||||
steps:
|
||||
- name: Update system
|
||||
@@ -41,10 +40,6 @@ jobs:
|
||||
- name: Fix checkout and upload-artifact
|
||||
run: |
|
||||
apt-get install -y nodejs npm
|
||||
mkdir -p /__e/node20/bin
|
||||
ln -sf $(which node) /__e/node20/bin/node
|
||||
ln -sf $(which npm) /__e/node20/bin/npm
|
||||
ln -sf $(which npx) /__e/node20/bin/npx
|
||||
mkdir -p /__e/node24/bin
|
||||
ln -sf $(which node) /__e/node24/bin/node
|
||||
ln -sf $(which npm) /__e/node24/bin/npm
|
||||
@@ -52,8 +47,8 @@ jobs:
|
||||
- name: Install git
|
||||
run: |
|
||||
apt-get install -y git
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-python@v6
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
apt-get install -y tox rust-all cargo
|
||||
@@ -76,8 +71,8 @@ jobs:
|
||||
python-version: ['3.9']
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Install Test dependencies
|
||||
@@ -94,8 +89,8 @@ jobs:
|
||||
python-version: ['3.14']
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Install Test dependencies
|
||||
@@ -112,8 +107,8 @@ jobs:
|
||||
python-version: ['3.9']
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Install Test dependencies
|
||||
@@ -137,8 +132,8 @@ jobs:
|
||||
python-version: 'pypy-3.11'
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Install Test dependencies
|
||||
@@ -152,8 +147,8 @@ jobs:
|
||||
if: github.event_name == 'push'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.14'
|
||||
- name: Install Test dependencies
|
||||
@@ -191,8 +186,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
needs: test-ubuntu-python-newest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
- name: JS Type Check
|
||||
@@ -213,8 +208,8 @@ jobs:
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.14'
|
||||
- name: Install tox
|
||||
@@ -227,8 +222,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
needs: test-ubuntu-python-newest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.12'
|
||||
- name: Install uv
|
||||
|
||||
@@ -2120,6 +2120,14 @@ Share collection by map
|
||||
|
||||
Default: `false`
|
||||
|
||||
##### collection_by_bday
|
||||
|
||||
_(>= 3.7.0)_
|
||||
|
||||
Share collection by bday (conversion on-the-fly)
|
||||
|
||||
Default: `false`
|
||||
|
||||
##### permit_create_token
|
||||
|
||||
_(>= 3.7.0)_
|
||||
@@ -2142,6 +2150,17 @@ Default: `false`
|
||||
* If `False` it can be explicitly granted by *rights* permissions: `M`
|
||||
* If `True` it can be explicitly forbidden by *rights* permissions: `m`
|
||||
|
||||
##### permit_create_bday
|
||||
|
||||
_(>= 3.7.0)_
|
||||
|
||||
Permit create of bday-based sharing
|
||||
|
||||
Default: `false`
|
||||
|
||||
* If `False` it can be explicitly granted by *rights* permissions: `B`
|
||||
* If `True` it can be explicitly forbidden by *rights* permissions: `b`
|
||||
|
||||
##### permit_properties_overlay
|
||||
|
||||
_(>= 3.7.0)_
|
||||
@@ -2408,6 +2427,8 @@ The following `permissions` are recognized:
|
||||
* **t:** deny create of token-based sharing of collection in case `permit_create_token=True` _(>= 3.7.0)_
|
||||
* **M:** permit create of map-based sharing of collection in case `permit_create_map= False` _(>= 3.7.0)_
|
||||
* **m:** deny create of map-based sharing of collection in case `permit_create_map=True` _(>= 3.7.0)_
|
||||
* **B:** permit create of bday-based sharing of addressbook collection in case `permit_create_bday= False` _(>= 3.7.0)_
|
||||
* **b:** deny create of bday-based sharing of addressbook collection in case `permit_create_bday=True` _(>= 3.7.0)_
|
||||
|
||||
### Storage
|
||||
|
||||
|
||||
117
SHARING.md
117
SHARING.md
@@ -122,11 +122,12 @@ Types of supported sharing configuration:
|
||||
* `ShareType`: type of share
|
||||
* `token`: token-based share (do not require user authentication)
|
||||
* `map`: map-based share (requires user authentication)
|
||||
* `bday`: map-based share (requires user authentication) with on-the-fly auto-conversion
|
||||
* `PathOrToken`: token or "virtual" collection, has to be unique (PRIMARY KEY)
|
||||
* `PathMapped`: target collection
|
||||
* `Owner`: owner of the share
|
||||
* `User`: user of the share
|
||||
* `Permissions`: effective permission of the share
|
||||
* `Permissions`: effective permission of the share (*bday* is always read-only)
|
||||
* `EnabledByOwner`: control by owner
|
||||
* `EnabledByUser`: control by user
|
||||
* `HiddenByOwner`: control by owner
|
||||
@@ -157,10 +158,15 @@ File-based configuration store is using encoded `PathOrToken` as filename for ea
|
||||
|
||||
Map-based sharing can be accessed as usual after authentication and authorization.
|
||||
|
||||
* *map* is a standard sharing of one collection to another user
|
||||
* *bday* is special sharing auto-mapping on-the-fly a VADDRESSBOOK to a VCALENDAR of all entries containing a `BDAY`
|
||||
|
||||
#### Permission Control
|
||||
|
||||
* `permit_create_map`
|
||||
* supported *rights* permissions: `Mm`
|
||||
* `permit_create_bday`
|
||||
* supported *rights* permissions: `Bb`
|
||||
|
||||
#### Workflow
|
||||
|
||||
@@ -243,7 +249,7 @@ Shows what kind of ShareTypes are supported
|
||||
* form->text
|
||||
|
||||
```bash
|
||||
curl -u user:pass -H "accept: text/plain" -d "" http://localhost:5232/.sharing/v1/all/info
|
||||
curl -u user:$userpw -H "accept: text/plain" -d "" http://localhost:5232/.sharing/v1/all/info
|
||||
ApiVersion=1
|
||||
Status='success'
|
||||
FeatureEnabledCollectionByMap=True
|
||||
@@ -256,18 +262,20 @@ PermittedCreateCollectionByToken=True
|
||||
|
||||
```
|
||||
bash
|
||||
curl -u user:pass --silent -H "accept: application/json" -d "" http://localhost:5232/.sharing/v1/all/info | jq
|
||||
curl -u user:$userpw --silent -H "accept: application/json" -d "" http://localhost:5232/.sharing/v1/all/info | jq
|
||||
{
|
||||
"ApiVersion": 1,
|
||||
"Status": "success",
|
||||
"FeatureEnabledCollectionByMap": true,
|
||||
"PermittedCreateCollectionByMap": true,
|
||||
"FeatureEnabledCollectionByToken": true,
|
||||
"PermittedCreateCollectionByToken": true
|
||||
"PermittedCreateCollectionByToken": true,
|
||||
"FeatureEnabledCollectionByBday": true,
|
||||
"PermittedCreateCollectionByBday": true
|
||||
}
|
||||
```
|
||||
|
||||
##### API Hook "(token|map)/create"
|
||||
##### API Hook "(token|map|bday)/create"
|
||||
|
||||
* Authorization
|
||||
* Authenticated user is `Owner`
|
||||
@@ -303,7 +311,7 @@ Create a share by mapping a collection of an `Owner` to a token.
|
||||
* form->text
|
||||
|
||||
```bash
|
||||
curl -u user:pass -d "PathMapped=/user/testcalendar1/" -d "Enabled=True" -d "Hidden=False" http://localhost:5232/.sharing/v1/token/create
|
||||
curl -u user:$userpw -d "PathMapped=/user/testcalendar1/" -d "Enabled=True" -d "Hidden=False" http://localhost:5232/.sharing/v1/token/create
|
||||
ApiVersion=1
|
||||
Status='success'
|
||||
PathOrToken='v1/VQR7AmsVRi2ZlFj_JwGpFx-ES5Goyku-gP_YkLh1zUw='
|
||||
@@ -312,11 +320,11 @@ PathOrToken='v1/VQR7AmsVRi2ZlFj_JwGpFx-ES5Goyku-gP_YkLh1zUw='
|
||||
* json->json
|
||||
|
||||
```bash
|
||||
curl -u user:pass -H "Content-Type: application/json" -d '{ "PathMapped": "/user/testcalendar1/", "Enabled": true, "Hidden": false}' http://localhost:5232/.sharing/v1/token/create
|
||||
curl -u user:$userpw -H "Content-Type: application/json" -d '{ "PathMapped": "/user/testcalendar1/", "Enabled": true, "Hidden": false}' http://localhost:5232/.sharing/v1/token/create
|
||||
{"ApiVersion": 1, "Status": "success", "PathOrToken": "v1/aMsmGqOsRwSH-2-6tEa8EMr4RMYzMU7WvPmjnp5qDnw="}
|
||||
```
|
||||
|
||||
###### API Hook "map/create"
|
||||
###### API Hook "(map|bday)/create"
|
||||
|
||||
Create a share by mapping a collection of an `Owner` to an `User`.
|
||||
|
||||
@@ -325,8 +333,13 @@ Create a share by mapping a collection of an `Owner` to an `User`.
|
||||
* `PathMapped` is not existing already as a share target for same `User`
|
||||
* Authenticated user as `Owner` has at least read access to `PathMapped`
|
||||
* Provided `User` has at least read access to `PathOrToken`
|
||||
* Global permitted by `permit_create_map = True` or `rights` permission `m`
|
||||
* Global denied by `permit_create_map = False` or `rights` permission `M`
|
||||
* *map*
|
||||
* Global permitted by `permit_create_map = True` or `rights` permission `m`
|
||||
* Global denied by `permit_create_map = False` or `rights` permission `M`
|
||||
* *bday*
|
||||
* Global permitted by `permit_create_map = True` or `rights` permission `b`
|
||||
* Global denied by `permit_create_map = False` or `rights` permission `B`
|
||||
* `PathMapped` is a VCALENDAR collection
|
||||
|
||||
* Input
|
||||
|
||||
@@ -346,7 +359,7 @@ Create a share by mapping a collection of an `Owner` to an `User`.
|
||||
* form->text
|
||||
|
||||
```bash
|
||||
curl -u owner:pass -d "PathOrToken=/user/cal1-from-owner/" -d "PathMapped=/owner/testcalendar1/" -d "User=user" -d "Enabled=True" -d "Hidden=False" http://localhost:5232/.sharing/v1/map/create
|
||||
curl -u owner:$ownerpw -d "PathOrToken=/user/cal1-from-owner/" -d "PathMapped=/owner/testcalendar1/" -d "User=user" -d "Enabled=True" -d "Hidden=False" http://localhost:5232/.sharing/v1/map/create
|
||||
ApiVersion=1
|
||||
Status='success'
|
||||
```
|
||||
@@ -354,11 +367,11 @@ Status='success'
|
||||
* json->json
|
||||
|
||||
```bash
|
||||
curl -u owner:pass -H "Content-Type: application/json" -d '{ "PathOrToken": "/user/cal1-from-owner/", "PathMapped": "/owner/testcalendar1/", "User" : "user", "Enabled": true, "Hidden": false}' http://localhost:5232/.sharing/v1/map/create
|
||||
curl -u owner:$ownerpw -H "Content-Type: application/json" -d '{ "PathOrToken": "/user/cal1-from-owner/", "PathMapped": "/owner/testcalendar1/", "User" : "user", "Enabled": true, "Hidden": false}' http://localhost:5232/.sharing/v1/map/create
|
||||
{"ApiVersion": 1, "Status": "success"}
|
||||
```
|
||||
|
||||
##### API Hook "(map|token|all)/list"
|
||||
##### API Hook "(all|token|map|bday)/list"
|
||||
|
||||
List shares (optional with filter) either owned or assigned as user.
|
||||
|
||||
@@ -379,7 +392,7 @@ List shares (optional with filter) either owned or assigned as user.
|
||||
* form->text ("all")
|
||||
|
||||
```bash
|
||||
curl -u user:pass -d "" http://localhost:5232/.sharing/v1/map/list://localhost:5232/.sharing/v1/map/list
|
||||
curl -u user:$userpw -d "" http://localhost:5232/.sharing/v1/map/list://localhost:5232/.sharing/v1/map/list
|
||||
ApiVersion=1
|
||||
Lines=1
|
||||
Status='success'
|
||||
@@ -390,7 +403,7 @@ Content[0]="map;/user/cal1-from-owner/;/owner/testcalendar1/;owner;user;r;True;T
|
||||
* form->csv ("map" only)
|
||||
|
||||
```bash
|
||||
curl -H "accept: text/csv" -u user:pass -d "" http://localhost:5232/.sharing/v1/map/list://localhost:5232/.sharing/v1/map/list
|
||||
curl -H "accept: text/csv" -u user:$userpw -d "" http://localhost:5232/.sharing/v1/map/list://localhost:5232/.sharing/v1/map/list
|
||||
ShareType;PathOrToken;PathMapped;Owner;User;Permissions;EnabledByOwner;EnabledByUser;HiddenByOwner;HiddenByUser;TimestampCreated;TimestampUpdated;Properties
|
||||
map;/user/cal1-from-owner/;/owner/testcalendar1/;owner;user;r;True;False;False;True;1772747277;1772747277;
|
||||
```
|
||||
@@ -398,7 +411,7 @@ map;/user/cal1-from-owner/;/owner/testcalendar1/;owner;user;r;True;False;False;T
|
||||
* json->json ("all"), parsed with `jq`
|
||||
|
||||
```bash
|
||||
curl -s -H "Content-Type: application/json" -u user:pass -d "{}" http://localhost:5232/.sharing/v1/all/list | jq
|
||||
curl -s -H "Content-Type: application/json" -u user:$userpw -d "{}" http://localhost:5232/.sharing/v1/all/list | jq
|
||||
{
|
||||
"ApiVersion": 1,
|
||||
"Lines": 2,
|
||||
@@ -439,7 +452,7 @@ curl -s -H "Content-Type: application/json" -u user:pass -d "{}" http://localhos
|
||||
```
|
||||
|
||||
|
||||
##### API Hook "(map|token)/delete"
|
||||
##### API Hook "(token|map|bday)/delete"
|
||||
|
||||
Delete a share selected by `PathOrToken`.
|
||||
|
||||
@@ -460,7 +473,7 @@ Delete a share selected by `PathOrToken`.
|
||||
* form->text
|
||||
|
||||
```bash
|
||||
curl -u owner:pass -d "PathOrToken=/user/cal1-from-owner/" http://localhost:5232/.sharing/v1/map/delete
|
||||
curl -u owner:$ownerpw -d "PathOrToken=/user/cal1-from-owner/" http://localhost:5232/.sharing/v1/map/delete
|
||||
ApiVersion=1
|
||||
Status='success'
|
||||
```
|
||||
@@ -468,11 +481,11 @@ Status='success'
|
||||
* json->json
|
||||
|
||||
```bash
|
||||
curl -u user:pass -H "Content-Type: application/json" -d '{ "PathOrToken": "v1/DUSl_J5rRlWx3fy8YRXpH22FFllplkOTpcSwfGtpvkc="}' http://localhost:5232/.sharing/v1/token/delete
|
||||
curl -u user:$userpw -H "Content-Type: application/json" -d '{ "PathOrToken": "v1/DUSl_J5rRlWx3fy8YRXpH22FFllplkOTpcSwfGtpvkc="}' http://localhost:5232/.sharing/v1/token/delete
|
||||
{"ApiVersion": 1, "Status": "success"}
|
||||
```
|
||||
|
||||
##### API Hook "(token|map)/update"
|
||||
##### API Hook "(token|map|bday)/update"
|
||||
|
||||
Update a share selected by `PathOrToken`.
|
||||
|
||||
@@ -500,7 +513,7 @@ Execute delete+create in case `PathOrToken` needs to be changed.
|
||||
* form->text
|
||||
|
||||
```bash
|
||||
curl -u user:pass -d "PathOrToken=/user/cal1-from-owner/" -d "Enabled=True" -d "Hidden=False" http://localhost:5232/.sharing/v1/map/update
|
||||
curl -u user:$userpw -d "PathOrToken=/user/cal1-from-owner/" -d "Enabled=True" -d "Hidden=False" http://localhost:5232/.sharing/v1/map/update
|
||||
ApiVersion=1
|
||||
Status='success'
|
||||
```
|
||||
@@ -508,11 +521,11 @@ Status='success'
|
||||
* json->json
|
||||
|
||||
```bash
|
||||
curl -u user:pass -H "Content-Type: application/json" -d '{ "PathOrToken": "/user/cal1-from-owner/", "Enabled": true, "Hidden": false}' http://localhost:5232/.sharing/v1/map/update
|
||||
curl -u user:$userpw -H "Content-Type: application/json" -d '{ "PathOrToken": "/user/cal1-from-owner/", "Enabled": true, "Hidden": false}' http://localhost:5232/.sharing/v1/map/update
|
||||
{"ApiVersion": 1, "Status": "success"}
|
||||
```
|
||||
|
||||
##### API Hooks "(map|token)/(enable|disable|hide|unhide)"
|
||||
##### API Hooks "(token|map|bday)/(enable|disable|hide|unhide)"
|
||||
|
||||
Toggle enable|disable|hide|unhide of `Owner` or `User` of a share selected by `PathOrToken`
|
||||
|
||||
@@ -531,7 +544,7 @@ Toggle enable|disable|hide|unhide of `Owner` or `User` of a share selected by `P
|
||||
* form->text
|
||||
|
||||
```bash
|
||||
curl -u user:pass -d "PathOrToken=/user/cal1-from-owner/" http://localhost:5232/.sharing/v1/map/enable
|
||||
curl -u user:$userpw -d "PathOrToken=/user/cal1-from-owner/" http://localhost:5232/.sharing/v1/map/enable
|
||||
ApiVersion=1
|
||||
Status='success'
|
||||
```bash
|
||||
@@ -539,7 +552,7 @@ Status='success'
|
||||
* json->json
|
||||
|
||||
```bash
|
||||
curl -u user:pass -H "Content-Type: application/json" -d '{ "PathOrToken": "/user/cal1-from-owner/"}' http://localhost:5232/.sharing/v1/map/unhide
|
||||
curl -u user:$userpw -H "Content-Type: application/json" -d '{ "PathOrToken": "/user/cal1-from-owner/"}' http://localhost:5232/.sharing/v1/map/unhide
|
||||
{"ApiVersion": 1, "Status": "success"}
|
||||
```
|
||||
|
||||
@@ -595,32 +608,66 @@ xml_ppc='<?xml version="1.0"?>
|
||||
|
||||
```bash
|
||||
## Retrieve collection color of owner (no color set)
|
||||
curl -u owner:pass -d "$xml_pfc" -X PROPFIND http://localhost:5232/owner/testcalendar1/
|
||||
curl -u owner:$ownerpw -d "$xml_pfc" -X PROPFIND http://localhost:5232/owner/testcalendar1/
|
||||
|
||||
## Create read-only share for user
|
||||
curl -u owner:pass -d "PathOrToken=/user/cal1-from-owner/" -d "PathMapped=/owner/testcalendar1/" -d "User=user" -d "Enabled=True" -d "Hidden=False" http://localhost:5232/.sharing/v1/map/create
|
||||
curl -u owner:$ownerpw -d "PathOrToken=/user/cal1-from-owner/" -d "PathMapped=/owner/testcalendar1/" -d "User=user" -d "Enabled=True" -d "Hidden=False" http://localhost:5232/.sharing/v1/map/create
|
||||
|
||||
## Accept (enable+unhide) share by user
|
||||
curl -u user:pass -d "PathOrToken=/user/cal1-from-owner/" -d "Enabled=True" -d "Hidden=False" http://localhost:5232/.sharing/v1/map/update
|
||||
curl -u user:$userpw -d "PathOrToken=/user/cal1-from-owner/" -d "Enabled=True" -d "Hidden=False" http://localhost:5232/.sharing/v1/map/update
|
||||
|
||||
## Retrieve collection color of share by user (no color set)
|
||||
curl -u user:pass -d "$xml_pfc" -X PROPFIND http://localhost:5232/user/cal1-from-owner/
|
||||
curl -u user:$userpw -d "$xml_pfc" -X PROPFIND http://localhost:5232/user/cal1-from-owner/
|
||||
|
||||
## Set property overlay by user
|
||||
curl -u user:pass -d "PathOrToken=/user/cal1-from-owner/" -d 'Properties="ICAL:calendar-color"="#CCCCCC"' http://localhost:5232/.sharing/v1/map/update
|
||||
curl -u user:$userpw -d "PathOrToken=/user/cal1-from-owner/" -d 'Properties="ICAL:calendar-color"="#CCCCCC"' http://localhost:5232/.sharing/v1/map/update
|
||||
|
||||
## Retrieve collection color of share by user (color set)
|
||||
curl -u user:pass -d "$xml_pfc" -X PROPFIND http://localhost:5232/user/cal1-from-owner/
|
||||
curl -u user:$userpw -d "$xml_pfc" -X PROPFIND http://localhost:5232/user/cal1-from-owner/
|
||||
|
||||
## Delete property overlay by user
|
||||
curl -u user:pass -d "PathOrToken=/user/cal1-from-owner/" -d 'Properties=' http://localhost:5232/.sharing/v1/map/update
|
||||
curl -u user:$userpw -d "PathOrToken=/user/cal1-from-owner/" -d 'Properties=' http://localhost:5232/.sharing/v1/map/update
|
||||
|
||||
## Retrieve collection color of share by user (no color set)
|
||||
curl -u user:pass -d "$xml_pfc" -X PROPFIND http://localhost:5232/user/cal1-from-owner/
|
||||
url -u user:$userpw -d "$xml_pfc" -X PROPFIND http://localhost:5232/user/cal1-from-owner/
|
||||
|
||||
## Add property overlay by user using PROPPATCH
|
||||
curl -u user:pass -d "$xml_ppc" -X PROPPATCH http://localhost:5232/user/cal1-from-owner/
|
||||
curl -u user:$userpw -d "$xml_ppc" -X PROPPATCH http://localhost:5232/user/cal1-from-owner/
|
||||
|
||||
## Retrieve collection color of share by user (color set)
|
||||
curl -u user:pass -d "$xml_pfc" -X PROPFIND http://localhost:5232/user/cal1-from-owner/
|
||||
curl -u user:$userpw -d "$xml_pfc" -X PROPFIND http://localhost:5232/user/cal1-from-owner/
|
||||
```
|
||||
|
||||
## Virtual "bday" collection
|
||||
|
||||
Owner can create for itself or for particular user a virtual bday collection from an existing addressbook.
|
||||
|
||||
|
||||
### Examples
|
||||
|
||||
Preconditions:
|
||||
|
||||
* Collection with type *adressbook* is existing
|
||||
* Config options enabled in section `sharing`:
|
||||
* `collection_by_bday`
|
||||
* `permit_create_bday`
|
||||
|
||||
#### Examples using API
|
||||
|
||||
* Create
|
||||
|
||||
```bash
|
||||
## Create sharing of type "bday"
|
||||
curl -u owner:$ownerpw -d "PathOrToken=/owner/bday-of-addressbook/" -d "PathMapped=/owner/addressbook/" -d "User=owner" http://localhost:5232/.sharing/v1/bday/create
|
||||
|
||||
|
||||
## Enable
|
||||
curl -u owner:$ownerpw -d "PathOrToken=/owner/bday-of-addressbook/" http://localhost:5232/.sharing/v1/bday/enable
|
||||
|
||||
## Unhide
|
||||
curl -u owner:$ownerpw -d "PathOrToken=/owner/bday-of-addressbook/" http://localhost:5232/.sharing/v1/bday/unhide
|
||||
```
|
||||
|
||||
* Check
|
||||
|
||||
Use e.g. WebUI, an additional (virtual) calendar collection appears
|
||||
|
||||
8
config
8
config
@@ -320,6 +320,9 @@
|
||||
# Share collection by map
|
||||
#collection_by_map = false
|
||||
|
||||
# Share collection by bday (conversion on-the-fly)
|
||||
#collection_by_bday = false
|
||||
|
||||
# Permit create of token-based sharing
|
||||
# If False it can be explicitly granted by permissions: t
|
||||
# If True it can be explicitly forbidden by permissions: T
|
||||
@@ -330,6 +333,11 @@
|
||||
# If True it can be explicitly forbidden by permissions: M
|
||||
#permit_create_map = false
|
||||
|
||||
# Permit create of bday-based sharing
|
||||
# If False it can be explicitly granted by permissions: b
|
||||
# If True it can be explicitly forbidden by permissions: B
|
||||
#permit_create_bday = false
|
||||
|
||||
# Permit properties overlay
|
||||
# If False it can be explicitly granted by share permissions: P
|
||||
# If True it can be explicitly forbidden by share permissions: p
|
||||
|
||||
@@ -117,5 +117,9 @@ class ApplicationPartGet(ApplicationBase):
|
||||
"ETag": item.etag}
|
||||
if content_disposition:
|
||||
headers["Content-Disposition"] = content_disposition
|
||||
answer = item.serialize()
|
||||
if isinstance(item, storage.BaseCollection) and self._sharing._enabled and share and share['ShareType'] == "bday":
|
||||
# convert VCF to ICS
|
||||
answer = item.serialize(vcf_to_ics=True)
|
||||
else:
|
||||
answer = item.serialize()
|
||||
return client.OK, headers, answer, None
|
||||
|
||||
@@ -112,18 +112,27 @@ def xml_propfind_response(
|
||||
logger.debug("TRACE/PROPFIND/xml_propfind: sharetype=%r item.path=%r", sharetype, uri)
|
||||
for entry in shares:
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("TRACE/PROPFIND/xml_propfind: entry=%r", entry)
|
||||
logger.debug("TRACE/PROPFIND/xml_propfind: check entry=%r", entry)
|
||||
if entry is not None:
|
||||
if shares[entry]['PathMapped'] == uri:
|
||||
if sharetype is None or shares[entry]['ShareType'] == sharetype:
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("TRACE/PROPFIND/xml_propfind: PathMapped=%r uri=%r", shares[entry]['PathMapped'], uri)
|
||||
if uri.startswith(shares[entry]['PathMapped']):
|
||||
if sharetype is not None and shares[entry]['ShareType'] == sharetype:
|
||||
share = shares[entry]
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("TRACE/PROPFIND/xml_propfind: share=%r", share)
|
||||
logger.debug("TRACE/PROPFIND/xml_propfind: found share=%r", share)
|
||||
break
|
||||
|
||||
share_bday_automap = False
|
||||
if share and share['ShareType'] == "bday":
|
||||
share_bday_automap = True
|
||||
|
||||
if share:
|
||||
# backmap
|
||||
uri = uri.replace(share['PathMapped'], share['PathOrToken'])
|
||||
if share_bday_automap and not uri.endswith("/"):
|
||||
uri = uri.rstrip(".vcf") + ".ics"
|
||||
|
||||
href.text = xmlutils.make_href(base_prefix, uri)
|
||||
response.append(href)
|
||||
|
||||
@@ -155,12 +164,16 @@ def xml_propfind_response(
|
||||
if is_collection:
|
||||
if is_leaf:
|
||||
props.append(xmlutils.make_clark("D:displayname"))
|
||||
props.append(xmlutils.make_clark("D:sync-token"))
|
||||
if collection.tag == "VCALENDAR":
|
||||
if share_bday_automap:
|
||||
# bday share has no sync-token support
|
||||
pass
|
||||
else:
|
||||
props.append(xmlutils.make_clark("D:sync-token"))
|
||||
if collection.tag == "VCALENDAR" or share_bday_automap:
|
||||
props.append(xmlutils.make_clark("CS:getctag"))
|
||||
props.append(
|
||||
xmlutils.make_clark("C:supported-calendar-component-set"))
|
||||
if collection.tag == "VADDRESSBOOK":
|
||||
if collection.tag == "VADDRESSBOOK" and not share_bday_automap:
|
||||
props.append(xmlutils.make_clark("CS:getctag"))
|
||||
props.append(
|
||||
xmlutils.make_clark("CR:supported-address-data"))
|
||||
@@ -205,6 +218,8 @@ def xml_propfind_response(
|
||||
if share:
|
||||
# backmap
|
||||
child_element.text = child_element.text.replace(share['PathMapped'], share['PathOrToken'])
|
||||
if share_bday_automap:
|
||||
child_element.text = child_element.text.rstrip(".vcf") + ".ics"
|
||||
element.append(child_element)
|
||||
elif tag == xmlutils.make_clark("C:supported-calendar-component-set"):
|
||||
human_tag = xmlutils.make_human_tag(tag)
|
||||
@@ -214,6 +229,9 @@ def xml_propfind_response(
|
||||
components = components_text.split(",")
|
||||
else:
|
||||
components = ["VTODO", "VEVENT", "VJOURNAL"]
|
||||
if share_bday_automap:
|
||||
# enforce VEVENT-only
|
||||
components = ["VEVENT"]
|
||||
for component in components:
|
||||
comp = ET.Element(xmlutils.make_clark("C:comp"))
|
||||
comp.set("name", component)
|
||||
@@ -221,7 +239,7 @@ def xml_propfind_response(
|
||||
else:
|
||||
is404 = True
|
||||
elif tag == xmlutils.make_clark("CR:supported-address-data"):
|
||||
if is_collection and is_leaf and collection.tag == "VADDRESSBOOK":
|
||||
if is_collection and is_leaf and collection.tag == "VADDRESSBOOK" and not share_bday_automap:
|
||||
# Advertise supported vCard versions per RFC 6352 section 6.2.2
|
||||
# vCard 4.0 requires vobject >= 1.0.0
|
||||
versions: Sequence[str] = (("4.0", "3.0")
|
||||
@@ -243,6 +261,8 @@ def xml_propfind_response(
|
||||
if share:
|
||||
# backmap
|
||||
child_element.text = child_element.text.replace(share['Owner'], share['User'])
|
||||
if share_bday_automap:
|
||||
child_element.text = child_element.text.rstrip(".vcf") + ".ics"
|
||||
element.append(child_element)
|
||||
else:
|
||||
element.append(ET.Element(
|
||||
@@ -254,7 +274,8 @@ def xml_propfind_response(
|
||||
if "P" in share['Permissions']:
|
||||
privileges.append("D:write-properties")
|
||||
if "w" in share['Permissions']:
|
||||
privileges.append("D:write-content")
|
||||
if not share_bday_automap:
|
||||
privileges.append("D:write-content")
|
||||
elif write:
|
||||
privileges.append("D:all")
|
||||
privileges.append("D:write")
|
||||
@@ -271,11 +292,12 @@ def xml_propfind_response(
|
||||
"D:principal-search-property-set",
|
||||
"D:principal-property-search"]
|
||||
if is_collection and is_leaf:
|
||||
reports.append("D:sync-collection")
|
||||
if collection.tag == "VADDRESSBOOK":
|
||||
if not share_bday_automap:
|
||||
reports.append("D:sync-collection")
|
||||
if collection.tag == "VADDRESSBOOK" and not share_bday_automap:
|
||||
reports.append("CR:addressbook-multiget")
|
||||
reports.append("CR:addressbook-query")
|
||||
elif collection.tag == "VCALENDAR":
|
||||
elif collection.tag == "VCALENDAR" or share_bday_automap:
|
||||
reports.append("C:calendar-multiget")
|
||||
reports.append("C:calendar-query")
|
||||
for human_tag in reports:
|
||||
@@ -288,7 +310,18 @@ def xml_propfind_response(
|
||||
element.append(supported_report)
|
||||
elif tag == xmlutils.make_clark("D:getcontentlength"):
|
||||
if not is_collection or is_leaf:
|
||||
element.text = str(len(item.serialize().encode(encoding)))
|
||||
if collection.tag == "VADDRESSBOOK" and share_bday_automap and isinstance(item, storage.BaseCollection):
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("TRACE/PROPFIND/xml_propfind_response/getcontentlength: start bday automap handling")
|
||||
length = 0
|
||||
for entry in item.get_all():
|
||||
item_ics = entry.convert_vcf_to_ics()
|
||||
if item_ics is None:
|
||||
continue
|
||||
length += len(item_ics.vobject_item.serialize().encode(encoding))
|
||||
element.text = str(length)
|
||||
else:
|
||||
element.text = str(len(item.serialize().encode(encoding)))
|
||||
else:
|
||||
is404 = True
|
||||
elif tag == xmlutils.make_clark("D:owner"):
|
||||
@@ -306,6 +339,9 @@ def xml_propfind_response(
|
||||
if is_leaf:
|
||||
element.text = xmlutils.MIMETYPES[
|
||||
collection.tag]
|
||||
if share_bday_automap:
|
||||
# overwrite
|
||||
element.text = xmlutils.MIMETYPES["VCALENDAR"]
|
||||
else:
|
||||
is404 = True
|
||||
elif tag == xmlutils.make_clark("D:resourcetype"):
|
||||
@@ -314,11 +350,11 @@ def xml_propfind_response(
|
||||
xmlutils.make_clark("D:principal"))
|
||||
element.append(child_element)
|
||||
if is_leaf:
|
||||
if collection.tag == "VADDRESSBOOK":
|
||||
if collection.tag == "VADDRESSBOOK" and not share_bday_automap:
|
||||
child_element = ET.Element(
|
||||
xmlutils.make_clark("CR:addressbook"))
|
||||
element.append(child_element)
|
||||
elif collection.tag == "VCALENDAR":
|
||||
elif collection.tag == "VCALENDAR" or share_bday_automap:
|
||||
child_element = ET.Element(
|
||||
xmlutils.make_clark("C:calendar"))
|
||||
element.append(child_element)
|
||||
@@ -335,12 +371,25 @@ def xml_propfind_response(
|
||||
displayname = share['Properties']["D:displayname"]
|
||||
if displayname is not None:
|
||||
element.text = displayname
|
||||
if share_bday_automap:
|
||||
element.text += " (BDAY)"
|
||||
else:
|
||||
is404 = True
|
||||
elif tag == xmlutils.make_clark("RADICALE:getcontentcount"):
|
||||
# Only for internal use by the web interface
|
||||
if isinstance(item, storage.BaseCollection) and not collection.is_principal:
|
||||
element.text = str(sum(1 for x in item.get_all()))
|
||||
if collection.tag == "VADDRESSBOOK" and share_bday_automap:
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("TRACE/PROPFIND/xml_propfind_response/getcontentcount: start bday automap handling")
|
||||
items = []
|
||||
for entry in item.get_all():
|
||||
item_ics = entry.convert_vcf_to_ics()
|
||||
if item_ics is None:
|
||||
continue
|
||||
items.append(item_ics.vobject_item)
|
||||
element.text = str(sum(1 for x in items))
|
||||
else:
|
||||
element.text = str(sum(1 for x in item.get_all()))
|
||||
else:
|
||||
is404 = True
|
||||
elif tag == xmlutils.make_clark("D:displayname"):
|
||||
@@ -351,6 +400,8 @@ def xml_propfind_response(
|
||||
displayname = collection.path
|
||||
if displayname is not None:
|
||||
element.text = displayname
|
||||
if share_bday_automap:
|
||||
element.text += " (BDAY)"
|
||||
else:
|
||||
is404 = True
|
||||
elif tag == xmlutils.make_clark("CS:getctag"):
|
||||
@@ -385,6 +436,9 @@ def xml_propfind_response(
|
||||
elif tag == xmlutils.make_clark("D:getcontenttype"):
|
||||
assert not isinstance(item, storage.BaseCollection)
|
||||
element.text = xmlutils.get_content_type(item, encoding)
|
||||
if share_bday_automap:
|
||||
# overwrite
|
||||
element.text = xmlutils.MIMETYPES["VCALENDAR"]
|
||||
elif tag == xmlutils.make_clark("D:resourcetype"):
|
||||
# resourcetype must be returned empty for non-collection elements
|
||||
pass
|
||||
@@ -491,7 +545,13 @@ class ApplicationPartPropfind(ApplicationBase):
|
||||
# put item back
|
||||
items_iter = itertools.chain([item], items_iter)
|
||||
for item, permission in list(self._collect_allowed_items(items_iter, user)):
|
||||
allowed_items.append((item, permission, None))
|
||||
if self._sharing._enabled and share:
|
||||
if share['ShareType'] == "bday" and not isinstance(item, storage.BaseCollection):
|
||||
if not item.convert_vcf_to_ics():
|
||||
continue
|
||||
allowed_items.append((item, permission, share['ShareType']))
|
||||
else:
|
||||
allowed_items.append((item, permission, None))
|
||||
if self._sharing._enabled:
|
||||
if http_depth == "1":
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
|
||||
@@ -159,6 +159,13 @@ def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element],
|
||||
"""
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("TRACE/REPORT/xml_report: base_prefix=%r path=%r", base_prefix, path)
|
||||
|
||||
share_bday_automap = False
|
||||
if share and share['ShareType'] == "bday":
|
||||
share_bday_automap = True
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("TRACE/REPORT/xml_report(1): share=%r", share)
|
||||
|
||||
multistatus = ET.Element(xmlutils.make_clark("D:multistatus"))
|
||||
if xml_request is None:
|
||||
return client.MULTI_STATUS, multistatus
|
||||
@@ -173,12 +180,9 @@ def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element],
|
||||
logger.warning("Unsupported REPORT method %r on %r requested",
|
||||
xmlutils.make_human_tag(root.tag), path)
|
||||
return client.MULTI_STATUS, multistatus
|
||||
if (root.tag == xmlutils.make_clark("C:calendar-multiget") and
|
||||
collection.tag != "VCALENDAR" or
|
||||
root.tag == xmlutils.make_clark("CR:addressbook-multiget") and
|
||||
collection.tag != "VADDRESSBOOK" or
|
||||
root.tag == xmlutils.make_clark("D:sync-collection") and
|
||||
collection.tag not in ("VADDRESSBOOK", "VCALENDAR")):
|
||||
if ((root.tag == xmlutils.make_clark("C:calendar-multiget") and collection.tag != "VCALENDAR" and not share_bday_automap) or
|
||||
(root.tag == xmlutils.make_clark("CR:addressbook-multiget") and collection.tag != "VADDRESSBOOK") or
|
||||
(root.tag == xmlutils.make_clark("D:sync-collection") and collection.tag not in ("VADDRESSBOOK", "VCALENDAR"))):
|
||||
logger.warning("Invalid REPORT method %r on %r requested",
|
||||
xmlutils.make_human_tag(root.tag), path)
|
||||
return client.FORBIDDEN, xmlutils.webdav_error("D:supported-report")
|
||||
@@ -259,6 +263,23 @@ def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element],
|
||||
collection_tag = collection.tag
|
||||
# !!! Don't access storage after this !!!
|
||||
unlock_storage_fn()
|
||||
if share and share['ShareType'] == "bday":
|
||||
collection_tag = "VCALENDAR"
|
||||
# autoconvert
|
||||
retrieved_items_vcf_to_ics = []
|
||||
for item, flag in retrieved_items:
|
||||
item_ics = item.convert_vcf_to_ics()
|
||||
if item_ics is None:
|
||||
continue
|
||||
else:
|
||||
pass
|
||||
item_ics.href = item.href
|
||||
retrieved_items_vcf_to_ics.append((item_ics, flag))
|
||||
retrieved_items = retrieved_items_vcf_to_ics
|
||||
logging.debug("TRACE/REPORT/retrieved_items: %r", retrieved_items)
|
||||
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("TRACE/REPORT/xml_report(2): share=%r", share)
|
||||
|
||||
n_vevents = 0
|
||||
while retrieved_items:
|
||||
@@ -712,12 +733,25 @@ def xml_item_response(base_prefix: str, href: str,
|
||||
found_props: Sequence[ET.Element] = (),
|
||||
not_found_props: Sequence[ET.Element] = (),
|
||||
found_item: bool = True, share: Union[dict, None] = None) -> ET.Element:
|
||||
|
||||
response = ET.Element(xmlutils.make_clark("D:response"))
|
||||
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("TRACE/REPORT/xml_item_response: found=%s share=%r", found_item, share)
|
||||
|
||||
share_bday_automap = False
|
||||
if share and share['ShareType'] == "bday":
|
||||
share_bday_automap = True
|
||||
|
||||
href_element = ET.Element(xmlutils.make_clark("D:href"))
|
||||
href_element.text = xmlutils.make_href(base_prefix, href)
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("TRACE/REPORT/xml_report: href=%r", href_element.text)
|
||||
if share:
|
||||
# backmap
|
||||
href_element.text = href_element.text.replace(share['PathMapped'], share['PathOrToken'])
|
||||
if share_bday_automap:
|
||||
href_element.text = href_element.text.rstrip(".vcf") + ".ics"
|
||||
response.append(href_element)
|
||||
|
||||
if found_item:
|
||||
@@ -759,6 +793,9 @@ def retrieve_items(
|
||||
if share:
|
||||
# map back to owner
|
||||
hreference = hreference.replace(share['PathOrToken'], share['PathMapped'])
|
||||
if share['ShareType'] == "bday":
|
||||
if not hreference.endswith('/'):
|
||||
hreference = hreference.rstrip(".ics") + ".vcf"
|
||||
try:
|
||||
name = pathutils.name_from_path(hreference, collection)
|
||||
except ValueError as e:
|
||||
|
||||
@@ -487,6 +487,10 @@ DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([
|
||||
"value": "false",
|
||||
"help": "enable sharing of collection by map",
|
||||
"type": bool}),
|
||||
("collection_by_bday", {
|
||||
"value": "false",
|
||||
"help": "enable sharing of collection by bday (conversion on-the-fly)",
|
||||
"type": bool}),
|
||||
("permit_create_token", {
|
||||
"value": "false",
|
||||
"help": "permit create of token-based sharing",
|
||||
@@ -495,6 +499,10 @@ DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([
|
||||
"value": "false",
|
||||
"help": "permit create of map-based sharing",
|
||||
"type": bool}),
|
||||
("permit_create_bday", {
|
||||
"value": "false",
|
||||
"help": "permit create of bday-based sharing",
|
||||
"type": bool}),
|
||||
("permit_properties_overlay", {
|
||||
"value": "false",
|
||||
"help": "permit properties overlay",
|
||||
|
||||
@@ -26,14 +26,15 @@ Module for address books and calendar entries (see ``Item``).
|
||||
|
||||
import binascii
|
||||
import contextlib
|
||||
import datetime
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from hashlib import sha256
|
||||
from itertools import chain
|
||||
from typing import (Any, Callable, List, MutableMapping, Optional, Sequence,
|
||||
Tuple)
|
||||
Tuple, Union)
|
||||
|
||||
import vobject
|
||||
|
||||
@@ -42,6 +43,11 @@ from radicale import pathutils, utils
|
||||
from radicale.item import filter as radicale_filter
|
||||
from radicale.log import logger
|
||||
|
||||
# Product ID for auto-conversion
|
||||
PRODID_CONVERTED = u"-//Radicale//NONSGML " + utils.package_version("radicale") + "//EN (auto-converted)"
|
||||
PRODID_SUFFIX = " (auto-converted by Radicale " + utils.package_version("radicale") + ")"
|
||||
UID_SUFFIX = "-auto-converted-by-Radicale"
|
||||
|
||||
|
||||
def read_components(s: str) -> List[vobject.base.Component]:
|
||||
"""Wrapper for vobject.readComponents"""
|
||||
@@ -159,7 +165,7 @@ def check_and_sanitize_items(
|
||||
# an event with DTEND and DURATION:PT0S
|
||||
if (hasattr(component, "dtend") and
|
||||
hasattr(component, "duration") and
|
||||
component.duration.value == timedelta(0)):
|
||||
component.duration.value == datetime.timedelta(0)):
|
||||
logger.debug("Quirks: Removing zero duration from %s in "
|
||||
"object %r", component_name, component_uid)
|
||||
del component.duration
|
||||
@@ -314,7 +320,7 @@ def find_time_range(vobject_item: vobject.base.Component, tag: str
|
||||
return radicale_filter.TIMESTAMP_MIN, radicale_filter.TIMESTAMP_MAX
|
||||
start = end = None
|
||||
|
||||
def range_fn(range_start: datetime, range_end: datetime,
|
||||
def range_fn(range_start: datetime.datetime, range_end: datetime.datetime,
|
||||
is_recurrence: bool) -> bool:
|
||||
nonlocal start, end
|
||||
if start is None or range_start < start:
|
||||
@@ -323,7 +329,7 @@ def find_time_range(vobject_item: vobject.base.Component, tag: str
|
||||
end = range_end
|
||||
return False
|
||||
|
||||
def infinity_fn(range_start: datetime) -> bool:
|
||||
def infinity_fn(range_start: datetime.datetime) -> bool:
|
||||
nonlocal start, end
|
||||
if start is None or range_start < start:
|
||||
start = range_start
|
||||
@@ -498,3 +504,114 @@ class Item:
|
||||
self.time_range
|
||||
self.component_name
|
||||
self._vobject_item = orig_vobject_item
|
||||
|
||||
def convert_vcf_to_ics(self) -> Union["Item", None]:
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("TRACE/item/convert_vcf_to_ics: convert VCF to ICS (href): %r", self.href)
|
||||
logger.debug("TRACE/item/convert_vcf_to_ics: convert VCF to ICS (vobject): %r", self.vobject_item)
|
||||
if self.vobject_item.name != "VCARD":
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("TRACE/item/convert_vcf_to_ics: item is not a VCARD (skip): %r", self.href)
|
||||
return None
|
||||
else:
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("TRACE/item/convert_vcf_to_ics: item is a VCARD (ok): %r", self.href)
|
||||
if not hasattr(self.vobject_item, "bday"):
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("TRACE/item/convert_vcf_to_ics: miss bday (skip): %r", self.href)
|
||||
return None
|
||||
else:
|
||||
pass
|
||||
|
||||
bday = self.vobject_item.bday
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("TRACE/item/convert_vcf_to_ics: has bday (ok): %r -> %r", self.href, bday.value)
|
||||
|
||||
pattern = re.compile('^([0-9]{4})-?([0-9]{2})-?([0-9]{2})$')
|
||||
match = pattern.match(bday.value)
|
||||
if not match:
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("TRACE/item/convert_vcf_to_ics: has unsupported bday: %r -> %r", self.href, bday.value)
|
||||
return None
|
||||
else:
|
||||
pass
|
||||
|
||||
bdayS = match[1] + match[2] + match[3]
|
||||
bdayY = int(match[1])
|
||||
bdayM = int(match[2])
|
||||
bdayD = int(match[3])
|
||||
|
||||
# create ICS
|
||||
if hasattr(self.vobject_item, "fn"):
|
||||
name = self.vobject_item.fn.value
|
||||
elif hasattr(self.vobject_item, "n"):
|
||||
name = self.vobject_item.n.value
|
||||
elif hasattr(self.vobject_item, "nickname"):
|
||||
name = self.vobject_item.nickname.value
|
||||
else:
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("TRACE/item/convert_vcf_to_ics: has bday but neither FN or N or NICKNAME (skip): %r", self.href)
|
||||
return None
|
||||
|
||||
# create VCALENDAR
|
||||
item_ics = vobject.newFromBehavior('vcalendar')
|
||||
|
||||
# set PRODID
|
||||
if hasattr(self.vobject_item, "prodid"):
|
||||
item_ics.add('prodid').value = self.vobject_item.prodid.value + PRODID_SUFFIX
|
||||
else:
|
||||
item_ics.add('prodid').value = PRODID_CONVERTED
|
||||
|
||||
# create EVENT
|
||||
item_ics.add('vevent')
|
||||
|
||||
# set DTSTART
|
||||
dtstart = datetime.date(bdayY, bdayM, bdayD)
|
||||
item_ics.vevent.add('dtstart').value = dtstart
|
||||
|
||||
# calculate and set DTEND
|
||||
dtend = dtstart + datetime.timedelta(days=1)
|
||||
item_ics.vevent.add('dtend').value = dtend
|
||||
|
||||
# set UID
|
||||
if hasattr(self.vobject_item, "uid"):
|
||||
pattern = re.compile('^(.*)-[0-9a-fA-F]{12}(.*)$')
|
||||
match = pattern.match(self.vobject_item.uid.value)
|
||||
if match:
|
||||
# replace part of UUID by bday
|
||||
item_ics.vevent.add('uid').value = match[1] + '-' + 'bda0' + bdayS + match[2]
|
||||
else:
|
||||
item_ics.vevent.add('uid').value = self.vobject_item.uid.value + UID_SUFFIX
|
||||
else:
|
||||
item_ics.vevent.add('uid').value = match[1] + match[2] + match[3] + "@" + name.replace(" ", "-") + UID_SUFFIX
|
||||
|
||||
# set SUMMARY
|
||||
item_ics.vevent.add('summary').value = name + " (BDAY)"
|
||||
|
||||
# set RRULE
|
||||
item_ics.vevent.add('rrule').value = "FREQ=YEARLY"
|
||||
|
||||
# add transparency
|
||||
item_ics.vevent.add('transp').value = "TRANSPARENT"
|
||||
|
||||
href = self.href
|
||||
if href is not None:
|
||||
href = href.rstrip(".vcf") + ".ics"
|
||||
|
||||
etag = self.etag
|
||||
# replace 14 leading chars of etag "<hexdigits>" by special format bda0YYYYMMDD00
|
||||
etag = '"bda0' + bdayS + '00' + etag[15:]
|
||||
|
||||
item_new: Item = Item(
|
||||
collection=self.collection,
|
||||
etag=etag,
|
||||
href=href,
|
||||
vobject_item=item_ics)
|
||||
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("TRACE/storage: item generated/vobject: %r", item_ics.serialize())
|
||||
logger.debug("TRACE/storage: item orig /etag : %r", self.etag)
|
||||
logger.debug("TRACE/storage: item generated/etag : %r", item_new.etag)
|
||||
logger.debug("TRACE/storage: item generated/href : %r", item_new.href)
|
||||
|
||||
return item_new
|
||||
|
||||
@@ -35,6 +35,8 @@ Permissions:
|
||||
- t: deny create of token-based sharing of collection in case permit_create_token=True (>= 3.7.0)
|
||||
- M: permit create of map-based sharing of collection in case permit_create_map=False (>= 3.7.0)
|
||||
- m: deny create of map-based sharing of collection in case permit_create_map=True (>= 3.7.0)
|
||||
- B: permit create of bday-based sharing of addressbook collection in case permit_create_bday=False (>= 3.7.0)
|
||||
- b: deny create of bday-based sharing of addressbook collection in case permit_create_bday=True (>= 3.7.0)
|
||||
|
||||
Permissions only supported so far in share permissions:
|
||||
- P: permit properties overlay in case permit_properties_overlay=False (>= 3.7.0)
|
||||
@@ -53,7 +55,7 @@ from radicale import config, utils
|
||||
INTERNAL_TYPES: Sequence[str] = ("authenticated", "owner_write", "owner_only",
|
||||
"from_file")
|
||||
|
||||
INTERNAL_PERMISSIONS: str = "RriWwDdOoTtMmPpEe"
|
||||
INTERNAL_PERMISSIONS: str = "RriWwDdOoTtMmPpEeBb"
|
||||
|
||||
|
||||
def load(configuration: "config.Configuration") -> "BaseRights":
|
||||
|
||||
@@ -67,11 +67,12 @@ DB_TYPES_V1: dict[str, type] = {
|
||||
|
||||
DB_FIELDS_V1_USER_PERMITTED: Sequence[str] = ('EnabledByUser', 'HiddenByUser', 'Properties')
|
||||
|
||||
SHARE_TYPES: Sequence[str] = ('token', 'map', 'all')
|
||||
SHARE_TYPES: Sequence[str] = ('token', 'map', 'bday', 'all')
|
||||
|
||||
SHARE_TYPES_V1: Sequence[str] = ('token', 'map')
|
||||
SHARE_TYPES_V1: Sequence[str] = ('token', 'map', 'bday')
|
||||
# token: share by secret token (does not require authentication)
|
||||
# map : share by mapping collection of one user to another as virtual
|
||||
# bday : share by mapping addressbook-collection of one user to another as virtual calendar-collection
|
||||
# all : only supported for "list" and "info"
|
||||
|
||||
API_HOOKS_V1: Sequence[str] = ('list', 'create', 'delete', 'update', 'hide', 'unhide', 'enable', 'disable', 'info')
|
||||
@@ -93,8 +94,10 @@ API_TYPES_V1: dict[str, type] = {
|
||||
"Lines": int,
|
||||
"FeatureEnabledCollectionByMap": bool,
|
||||
"FeatureEnabledCollectionByToken": bool,
|
||||
"FeatureEnabledCollectionByBday": bool,
|
||||
"PermittedCreateCollectionByMap": bool,
|
||||
"PermittedCreateCollectionByToken": bool,
|
||||
"PermittedCreateCollectionByBday": bool,
|
||||
"ShareType": str,
|
||||
"PathOrToken": str,
|
||||
"PathMapped": str,
|
||||
@@ -142,16 +145,20 @@ class BaseSharing:
|
||||
# Sharing
|
||||
self.sharing_collection_by_map = configuration.get("sharing", "collection_by_map")
|
||||
self.sharing_collection_by_token = configuration.get("sharing", "collection_by_token")
|
||||
self.sharing_collection_by_bday = configuration.get("sharing", "collection_by_bday")
|
||||
self.permit_create_token = configuration.get("sharing", "permit_create_token")
|
||||
self.permit_create_map = configuration.get("sharing", "permit_create_map")
|
||||
self.permit_create_bday = configuration.get("sharing", "permit_create_bday")
|
||||
self.default_permissions_create_token = configuration.get("sharing", "default_permissions_create_token")
|
||||
self.default_permissions_create_map = configuration.get("sharing", "default_permissions_create_map")
|
||||
self.permit_properties_overlay = configuration.get("sharing", "permit_properties_overlay")
|
||||
self.enforce_properties_overlay = configuration.get("sharing", "enforce_properties_overlay")
|
||||
logger.info("sharing.collection_by_map : %s", self.sharing_collection_by_map)
|
||||
logger.info("sharing.collection_by_token: %s", self.sharing_collection_by_token)
|
||||
logger.info("sharing.collection_by_bday : %s", self.sharing_collection_by_bday)
|
||||
logger.info("sharing.permit_create_token: %s", self.permit_create_token)
|
||||
logger.info("sharing.permit_create_map : %s", self.permit_create_map)
|
||||
logger.info("sharing.permit_create_bday : %s", self.permit_create_bday)
|
||||
logger.info("sharing.default_permissions_create_token: %r", self.default_permissions_create_token)
|
||||
logger.info("sharing.default_permissions_create_map : %r", self.default_permissions_create_map)
|
||||
logger.info("sharing.permit_properties_overlay: %s", self.permit_properties_overlay)
|
||||
@@ -161,7 +168,7 @@ class BaseSharing:
|
||||
self.sharing_db_type = configuration.get("sharing", "type")
|
||||
logger.info("sharing.database_type: %s", self.sharing_db_type)
|
||||
|
||||
if ((self.sharing_collection_by_map is False) and (self.sharing_collection_by_token is False)):
|
||||
if ((self.sharing_collection_by_map is False) and (self.sharing_collection_by_token is False) and (self.sharing_collection_by_bday is False)):
|
||||
logger.info("sharing disabled as no feature is enabled")
|
||||
self._enabled = False
|
||||
return
|
||||
@@ -341,6 +348,20 @@ class BaseSharing:
|
||||
HiddenByOwner=Hidden,
|
||||
HiddenByUser=Hidden)
|
||||
|
||||
if not self.sharing_collection_by_bday:
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("TRACE/sharing/bday: not active")
|
||||
else:
|
||||
# retrieve collections depending on filter
|
||||
sharing_collection_list += self.database_list_sharing(
|
||||
ShareType="bday",
|
||||
OwnerOrUser=User,
|
||||
User=User,
|
||||
EnabledByOwner=Enabled,
|
||||
EnabledByUser=Enabled,
|
||||
HiddenByOwner=Hidden,
|
||||
HiddenByUser=Hidden)
|
||||
|
||||
return sharing_collection_list
|
||||
|
||||
# resolves a path to a share
|
||||
@@ -366,6 +387,13 @@ class BaseSharing:
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("TRACE/sharing/map: not active")
|
||||
|
||||
if self.sharing_collection_by_bday:
|
||||
if share is None:
|
||||
share = self.sharing_collection_by_bday_resolver(path, user)
|
||||
else:
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("TRACE/sharing/bday: not active")
|
||||
|
||||
if share is not None:
|
||||
if self.permit_properties_overlay:
|
||||
if share['Permissions'] and "p" not in share['Permissions']:
|
||||
@@ -466,6 +494,43 @@ class BaseSharing:
|
||||
logger.debug("TRACE/sharing/map: not active")
|
||||
return None
|
||||
|
||||
# resolves a bday "path" to a share
|
||||
def sharing_collection_by_bday_resolver(self, path: str, user: str) -> Union[dict, None]:
|
||||
""" returning dict with PathMapped, Owner, Permissions or None if invalid"""
|
||||
if self.sharing_collection_by_bday:
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("TRACE/sharing/bday/resolver: check path: %r", path)
|
||||
result = self.database_get_sharing(
|
||||
ShareType="bday",
|
||||
PathOrToken=path,
|
||||
User=user)
|
||||
if result:
|
||||
pass
|
||||
else:
|
||||
# fallback to parent path
|
||||
parent_path = pathutils.parent_path(path)
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("TRACE/sharing/bday/resolver: check parent path: %r", parent_path)
|
||||
result = self.database_get_sharing(
|
||||
ShareType="bday",
|
||||
PathOrToken=parent_path,
|
||||
User=user)
|
||||
if result:
|
||||
result['PathMapped'] = path.replace(parent_path, result['PathMapped'])
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("TRACE/sharing/bday/resolver: PathMapped=%r Permissions=%r by parent_path=%r", result['PathMapped'], result['Permissions'], parent_path)
|
||||
else:
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("TRACE/sharing/bday: not found")
|
||||
return None
|
||||
|
||||
logger.info("Sharing/%s: resolved path %r->%r, user %r->%r, permissions %r", "bday", path, result['PathMapped'], user, result['Owner'], result['Permissions'])
|
||||
return result
|
||||
else:
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("TRACE/sharing/bday: not active")
|
||||
return None
|
||||
|
||||
# *** POST API ***
|
||||
def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: str) -> types.WSGIResponse:
|
||||
# Late import to avoid circular dependency in config
|
||||
@@ -480,28 +545,28 @@ class BaseSharing:
|
||||
``user`` is empty for anonymous users.
|
||||
|
||||
Request:
|
||||
action: (token|map/list
|
||||
action: (token|map|bday)/list
|
||||
PathOrToken: <path|token> (optional for filter)
|
||||
|
||||
action: (token|map)/create
|
||||
action: (token|map|bday)/create
|
||||
PathMapped: <path> (mandatory)
|
||||
Permissions: <Permissions> (default: r)
|
||||
|
||||
token -> returns <token>
|
||||
|
||||
map
|
||||
map|bday
|
||||
PathOrToken: <path> (mandatory)
|
||||
User: <target_user> (mandatory)
|
||||
|
||||
action: (token|map)/update
|
||||
action: (token|map|bday)/update
|
||||
|
||||
action: (token|map)/(delete|disable|enable|hide|unhide)
|
||||
action: (token|map|bday)/(delete|disable|enable|hide|unhide)
|
||||
PathOrToken: <path|token> (mandatory)
|
||||
|
||||
token
|
||||
|
||||
map
|
||||
PathMapped: <path> (mandator)
|
||||
map|bday
|
||||
PathMapped: <path> (mandatory)
|
||||
User: <target_user>
|
||||
|
||||
Response: output format depending on ACCEPT header
|
||||
@@ -561,6 +626,11 @@ class BaseSharing:
|
||||
logger.warning(api_info + ": not enabled by config (collection_by_map)")
|
||||
return httputils.NOT_FOUND
|
||||
|
||||
if not self.sharing_collection_by_bday and ShareType == "bday":
|
||||
# API "token" is not enabled
|
||||
logger.warning(api_info + ": not enabled by config (collection_by_bday)")
|
||||
return httputils.NOT_FOUND
|
||||
|
||||
# check for valid API hooks
|
||||
if action not in API_HOOKS_V1:
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
@@ -756,7 +826,7 @@ class BaseSharing:
|
||||
answer['ApiVersion'] = 1
|
||||
Timestamp = int((datetime.now() - datetime(1970, 1, 1)).total_seconds())
|
||||
|
||||
if not self.sharing_collection_by_map and not self.sharing_collection_by_token:
|
||||
if not self.sharing_collection_by_map and not self.sharing_collection_by_token and not self.sharing_collection_by_bday:
|
||||
if not action == 'info':
|
||||
# API is not enabled
|
||||
logger.warning(api_info + ": API is not enabled")
|
||||
@@ -881,7 +951,7 @@ class BaseSharing:
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("TRACE/" + api_info + ": result=%r", result)
|
||||
|
||||
elif ShareType == "map":
|
||||
elif ShareType in ["map", "bday"]:
|
||||
# check preconditions
|
||||
if PathOrToken is None:
|
||||
return httputils.bad_request("Missing PathOrToken")
|
||||
@@ -911,14 +981,25 @@ class BaseSharing:
|
||||
logger.warning(api_info + ": access to PathMapped=%r not allowed for owner %r", PathMapped, user)
|
||||
return httputils.NOT_ALLOWED
|
||||
|
||||
if self.permit_create_map is False:
|
||||
if "m" not in access.permissions:
|
||||
logger.warning(api_info + ": access to PathMapped=%r not allowed for owner %r (permit=False but explicit grant misses 'm')", PathMapped, user)
|
||||
return httputils.NOT_ALLOWED
|
||||
else:
|
||||
if "M" in access.permissions:
|
||||
logger.warning(api_info + ": access to PathMapped=%r not allowed for owner %r (permit=True but denied by 'M')", PathMapped, user)
|
||||
return httputils.NOT_ALLOWED
|
||||
if ShareType == "map":
|
||||
if self.permit_create_map is False:
|
||||
if "m" not in access.permissions:
|
||||
logger.warning(api_info + ": access to PathMapped=%r not allowed for owner %r (permit=False but explicit grant misses 'm')", PathMapped, user)
|
||||
return httputils.NOT_ALLOWED
|
||||
else:
|
||||
if "M" in access.permissions:
|
||||
logger.warning(api_info + ": access to PathMapped=%r not allowed for owner %r (permit=True but denied by 'M')", PathMapped, user)
|
||||
return httputils.NOT_ALLOWED
|
||||
|
||||
elif ShareType == "bday":
|
||||
if self.permit_create_bday is False:
|
||||
if "b" not in access.permissions:
|
||||
logger.warning(api_info + ": access to PathMapped=%r not allowed for owner %r (permit=False but explicit grant misses 'b')", PathMapped, user)
|
||||
return httputils.NOT_ALLOWED
|
||||
else:
|
||||
if "B" in access.permissions:
|
||||
logger.warning(api_info + ": access to PathMapped=%r not allowed for owner %r (permit=True but denied by 'B')", PathMapped, user)
|
||||
return httputils.NOT_ALLOWED
|
||||
|
||||
access = Access(self._rights, User, PathOrToken)
|
||||
if not access.check("r"):
|
||||
@@ -978,7 +1059,7 @@ class BaseSharing:
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("TRACE/" + api_info + ": start")
|
||||
|
||||
if ShareType not in ["token", "map"]:
|
||||
if ShareType not in ["token", "map", "bday"]:
|
||||
logger.warning(api_info + ": unsupported for ShareType=%r", ShareType)
|
||||
return httputils.bad_request("Invalid share type")
|
||||
|
||||
@@ -1023,17 +1104,33 @@ class BaseSharing:
|
||||
logger.warning(api_info + ": access to %r not allowed for user %r", PathMapped, user)
|
||||
return httputils.NOT_ALLOWED
|
||||
|
||||
result = self.database_update_sharing(
|
||||
ShareType=ShareType,
|
||||
PathMapped=PathMapped,
|
||||
Permissions=Permissions,
|
||||
EnabledByOwner=Enabled,
|
||||
HiddenByOwner=Hidden,
|
||||
PathOrToken=PathOrToken,
|
||||
OwnerOrUser=user,
|
||||
User=User,
|
||||
Timestamp=Timestamp,
|
||||
Properties=Properties)
|
||||
if user == share['User']:
|
||||
# self-owned share
|
||||
result = self.database_update_sharing(
|
||||
ShareType=ShareType,
|
||||
PathMapped=PathMapped,
|
||||
Permissions=Permissions,
|
||||
EnabledByOwner=Enabled,
|
||||
EnabledByUser=Enabled,
|
||||
HiddenByOwner=Hidden,
|
||||
HiddenByUser=Hidden,
|
||||
PathOrToken=PathOrToken,
|
||||
OwnerOrUser=user,
|
||||
User=User,
|
||||
Timestamp=Timestamp,
|
||||
Properties=Properties)
|
||||
else:
|
||||
result = self.database_update_sharing(
|
||||
ShareType=ShareType,
|
||||
PathMapped=PathMapped,
|
||||
Permissions=Permissions,
|
||||
EnabledByOwner=Enabled,
|
||||
HiddenByOwner=Hidden,
|
||||
PathOrToken=PathOrToken,
|
||||
OwnerOrUser=user,
|
||||
User=User,
|
||||
Timestamp=Timestamp,
|
||||
Properties=Properties)
|
||||
|
||||
elif user == share['User']:
|
||||
# User is only allowed to update Properties
|
||||
@@ -1088,7 +1185,7 @@ class BaseSharing:
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("TRACE/" + api_info + ": start")
|
||||
|
||||
if ShareType not in ["token", "map"]:
|
||||
if ShareType not in ["token", "map", "bday"]:
|
||||
logger.warning(api_info + ": unsupported for ShareType=%r", ShareType)
|
||||
return httputils.bad_request("Invalid share type")
|
||||
|
||||
@@ -1133,13 +1230,16 @@ class BaseSharing:
|
||||
if ShareType in ["all", "token"]:
|
||||
answer['FeatureEnabledCollectionByToken'] = self.sharing_collection_by_token
|
||||
answer['PermittedCreateCollectionByToken'] = self.permit_create_token
|
||||
if ShareType in ["all", "bday"]:
|
||||
answer['FeatureEnabledCollectionByBday'] = self.sharing_collection_by_bday
|
||||
answer['PermittedCreateCollectionByBday'] = self.permit_create_bday
|
||||
|
||||
# action: TOGGLE
|
||||
elif action in API_SHARE_TOGGLES_V1:
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("TRACE/sharing/API/POST/" + action)
|
||||
|
||||
if ShareType not in ["token", "map"]:
|
||||
if ShareType not in ["token", "map", "bday"]:
|
||||
logger.warning(api_info + ": unsupported for ShareType=%r", ShareType)
|
||||
return httputils.bad_request("Invalid share type")
|
||||
|
||||
|
||||
@@ -241,6 +241,17 @@ class Sharing(sharing.BaseSharing):
|
||||
# must be unique systemwide
|
||||
logger.error("sharing/map/create: entry already exists: PathMapped=%r User=%r", PathMapped, User)
|
||||
return {"status": "conflict"}
|
||||
elif ShareType == "bday":
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("TRACE/sharing/bday/create: PathOrToken=%r Owner=%r PathMapped=%r User=%r Permissions=%r", PathOrToken, Owner, PathMapped, User, Permissions)
|
||||
# check for duplicate map entry
|
||||
for row in self._sharing_cache:
|
||||
if row['ShareType'] != "bday":
|
||||
continue
|
||||
if row['PathMapped'] == PathMapped and row['User'] == User and row['PathOrToken'] == PathOrToken:
|
||||
# must be unique systemwide
|
||||
logger.error("sharing/bday/create: entry already exists: PathMapped=%r User=%r", PathMapped, User)
|
||||
return {"status": "conflict"}
|
||||
else:
|
||||
return {"status": "error"}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ Take a look at the class ``BaseCollection`` if you want to implement your own.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import xml.etree.ElementTree as ET
|
||||
from hashlib import sha256
|
||||
from typing import (Callable, ContextManager, Dict, Iterable, Iterator, List,
|
||||
@@ -223,7 +224,7 @@ class BaseCollection:
|
||||
"""Get the HTTP-datetime of when the collection was modified."""
|
||||
raise NotImplementedError
|
||||
|
||||
def serialize(self) -> str:
|
||||
def serialize(self, vcf_to_ics: bool = False) -> str:
|
||||
"""Get the unicode string representing the whole collection."""
|
||||
if self.tag == "VCALENDAR":
|
||||
in_vcalendar = False
|
||||
@@ -282,7 +283,23 @@ class BaseCollection:
|
||||
vtimezones + components +
|
||||
template[template_insert_pos:])
|
||||
if self.tag == "VADDRESSBOOK":
|
||||
return "".join((item.serialize() for item in self.get_all()))
|
||||
if vcf_to_ics:
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("TRACE/storage: convert VCF to ICS")
|
||||
|
||||
items = []
|
||||
for item in self.get_all():
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("TRACE/storage/convert VCF to ICS: %r:", item)
|
||||
item_ics = item.convert_vcf_to_ics()
|
||||
if item_ics is None:
|
||||
continue
|
||||
else:
|
||||
pass
|
||||
items.append(item_ics.vobject_item)
|
||||
return "".join((item.serialize() for item in items))
|
||||
else:
|
||||
return "".join((item.serialize() for item in self.get_all()))
|
||||
return ""
|
||||
|
||||
|
||||
|
||||
9
radicale/tests/static/contact2-with-bday.vcf
Normal file
9
radicale/tests/static/contact2-with-bday.vcf
Normal 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
|
||||
9
radicale/tests/static/contact3-with-bday.vcf
Normal file
9
radicale/tests/static/contact3-with-bday.vcf
Normal 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
|
||||
@@ -256,6 +256,7 @@ class TestSharingApiSanity(BaseTest):
|
||||
"htpasswd_encryption": "plain"},
|
||||
"sharing": {
|
||||
"collection_by_map": "True",
|
||||
"collection_by_bday": "True",
|
||||
"collection_by_token": "True"},
|
||||
"rights": {"type": "owner_only"}})
|
||||
|
||||
@@ -333,13 +334,16 @@ class TestSharingApiSanity(BaseTest):
|
||||
# path with valid API and hook and all enabled
|
||||
self.configure({"sharing": {
|
||||
"collection_by_map": "True",
|
||||
"collection_by_bday": "True",
|
||||
"collection_by_token": "True"}
|
||||
})
|
||||
for sharetype in sharing.SHARE_TYPES:
|
||||
path = "/.sharing/v1/" + sharetype + "/" + action
|
||||
# invalid API
|
||||
logging.info("\n*** check hook -> 404 (invalid)")
|
||||
_, headers, _ = self.request("POST", path + "NA", check=404, login="owner:ownerpw")
|
||||
# valid API
|
||||
logging.info("\n*** check hook -> 400 (valid but no data)")
|
||||
_, headers, _ = self.request("POST", path, check=400, login="owner:ownerpw")
|
||||
|
||||
logging.info("\n*** check API hook: info/token -> 200")
|
||||
@@ -353,8 +357,10 @@ class TestSharingApiSanity(BaseTest):
|
||||
# When turning on permission to create
|
||||
self.configure({"sharing": {
|
||||
"collection_by_map": "True",
|
||||
"collection_by_bday": "True",
|
||||
"collection_by_token": "True",
|
||||
"permit_create_map": "True",
|
||||
"permit_create_bday": "True",
|
||||
"permit_create_token": "True"}
|
||||
})
|
||||
logging.info("\n*** check API hook: info/all")
|
||||
@@ -363,8 +369,10 @@ class TestSharingApiSanity(BaseTest):
|
||||
answer_dict = json.loads(answer)
|
||||
assert answer_dict['FeatureEnabledCollectionByMap'] is True, f'FeatureEnabledCollectionByMap {db_type}'
|
||||
assert answer_dict['FeatureEnabledCollectionByToken'] is True, f'FeatureEnabledCollectionByToken {db_type}'
|
||||
assert answer_dict['FeatureEnabledCollectionByBday'] is True, f'FeatureEnabledCollectionByBday {db_type}'
|
||||
assert answer_dict['PermittedCreateCollectionByMap'] is True, f'PermittedCreateCollectionByMap {db_type}'
|
||||
assert answer_dict['PermittedCreateCollectionByToken'] is True, f'PermittedCreateCollectionByToken {db_type}'
|
||||
assert answer_dict['PermittedCreateCollectionByBday'] is True, f'PermittedCreateCollectionByBday {db_type}'
|
||||
|
||||
def test_sharing_api_list_with_auth(self) -> None:
|
||||
"""POST/list with authentication."""
|
||||
@@ -374,8 +382,10 @@ class TestSharingApiSanity(BaseTest):
|
||||
"sharing": {
|
||||
"type": "csv",
|
||||
"permit_create_map": True,
|
||||
"permit_create_bday": True,
|
||||
"permit_create_token": True,
|
||||
"collection_by_map": "True",
|
||||
"collection_by_bday": "True",
|
||||
"collection_by_token": "True"},
|
||||
"logging": {"request_header_on_debug": "true",
|
||||
"request_content_on_debug": "True"},
|
||||
@@ -478,6 +488,7 @@ class TestSharingApiSanity(BaseTest):
|
||||
"permit_create_map": True,
|
||||
"permit_create_token": True,
|
||||
"collection_by_map": "True",
|
||||
"collection_by_bday": "True",
|
||||
"collection_by_token": "True"},
|
||||
"logging": {"request_header_on_debug": "False",
|
||||
"response_content_on_debug": "True",
|
||||
@@ -4170,3 +4181,493 @@ permissions: RrWw""")
|
||||
assert answer_dict['Lines'] == 1
|
||||
assert 'ICAL:calendar-color' not in answer_dict['Content'][0]['Properties']
|
||||
assert 'C:calendar-description' not in answer_dict['Content'][0]['Properties']
|
||||
|
||||
def test_sharing_api_bday_basic(self) -> None:
|
||||
"""share-by-bday basic tests."""
|
||||
self.configure({"auth": {"type": "htpasswd",
|
||||
"htpasswd_filename": self.htpasswd_file_path,
|
||||
"htpasswd_encryption": "plain"},
|
||||
"sharing": {
|
||||
"type": "csv",
|
||||
"permit_create_bday": True,
|
||||
"permit_properties_overlay": "True",
|
||||
"enforce_properties_overlay": "True",
|
||||
"collection_by_bday": "True"},
|
||||
"logging": {"request_header_on_debug": "False",
|
||||
"response_content_on_debug": "True",
|
||||
"request_content_on_debug": "True"},
|
||||
"rights": {"type": "owner_only"}})
|
||||
|
||||
json_dict: dict
|
||||
|
||||
logging.info("\n*** prepare and test access")
|
||||
|
||||
for db_type in list(filter(lambda item: item != "none", sharing.INTERNAL_TYPES)):
|
||||
logging.info("\n*** test: %s", db_type)
|
||||
self.configure({"sharing": {"type": db_type}})
|
||||
|
||||
path_mapped = "/owner/adressbook-" + db_type + ".vcf/"
|
||||
path_shared_r = "/user/calendar-bday-abook-shared-by-owner-r-" + db_type + ".ics/"
|
||||
self.create_addressbook(path_mapped, login="owner:ownerpw")
|
||||
|
||||
contact = get_file_content("contact1.vcf")
|
||||
path = path_mapped + "/contact1.vcf"
|
||||
self.put(path, contact, login="owner:ownerpw")
|
||||
|
||||
contact = get_file_content("contact2-with-bday.vcf")
|
||||
path = path_mapped + "/contact2-with-bday.vcf"
|
||||
self.put(path, contact, login="owner:ownerpw")
|
||||
|
||||
contact = get_file_content("contact3-with-bday.vcf")
|
||||
path = path_mapped + "/contact3-with-bday.vcf"
|
||||
self.put(path, contact, login="owner:ownerpw")
|
||||
|
||||
# check PROPFIND as owner
|
||||
logging.info("\n*** PROPFIND collection owner -> ok")
|
||||
_, responses = self.propfind(path_mapped, """\
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<propfind xmlns="DAV:">
|
||||
<propname />
|
||||
</propfind>""", login="owner:ownerpw")
|
||||
logging.info("response: %r", responses)
|
||||
response = responses[path_mapped]
|
||||
assert not isinstance(response, int)
|
||||
assert "CR:supported-address-data" in response
|
||||
|
||||
# execute GET as owner
|
||||
logging.info("\n*** GET VCF collection owner -> ok")
|
||||
_, answer = self.get(path_mapped, login="owner:ownerpw")
|
||||
assert "contact1" in answer
|
||||
assert "contact2" in answer
|
||||
|
||||
# create map
|
||||
logging.info("\n*** create bday user/owner:r -> ok")
|
||||
json_dict = {}
|
||||
json_dict['User'] = "user"
|
||||
json_dict['PathMapped'] = path_mapped
|
||||
json_dict['PathOrToken'] = path_shared_r
|
||||
json_dict['Permissions'] = "r"
|
||||
json_dict['Enabled'] = True
|
||||
json_dict['Hidden'] = False
|
||||
_, headers, answer = self._sharing_api_json("bday", "create", check=200, login="owner:ownerpw", json_dict=json_dict)
|
||||
answer_dict = json.loads(answer)
|
||||
assert answer_dict['Status'] == "success"
|
||||
|
||||
# enable map by user
|
||||
logging.info("\n*** enable bday by user")
|
||||
json_dict = {}
|
||||
json_dict['User'] = "user"
|
||||
json_dict['PathMapped'] = path_mapped
|
||||
json_dict['PathOrToken'] = path_shared_r
|
||||
_, headers, answer = self._sharing_api_json("bday", "enable", check=200, login="user:userpw", json_dict=json_dict)
|
||||
|
||||
# check PROPFIND item as user
|
||||
logging.info("\n*** PROPFIND item as user -> calendar")
|
||||
response = self._propfind_allprop(path_shared_r, login="user:userpw")
|
||||
logging.debug("response: %r", response)
|
||||
assert "CR:supported-address-data" not in response
|
||||
assert "D:sync-token" not in response
|
||||
assert "C:supported-calendar-component-set" in response
|
||||
assert "D:current-user-privilege-set" in response
|
||||
|
||||
# check PROPFIND/priviledges item as user
|
||||
logging.info("\n*** PROPFIND/priviledges item as user -> calendar")
|
||||
priviledges_list = self._propfind_priviledges(path_shared_r, login="user:userpw")
|
||||
assert "D:read" in priviledges_list
|
||||
assert "D:write-content" not in priviledges_list
|
||||
assert "D:write-properties" in priviledges_list
|
||||
assert "D:write" not in priviledges_list
|
||||
assert "D:all" not in priviledges_list
|
||||
|
||||
# verify content as user
|
||||
logging.info("\n*** GET collection user -> ok")
|
||||
_, answer = self.get(path_shared_r, login="user:userpw")
|
||||
assert "BEGIN:VCARD" not in answer
|
||||
assert "BEGIN:VCALENDAR" in answer
|
||||
assert "RRULE:FREQ=YEARLY" in answer
|
||||
assert "DTSTART;VALUE=DATE:19700101" in answer
|
||||
assert "DTEND;VALUE=DATE:19700102" in answer
|
||||
assert "TRANSP:TRANSPARENT" in answer
|
||||
|
||||
# verify report as user
|
||||
logging.info("\n*** REPORT collection user -> ok")
|
||||
_, responses = self.report(path_shared_r, """\
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<C:calendar-query xmlns:C="urn:ietf:params:xml:ns:caldav">
|
||||
<D:prop xmlns:D="DAV:">
|
||||
<D:getetag/>
|
||||
</D:prop>
|
||||
</C:calendar-query>""", login="user:userpw")
|
||||
logging.debug("resonses: %r", responses)
|
||||
assert path_shared_r + "contact2-with-bday.ics" in responses
|
||||
assert path_shared_r + "contact3-with-bday.ics" in responses
|
||||
assert path_shared_r + "contact1.ics" not in responses
|
||||
|
||||
# get elements as user
|
||||
logging.info("\n*** REPORT collection entries user -> ok")
|
||||
_, responses = self.report(path_shared_r, """\
|
||||
<?xml version="1.0"?>
|
||||
<C:calendar-multiget xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
||||
<prop>
|
||||
<getetag />
|
||||
<C:calendar-data />
|
||||
</prop>
|
||||
<href>""" + path_shared_r + "contact2-with-bday.ics" + """</href>
|
||||
<href>""" + path_shared_r + "contact3-with-bday.ics" + """</href>
|
||||
</C:calendar-multiget>""", login="user:userpw")
|
||||
logging.debug("resonses: %r", responses)
|
||||
assert path_shared_r + "contact2-with-bday.ics" in responses
|
||||
assert path_shared_r + "contact3-with-bday.ics" in responses
|
||||
assert path_shared_r + "contact1.ics" not in responses
|
||||
|
||||
# timerange filter elements as user
|
||||
logging.info("\n*** REPORT collection entries with timerange user -> ok")
|
||||
_, responses = self.report(path_shared_r, """\
|
||||
<?xml version="1.0"?>
|
||||
<C:calendar-query xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
||||
<prop>
|
||||
<getcontenttype />
|
||||
<getetag />
|
||||
<C:calendar-data />
|
||||
</prop>
|
||||
<C:filter>
|
||||
<C:comp-filter name="VCALENDAR">
|
||||
<C:comp-filter name="VEVENT">
|
||||
<C:time-range start="20251124T000000Z" end="20260715T000000Z" />
|
||||
</C:comp-filter>
|
||||
</C:comp-filter>
|
||||
</C:filter>
|
||||
</C:calendar-query>""", login="user:userpw")
|
||||
logging.debug("resonses: %r", responses)
|
||||
assert path_shared_r + "contact2-with-bday.ics" in responses
|
||||
assert path_shared_r + "contact3-with-bday.ics" in responses
|
||||
assert path_shared_r + "contact1.ics" not in responses
|
||||
|
||||
logging.info("\n*** PROPFIND collection entries user -> ok")
|
||||
_, responses = self.propfind(path_shared_r, """\
|
||||
<?xml version="1.0"?>
|
||||
<propfind xmlns="DAV:">
|
||||
<prop>
|
||||
<getcontenttype />
|
||||
<resourcetype />
|
||||
<getetag />
|
||||
</prop>
|
||||
</propfind>""", login="user:userpw", HTTP_DEPTH="1")
|
||||
logging.debug("resonses: %r", responses)
|
||||
assert path_shared_r + "contact2-with-bday.ics" in responses
|
||||
assert path_shared_r + "contact3-with-bday.ics" in responses
|
||||
assert path_shared_r + "contact1.ics" not in responses
|
||||
response = responses[path_shared_r + "contact2-with-bday.ics"]
|
||||
assert not isinstance(response, int)
|
||||
status, prop = response["D:getcontenttype"]
|
||||
assert "text/calendar" in str(prop.text)
|
||||
|
||||
def test_sharing_api_bday_complex(self) -> None:
|
||||
"""share-by-bday complex tests."""
|
||||
self.configure({"auth": {"type": "htpasswd",
|
||||
"htpasswd_filename": self.htpasswd_file_path,
|
||||
"htpasswd_encryption": "plain"},
|
||||
"sharing": {
|
||||
"type": "csv",
|
||||
"permit_create_bday": True,
|
||||
"permit_create_map": True,
|
||||
"permit_properties_overlay": "True",
|
||||
"enforce_properties_overlay": "True",
|
||||
"collection_by_map": "True",
|
||||
"collection_by_bday": "True"},
|
||||
"logging": {"request_header_on_debug": "False",
|
||||
"response_content_on_debug": "True",
|
||||
"request_content_on_debug": "True"},
|
||||
"rights": {"type": "owner_only"}})
|
||||
|
||||
json_dict: dict
|
||||
|
||||
logging.info("\n*** prepare and test access")
|
||||
|
||||
for db_type in list(filter(lambda item: item != "none", sharing.INTERNAL_TYPES)):
|
||||
logging.info("\n*** test: %s", db_type)
|
||||
self.configure({"sharing": {"type": db_type}})
|
||||
|
||||
path_mapped = "/owner/adressbook-" + db_type + ".vcf/"
|
||||
path_user = "/user/"
|
||||
path_shared_bday = path_user + "calendar-bday-abook-shared-by-owner-r-" + db_type + ".ics/"
|
||||
path_shared_map = path_user + "abook-shared-by-owner-r-" + db_type + ".vcf/"
|
||||
self.create_addressbook(path_mapped, login="owner:ownerpw")
|
||||
|
||||
contact = get_file_content("contact1.vcf")
|
||||
path = path_mapped + "/contact1.vcf"
|
||||
self.put(path, contact, login="owner:ownerpw")
|
||||
|
||||
contact = get_file_content("contact2-with-bday.vcf")
|
||||
path = path_mapped + "/contact2-with-bday.vcf"
|
||||
self.put(path, contact, login="owner:ownerpw")
|
||||
|
||||
contact = get_file_content("contact3-with-bday.vcf")
|
||||
path = path_mapped + "/contact3-with-bday.vcf"
|
||||
self.put(path, contact, login="owner:ownerpw")
|
||||
|
||||
# check PROPFIND as owner
|
||||
logging.info("\n*** PROPFIND collection owner -> ok")
|
||||
_, responses = self.propfind(path_mapped, """\
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<propfind xmlns="DAV:">
|
||||
<propname />
|
||||
</propfind>""", login="owner:ownerpw")
|
||||
logging.info("response: %r", responses)
|
||||
response = responses[path_mapped]
|
||||
assert not isinstance(response, int)
|
||||
assert "CR:supported-address-data" in response
|
||||
|
||||
# execute GET as owner
|
||||
logging.info("\n*** GET VCF collection owner -> ok")
|
||||
_, answer = self.get(path_mapped, login="owner:ownerpw")
|
||||
assert "contact1" in answer
|
||||
assert "contact2" in answer
|
||||
|
||||
# create bday
|
||||
logging.info("\n*** create bday user/owner:r -> ok")
|
||||
json_dict = {}
|
||||
json_dict['User'] = "user"
|
||||
json_dict['PathMapped'] = path_mapped
|
||||
json_dict['PathOrToken'] = path_shared_bday
|
||||
json_dict['Permissions'] = "r"
|
||||
json_dict['Enabled'] = True
|
||||
json_dict['Hidden'] = False
|
||||
_, headers, answer = self._sharing_api_json("bday", "create", check=200, login="owner:ownerpw", json_dict=json_dict)
|
||||
answer_dict = json.loads(answer)
|
||||
assert answer_dict['Status'] == "success"
|
||||
|
||||
# enable bday by user
|
||||
logging.info("\n*** enable bday by user")
|
||||
json_dict = {}
|
||||
json_dict['User'] = "user"
|
||||
json_dict['PathMapped'] = path_mapped
|
||||
json_dict['PathOrToken'] = path_shared_bday
|
||||
_, headers, answer = self._sharing_api_json("bday", "enable", check=200, login="user:userpw", json_dict=json_dict)
|
||||
|
||||
# unhide bday by user
|
||||
logging.info("\n*** unhide bday by user")
|
||||
json_dict = {}
|
||||
json_dict['User'] = "user"
|
||||
json_dict['PathMapped'] = path_mapped
|
||||
json_dict['PathOrToken'] = path_shared_bday
|
||||
_, headers, answer = self._sharing_api_json("bday", "unhide", check=200, login="user:userpw", json_dict=json_dict)
|
||||
|
||||
# create map
|
||||
logging.info("\n*** create map user/owner:r -> ok")
|
||||
json_dict = {}
|
||||
json_dict['User'] = "user"
|
||||
json_dict['PathMapped'] = path_mapped
|
||||
json_dict['PathOrToken'] = path_shared_map
|
||||
json_dict['Permissions'] = "r"
|
||||
json_dict['Enabled'] = True
|
||||
json_dict['Hidden'] = False
|
||||
_, headers, answer = self._sharing_api_json("map", "create", check=200, login="owner:ownerpw", json_dict=json_dict)
|
||||
answer_dict = json.loads(answer)
|
||||
assert answer_dict['Status'] == "success"
|
||||
|
||||
# enable map by user
|
||||
logging.info("\n*** enable map by user")
|
||||
json_dict = {}
|
||||
json_dict['User'] = "user"
|
||||
json_dict['PathMapped'] = path_mapped
|
||||
json_dict['PathOrToken'] = path_shared_map
|
||||
_, headers, answer = self._sharing_api_json("map", "enable", check=200, login="user:userpw", json_dict=json_dict)
|
||||
|
||||
# unhide map by user
|
||||
logging.info("\n*** unhide map by user")
|
||||
json_dict = {}
|
||||
json_dict['User'] = "user"
|
||||
json_dict['PathMapped'] = path_mapped
|
||||
json_dict['PathOrToken'] = path_shared_map
|
||||
_, headers, answer = self._sharing_api_json("map", "unhide", check=200, login="user:userpw", json_dict=json_dict)
|
||||
|
||||
# check PROPFIND item as user
|
||||
logging.info("\n*** PROPFIND all as user")
|
||||
_, responses = self.propfind(path_user, """\
|
||||
<?xml version="1.0"?>
|
||||
<propfind xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:CR="urn:ietf:params:xml:ns:carddav" xmlns:CS="http://calendarserver.org/ns/" xmlns:ICAL="http://apple.com/ns/ical/" xmlns:RADICALE="http://radicale.org/ns/" xmlns:ns3="http://inf-it.com/ns/ab/">
|
||||
<prop>
|
||||
<resourcetype />
|
||||
<RADICALE:displayname />
|
||||
<ICAL:calendar-color />
|
||||
<ns3:addressbook-color />
|
||||
<C:calendar-description />
|
||||
<C:supported-calendar-component-set />
|
||||
<CR:addressbook-description />
|
||||
<CS:source />
|
||||
<RADICALE:getcontentcount />
|
||||
<getcontentlength />
|
||||
</prop>
|
||||
</propfind>""", login="user:userpw", HTTP_DEPTH="1")
|
||||
# logging.debug("responses: %r", responses)
|
||||
response = responses[path_shared_map]
|
||||
assert not isinstance(response, int)
|
||||
logging.debug("response %r: %r", path_shared_map, response)
|
||||
assert "C:supported-calendar-component-set" in response
|
||||
response = responses[path_shared_bday]
|
||||
assert not isinstance(response, int)
|
||||
logging.debug("response %r: %r", path_shared_bday, response)
|
||||
assert "C:supported-calendar-component-set" in response
|
||||
|
||||
# check PROPFIND item as user
|
||||
logging.info("\n*** PROPFIND all as user (reduced)")
|
||||
_, responses = self.propfind(path_user, """\
|
||||
<?xml version="1.0"?>
|
||||
<propfind xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:CR="urn:ietf:params:xml:ns:carddav" xmlns:CS="http://calendarserver.org/ns/" xmlns:ICAL="http://apple.com/ns/ical/" xmlns:RADICALE="http://radicale.org/ns/" xmlns:ns3="http://inf-it.com/ns/ab/">
|
||||
<prop>
|
||||
<resourcetype />
|
||||
<C:calendar-description />
|
||||
<CR:addressbook-description />
|
||||
<RADICALE:getcontentcount />
|
||||
<getcontentlength />
|
||||
</prop>
|
||||
</propfind>""", login="user:userpw", HTTP_DEPTH="1")
|
||||
# logging.debug("responses: %r", responses)
|
||||
response = responses[path_shared_bday]
|
||||
assert not isinstance(response, int)
|
||||
logging.debug("response %r: %r", path_shared_bday, response)
|
||||
assert "D:getcontentlength" in response
|
||||
assert "RADICALE:getcontentcount" in response
|
||||
status, prop = response["D:getcontentlength"]
|
||||
assert int(str(prop.text)) > 600
|
||||
status, prop = response["RADICALE:getcontentcount"]
|
||||
assert int(str(prop.text)) == 2
|
||||
|
||||
def test_sharing_api_bday_self(self) -> None:
|
||||
"""share-by-bday to self tests."""
|
||||
self.configure({"auth": {"type": "htpasswd",
|
||||
"htpasswd_filename": self.htpasswd_file_path,
|
||||
"htpasswd_encryption": "plain"},
|
||||
"sharing": {
|
||||
"type": "csv",
|
||||
"permit_create_bday": True,
|
||||
"permit_properties_overlay": "True",
|
||||
"enforce_properties_overlay": "True",
|
||||
"collection_by_bday": "True"},
|
||||
"logging": {"request_header_on_debug": "False",
|
||||
"response_content_on_debug": "True",
|
||||
"request_content_on_debug": "True"},
|
||||
"rights": {"type": "owner_only"}})
|
||||
|
||||
json_dict: dict
|
||||
|
||||
logging.info("\n*** prepare and test access")
|
||||
|
||||
path_owner = "/owner/"
|
||||
|
||||
for db_type in list(filter(lambda item: item != "none", sharing.INTERNAL_TYPES)):
|
||||
logging.info("\n*** test: %s", db_type)
|
||||
self.configure({"sharing": {"type": db_type}})
|
||||
|
||||
path_mapped = path_owner + "abook-" + db_type + ".vcf/"
|
||||
path_shared = path_owner + "cal-bday-abook-" + db_type + ".ics/"
|
||||
self.create_addressbook(path_mapped, login="owner:ownerpw")
|
||||
|
||||
contact = get_file_content("contact1.vcf")
|
||||
path = path_mapped + "/contact1.vcf"
|
||||
self.put(path, contact, login="owner:ownerpw")
|
||||
|
||||
contact = get_file_content("contact2-with-bday.vcf")
|
||||
path = path_mapped + "/contact2-with-bday.vcf"
|
||||
self.put(path, contact, login="owner:ownerpw")
|
||||
|
||||
contact = get_file_content("contact3-with-bday.vcf")
|
||||
path = path_mapped + "/contact3-with-bday.vcf"
|
||||
self.put(path, contact, login="owner:ownerpw")
|
||||
|
||||
# check PROPFIND as owner
|
||||
logging.info("\n*** PROPFIND collection owner -> ok")
|
||||
_, responses = self.propfind(path_mapped, """\
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<propfind xmlns="DAV:">
|
||||
<propname />
|
||||
</propfind>""", login="owner:ownerpw")
|
||||
logging.info("response: %r", responses)
|
||||
response = responses[path_mapped]
|
||||
assert not isinstance(response, int)
|
||||
assert "CR:supported-address-data" in response
|
||||
|
||||
# execute GET as owner
|
||||
logging.info("\n*** GET VCF collection owner -> ok")
|
||||
_, answer = self.get(path_mapped, login="owner:ownerpw")
|
||||
assert "contact1" in answer
|
||||
assert "contact2" in answer
|
||||
assert "NICKNAME-C3" in answer
|
||||
|
||||
# create map
|
||||
logging.info("\n*** create bday owner to itself -> ok")
|
||||
json_dict = {}
|
||||
json_dict['User'] = "owner"
|
||||
json_dict['PathMapped'] = path_mapped
|
||||
json_dict['PathOrToken'] = path_shared
|
||||
json_dict['Enabled'] = False
|
||||
json_dict['Hidden'] = True
|
||||
_, headers, answer = self._sharing_api_json("bday", "create", check=200, login="owner:ownerpw", json_dict=json_dict)
|
||||
|
||||
# check PROPFIND item as owner
|
||||
logging.info("\n*** PROPFIND all as owner")
|
||||
_, responses = self.propfind(path_owner, """\
|
||||
<?xml version="1.0"?>
|
||||
<propfind xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:CR="urn:ietf:params:xml:ns:carddav" xmlns:CS="http://calendarserver.org/ns/" xmlns:ICAL="http://apple.com/ns/ical/" xmlns:RADICALE="http://radicale.org/ns/" xmlns:ns3="http://inf-it.com/ns/ab/">
|
||||
<prop>
|
||||
<resourcetype />
|
||||
<RADICALE:displayname />
|
||||
<ICAL:calendar-color />
|
||||
<ns3:addressbook-color />
|
||||
<C:calendar-description />
|
||||
<C:supported-calendar-component-set />
|
||||
<CR:addressbook-description />
|
||||
<CS:source />
|
||||
<RADICALE:getcontentcount />
|
||||
<getcontentlength />
|
||||
</prop>
|
||||
</propfind>""", login="owner:ownerpw", HTTP_DEPTH="1")
|
||||
# logging.debug("responses: %r", responses)
|
||||
response = responses[path_mapped]
|
||||
assert not isinstance(response, int)
|
||||
logging.debug("response %r: %r", path_mapped, response)
|
||||
assert "C:supported-calendar-component-set" in response
|
||||
assert path_shared not in responses
|
||||
|
||||
# enable + unhide
|
||||
logging.info("\n*** enable+unhide bday owner to itself -> ok")
|
||||
json_dict = {}
|
||||
json_dict['PathOrToken'] = path_shared
|
||||
json_dict['Enabled'] = True
|
||||
json_dict['Hidden'] = False
|
||||
_, headers, answer = self._sharing_api_json("bday", "update", check=200, login="owner:ownerpw", json_dict=json_dict)
|
||||
|
||||
# check PROPFIND item as owner
|
||||
logging.info("\n*** PROPFIND all as owner")
|
||||
_, responses = self.propfind(path_owner, """\
|
||||
<?xml version="1.0"?>
|
||||
<propfind xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:CR="urn:ietf:params:xml:ns:carddav" xmlns:CS="http://calendarserver.org/ns/" xmlns:ICAL="http://apple.com/ns/ical/" xmlns:RADICALE="http://radicale.org/ns/" xmlns:ns3="http://inf-it.com/ns/ab/">
|
||||
<prop>
|
||||
<resourcetype />
|
||||
<RADICALE:displayname />
|
||||
<ICAL:calendar-color />
|
||||
<ns3:addressbook-color />
|
||||
<C:calendar-description />
|
||||
<C:supported-calendar-component-set />
|
||||
<CR:addressbook-description />
|
||||
<CS:source />
|
||||
<RADICALE:getcontentcount />
|
||||
<getcontentlength />
|
||||
</prop>
|
||||
</propfind>""", login="owner:ownerpw", HTTP_DEPTH="1")
|
||||
# logging.debug("responses: %r", responses)
|
||||
response = responses[path_mapped]
|
||||
assert not isinstance(response, int)
|
||||
logging.debug("response %r: %r", path_mapped, response)
|
||||
assert "C:supported-calendar-component-set" in response
|
||||
assert path_shared in responses
|
||||
|
||||
# check PROPFIND item as owner
|
||||
logging.info("\n*** PROPFIND item as owner -> calendar")
|
||||
response = self._propfind_allprop(path_shared, login="owner:ownerpw")
|
||||
logging.debug("response: %r", response)
|
||||
assert "CR:supported-address-data" not in response
|
||||
assert "D:sync-token" not in response
|
||||
assert "C:supported-calendar-component-set" in response
|
||||
assert "D:current-user-privilege-set" in response
|
||||
|
||||
Reference in New Issue
Block a user