Skip to content

Commit 1e3eb14

Browse files
authored
Merge pull request #2003 from pbiering/sharing-properties-overlay
Sharing properties overlay
2 parents c72347c + 2fab856 commit 1e3eb14

File tree

7 files changed

+468
-177
lines changed

7 files changed

+468
-177
lines changed

SHARING.md

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ Types of supported sharing configuration:
2828
* `HiddenByUser`: control by user
2929
* `TimestampCreated`: unixtime of creation
3030
* `TimestampUpdated`: unixtime of last update
31+
* `Properties`: overlay properties (limited set whitelisted)
3132

3233
`Enabled*`: owner AND user have to enable a share to become usable
3334

@@ -39,7 +40,9 @@ Types of supported sharing configuration:
3940

4041
(_>= 3.7.0_)
4142

42-
One CSV file containing one row per sharing config, separated by `,` and containing header with columns from above.
43+
One CSV file containing one row per sharing config, separated by `;` and containing header with columns from above.
44+
45+
If given, properties are stored in JSON format in CSV.
4346

4447
#### Files
4548

@@ -120,6 +123,7 @@ Can be selected by `HTTP_ACCEPT`
120123
* `Permissions`: effective permission of the share
121124
* `Enabled`: owner/user selected by authentication
122125
* `Hidden`: owner/user selected by authentication
126+
* `Properties`: properties to overlay
123127

124128
#### API Hooks
125129

@@ -250,7 +254,9 @@ Update a share selected by `PathOrToken`
250254
| - | - | - |
251255
| PathOrToken | yes | n/a |
252256
| PathMapped | no | |
257+
| OwnerOrUser | yes | n/a |
253258
| User | no | |
259+
| Properties | no | |
254260

255261
* Output: result status
256262

@@ -282,4 +288,13 @@ ApiVersion=1
282288
Status=success
283289
```
284290

291+
## Properties Overlay
292+
293+
Owner or user can define per share a set of properties to overlay on PROPFIND response during create or update via API.
294+
295+
Whitelisted ones are defined in `OVERLAY_PROPERTIES_WHITELIST` in `radicale/sharing/__init__.py`:
285296

297+
* `C:calendar-description` (_>= 3.7.0_)
298+
* `ICAL:calendar-color` (_>= 3.7.0_)
299+
* `CR:addressbook-description` (_>= 3.7.0_)
300+
* `INF:addressbook-color` (_>= 3.7.0_)

radicale/app/propfind.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,10 @@ def xml_propfind_response(
345345
human_tag = xmlutils.make_human_tag(tag)
346346
tag_text = collection.get_meta(human_tag)
347347
if tag_text is not None:
348+
if sharing:
349+
# map from overlay
350+
if sharing['Properties'][human_tag] is not None:
351+
tag_text = sharing['Properties'][human_tag]
348352
element.text = tag_text
349353
else:
350354
is404 = True

radicale/sharing/__init__.py

Lines changed: 57 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333

3434
INTERNAL_TYPES: Sequence[str] = ("csv", "files", "none")
3535

36-
DB_FIELDS_V1: Sequence[str] = ('ShareType', 'PathOrToken', 'PathMapped', 'Owner', 'User', 'Permissions', 'EnabledByOwner', 'EnabledByUser', 'HiddenByOwner', 'HiddenByUser', 'TimestampCreated', 'TimestampUpdated')
36+
DB_FIELDS_V1: Sequence[str] = ('ShareType', 'PathOrToken', 'PathMapped', 'Owner', 'User', 'Permissions', 'EnabledByOwner', 'EnabledByUser', 'HiddenByOwner', 'HiddenByUser', 'TimestampCreated', 'TimestampUpdated', 'Properties')
3737
DB_FIELDS_V1_BOOL: Sequence[str] = ('EnabledByOwner', 'EnabledByUser', 'HiddenByOwner', 'HiddenByUser')
3838
DB_FIELDS_V1_INT: Sequence[str] = ('TimestampCreated', 'TimestampUpdated')
3939
# ShareType: <token|map>
@@ -76,6 +76,8 @@
7676

7777
USER_PATTERN: str = "([a-zA-Z0-9@]+)" # TODO: extend or find better source
7878

79+
OVERLAY_PROPERTIES_WHITELIST: Sequence[str] = ("C:calendar-description", "ICAL:calendar-color", "CR:addressbook-description", "INF:addressbook-color")
80+
7981

8082
def load(configuration: "config.Configuration") -> "BaseSharing":
8183
"""Load the sharing database module chosen in configuration."""
@@ -178,20 +180,22 @@ def create_sharing(self,
178180
Permissions: str = "r",
179181
EnabledByOwner: bool = False, EnabledByUser: bool = False,
180182
HiddenByOwner: bool = True, HiddenByUser: bool = True,
181-
Timestamp: int = 0) -> dict:
183+
Timestamp: int = 0,
184+
Properties: Union[dict, None] = None) -> dict:
182185
""" create sharing """
183186
return {"status": "not-implemented"}
184187

185188
def update_sharing(self,
186189
ShareType: str,
187190
PathOrToken: str,
188-
Owner: Union[str, None] = None,
191+
OwnerOrUser: str,
189192
User: Union[str, None] = None,
190193
PathMapped: Union[str, None] = None,
191194
Permissions: Union[str, None] = None,
192195
EnabledByOwner: Union[bool, None] = None,
193196
HiddenByOwner: Union[bool, None] = None,
194-
Timestamp: int = 0) -> dict:
197+
Timestamp: int = 0,
198+
Properties: Union[dict, None] = None) -> dict:
195199
""" update sharing """
196200
return {"status": "not-implemented"}
197201

@@ -391,6 +395,8 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st
391395
PathOrToken: <path> (mandatory)
392396
User: <target_user> (mandatory)
393397
398+
action: (token|map)/update
399+
394400
action: (token|map)/(delete|disable|enable|hide|unhide)
395401
PathOrToken: <path|token> (mandatory)
396402
@@ -408,7 +414,7 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st
408414
Status in JSON/TEXT (TEXT can be parsed by shell)
409415
410416
"""
411-
if not self.sharing_collection_by_map and not self.sharing_collection_by_token:
417+
if not self._enabled:
412418
# API is not enabled
413419
return httputils.NOT_FOUND
414420

@@ -420,7 +426,7 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st
420426
if not path.startswith("/.sharing/v1/"):
421427
return httputils.NOT_FOUND
422428

423-
# split into ShareType and action or "info"
429+
# split into ShareType and action
424430
ShareType_action = path.removeprefix("/.sharing/v1/")
425431
match = re.search('([a-z]+)/([a-z]+)$', ShareType_action)
426432
if not match:
@@ -481,7 +487,21 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st
481487
# convert arrays into single value
482488
request_data = {}
483489
for key in request_parsed:
484-
request_data[key] = request_parsed[key][0]
490+
if key == "Properties":
491+
# Properties key value parser
492+
properties_dict: dict = {}
493+
for entry in request_parsed[key]:
494+
m = re.search('^([^=]+)=([^=]+)$', entry)
495+
if not m:
496+
return httputils.bad_request("Invalid properties format in form")
497+
token = m.group(1).lstrip('"\'').rstrip('"\'')
498+
value = m.group(2).lstrip('"\'').rstrip('"\'')
499+
properties_dict[token] = value
500+
if logger.isEnabledFor(logging.DEBUG):
501+
logger.debug("TRACE/sharing/API: converted Properties from form into dict: %r", properties_dict)
502+
request_data[key] = properties_dict
503+
else:
504+
request_data[key] = request_parsed[key][0]
485505
if logger.isEnabledFor(logging.DEBUG):
486506
logger.debug("TRACE/" + api_info + " (form): %r", f"{request_data}")
487507
else:
@@ -518,6 +538,7 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st
518538
HiddenByOwner: Union[bool, None] = None
519539
EnabledByUser: Union[bool, None] = None
520540
HiddenByUser: Union[bool, None] = None
541+
Properties: Union[dict, None] = None
521542

522543
# parameters sanity check
523544
for key in request_data:
@@ -552,12 +573,9 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st
552573

553574
# check for mandatory parameters
554575
if 'PathMapped' not in request_data:
555-
if action == 'info':
576+
if action in ['info', 'list', 'update']:
556577
# ignored
557578
pass
558-
elif action == "list":
559-
# optional
560-
pass
561579
else:
562580
if ShareType == "token" and action != 'create':
563581
# optional
@@ -588,6 +606,13 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st
588606
if 'Permissions' in request_data:
589607
Permissions = request_data['Permissions']
590608

609+
if 'Properties' in request_data:
610+
# verify against whitelist
611+
for entry in request_data['Properties']:
612+
if entry not in OVERLAY_PROPERTIES_WHITELIST:
613+
return httputils.bad_request("Property not supported to overlay: %r" % entry)
614+
Properties = request_data['Properties']
615+
591616
if ShareType == "map":
592617
if action == 'info':
593618
# ignored
@@ -609,6 +634,11 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st
609634
answer['ApiVersion'] = 1
610635
Timestamp = int((datetime.now() - datetime(1970, 1, 1)).total_seconds())
611636

637+
if not self.sharing_collection_by_map and not self.sharing_collection_by_token:
638+
if not action == 'info':
639+
# API is not enabled
640+
return httputils.NOT_FOUND
641+
612642
# action: list
613643
if action == "list":
614644
if logger.isEnabledFor(logging.DEBUG):
@@ -691,7 +721,8 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st
691721
Owner=Owner, User=Owner,
692722
Permissions=str(Permissions), # mandantory
693723
EnabledByOwner=EnabledByOwner, HiddenByOwner=HiddenByOwner,
694-
Timestamp=Timestamp)
724+
Timestamp=Timestamp,
725+
Properties=Properties)
695726
if logger.isEnabledFor(logging.DEBUG):
696727
logger.debug("TRACE/" + api_info + ": result=%r", result)
697728

@@ -747,7 +778,8 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st
747778
Permissions=str(Permissions), # mandatory
748779
EnabledByOwner=EnabledByOwner, HiddenByOwner=HiddenByOwner,
749780
EnabledByUser=EnabledByUser, HiddenByUser=HiddenByUser,
750-
Timestamp=Timestamp)
781+
Timestamp=Timestamp,
782+
Properties=Properties)
751783

752784
else:
753785
logger.error(api_info + ": unsupported for ShareType=%r", ShareType)
@@ -784,8 +816,10 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st
784816
EnabledByOwner=EnabledByOwner,
785817
HiddenByOwner=HiddenByOwner,
786818
PathOrToken=str(PathOrToken), # verification above that it is not None
787-
Owner=Owner,
788-
Timestamp=Timestamp)
819+
OwnerOrUser=Owner,
820+
User=User,
821+
Timestamp=Timestamp,
822+
Properties=Properties)
789823

790824
elif ShareType == "map":
791825
result = self.update_sharing(
@@ -795,8 +829,10 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st
795829
EnabledByOwner=EnabledByOwner,
796830
HiddenByOwner=HiddenByOwner,
797831
PathOrToken=str(PathOrToken), # verification above that it is not None
798-
Owner=Owner,
799-
Timestamp=Timestamp)
832+
OwnerOrUser=Owner,
833+
User=User,
834+
Timestamp=Timestamp,
835+
Properties=Properties)
800836

801837
else:
802838
logger.error(api_info + ": unsupported for ShareType=%r", ShareType)
@@ -918,18 +954,19 @@ def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: st
918954
answer_array.append(key + '=' + str(answer[key]))
919955
if 'Content' in answer and answer['Content'] is not None:
920956
csv = io.StringIO()
921-
writer = DictWriter(csv, fieldnames=DB_FIELDS_V1)
957+
writer = DictWriter(csv, fieldnames=DB_FIELDS_V1, delimiter=';')
922958
if output_format == "csv":
923959
writer.writeheader()
924960
for entry in answer['Content']:
925-
writer.writerow(entry)
961+
# TODO: Argument 1 to "writerow" of "DictWriter" has incompatible type "str"; expected "Mapping[str, Any]" [arg-type]
962+
writer.writerow(entry) # type: ignore[arg-type]
926963
if output_format == "csv":
927964
answer_array.append(csv.getvalue())
928965
else:
929966
index = 0
930967
for line in csv.getvalue().splitlines():
931968
# create a shell array with content lines
932-
answer_array.append('Content[' + str(index) + ']="' + line + '"')
969+
answer_array.append('Content[' + str(index) + ']="' + line.replace('"', '\\"') + '"')
933970
index += 1
934971
headers = {
935972
"Content-Type": "text/csv"

0 commit comments

Comments
 (0)