Merge pull request #2098 from pbiering/regression-fixes
Regression fixes
This commit is contained in:
22
.github/workflows/test.yml
vendored
22
.github/workflows/test.yml
vendored
@@ -85,6 +85,24 @@ jobs:
|
|||||||
- name: Test with newest Python on latest Ubuntu using VFAT
|
- name: Test with newest Python on latest Ubuntu using VFAT
|
||||||
run: tox -c pyproject.toml -e py_filesystem_vfat
|
run: tox -c pyproject.toml -e py_filesystem_vfat
|
||||||
|
|
||||||
|
test-ubuntu-python-newest-with-vfat-utf8:
|
||||||
|
name: Test VFAT UTF-8 Python:newest Ubuntu:latest
|
||||||
|
needs: [lint, test-ubuntu-python-newest, test-ubuntu-python-newest-with-vfat]
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
os: [ubuntu-latest]
|
||||||
|
python-version: ['3.14']
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v5
|
||||||
|
- uses: actions/setup-python@v6
|
||||||
|
with:
|
||||||
|
python-version: ${{ matrix.python-version }}
|
||||||
|
- name: Install Test dependencies
|
||||||
|
run: pip install tox
|
||||||
|
- name: Test with newest Python on latest Ubuntu using VFAT UTF-8
|
||||||
|
run: tox -c pyproject.toml -e py_filesystem_vfat_utf8
|
||||||
|
|
||||||
test-ubuntu-python-newest-with-ntfs:
|
test-ubuntu-python-newest-with-ntfs:
|
||||||
name: Test NTFS Python:newest Ubuntu:latest
|
name: Test NTFS Python:newest Ubuntu:latest
|
||||||
needs: [lint, test-ubuntu-python-newest]
|
needs: [lint, test-ubuntu-python-newest]
|
||||||
@@ -163,7 +181,7 @@ jobs:
|
|||||||
|
|
||||||
test-otheros-python-newest:
|
test-otheros-python-newest:
|
||||||
name: Test MacOS/Windows:latest Python:newest
|
name: Test MacOS/Windows:latest Python:newest
|
||||||
needs: [lint, test-ubuntu-python-newest, test-ubuntu-python-newest-with-ntfs, test-ubuntu-python-newest-with-vfat]
|
needs: [lint, test-ubuntu-python-newest, test-ubuntu-python-newest-with-ntfs, test-ubuntu-python-newest-with-vfat, test-ubuntu-python-newest-with-vfat-utf8]
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
os: [macos-latest, windows-latest]
|
os: [macos-latest, windows-latest]
|
||||||
@@ -310,7 +328,7 @@ jobs:
|
|||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v5
|
||||||
- uses: actions/setup-python@v6
|
- uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: '3.12'
|
python-version: '3.14'
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
run: pip install uv
|
run: pip install uv
|
||||||
- name: Install Playwright Browsers
|
- name: Install Playwright Browsers
|
||||||
|
|||||||
@@ -8,6 +8,11 @@
|
|||||||
* Improve: application will stop on startup if TEMP is provided but not existing or not writable
|
* Improve: application will stop on startup if TEMP is provided but not existing or not writable
|
||||||
* Extension: tox with new optional test cases to test with LinuxOS vfat, hfsplus, ntfs filesystems
|
* Extension: tox with new optional test cases to test with LinuxOS vfat, hfsplus, ntfs filesystems
|
||||||
* Adjust: respond with 500 in case principal collection cannot be created (e.g. filesystem issues)
|
* Adjust: respond with 500 in case principal collection cannot be created (e.g. filesystem issues)
|
||||||
|
* Improve: sharing supports now unicode
|
||||||
|
* Add: [server] new options validate_user_value/validate_path_value for ability to block unwanted values (autoenable "strict" on non-unicode filesystem)
|
||||||
|
* Adjust: several log levels incl. final result depending on status code
|
||||||
|
* Fix: sharing/csv: quote handling and honor stock encoding
|
||||||
|
* Extension: sharing: add ICAL:calendar-order to property overlay whitelist
|
||||||
|
|
||||||
## 3.7.1
|
## 3.7.1
|
||||||
* Fix: share address book collection as birthday calendar not working on non-DEBUG level
|
* Fix: share address book collection as birthday calendar not working on non-DEBUG level
|
||||||
|
|||||||
@@ -1617,6 +1617,35 @@ Strict preconditions check on PUT in case item already exists [RFC6352#9.2](http
|
|||||||
|
|
||||||
Default: `False`
|
Default: `False`
|
||||||
|
|
||||||
|
##### validate_user_value
|
||||||
|
|
||||||
|
_(>= 3.7.2)_
|
||||||
|
|
||||||
|
Validate user value content
|
||||||
|
|
||||||
|
Available types are:
|
||||||
|
* `none`
|
||||||
|
* `minimal` (control and some special chars)
|
||||||
|
* `unicode-letter` (unicode letters)
|
||||||
|
* `no-unicode` (no unicode)
|
||||||
|
* `strict` (reduced ASCII set)
|
||||||
|
|
||||||
|
Default: `minimum`
|
||||||
|
|
||||||
|
##### validate_path_type
|
||||||
|
|
||||||
|
_(>= 3.7.2)_
|
||||||
|
|
||||||
|
Validate path value content
|
||||||
|
|
||||||
|
* `none`
|
||||||
|
* `minimal` (control and some special chars)
|
||||||
|
* `unicode-letter` (unicode letters)
|
||||||
|
* `no-unicode` (no unicode)
|
||||||
|
* `strict` (reduced ASCII set)
|
||||||
|
|
||||||
|
Default: `minimum`
|
||||||
|
|
||||||
##### hook
|
##### hook
|
||||||
|
|
||||||
Command that is run after changes to storage. See the
|
Command that is run after changes to storage. See the
|
||||||
|
|||||||
9
config
9
config
@@ -58,6 +58,14 @@
|
|||||||
# script name to strip from URI if called by reverse proxy
|
# script name to strip from URI if called by reverse proxy
|
||||||
#script_name = (default taken from HTTP_X_SCRIPT_NAME or SCRIPT_NAME)
|
#script_name = (default taken from HTTP_X_SCRIPT_NAME or SCRIPT_NAME)
|
||||||
|
|
||||||
|
# validate user type
|
||||||
|
# Value: none|minimal|unicode-letter|no-unicode|strict
|
||||||
|
#validate_user_value = minimal
|
||||||
|
|
||||||
|
# validate path value
|
||||||
|
# Value: none|minimal|unicode-letter|no-unicode|strict
|
||||||
|
#validate_path_value = minimal
|
||||||
|
|
||||||
|
|
||||||
[encoding]
|
[encoding]
|
||||||
|
|
||||||
@@ -424,6 +432,7 @@
|
|||||||
# This may become the default in future versions, override if you need a different CSP.
|
# This may become the default in future versions, override if you need a different CSP.
|
||||||
Content-Security-Policy = default-src 'self'; object-src 'none'
|
Content-Security-Policy = default-src 'self'; object-src 'none'
|
||||||
|
|
||||||
|
|
||||||
[hook]
|
[hook]
|
||||||
|
|
||||||
# Hook types
|
# Hook types
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ commands_pre = [
|
|||||||
# unconditionally create mount point
|
# unconditionally create mount point
|
||||||
["mkdir", "-p", "/tmp/vfat"],
|
["mkdir", "-p", "/tmp/vfat"],
|
||||||
# mount image
|
# mount image
|
||||||
["sudo", "/usr/bin/mount", "-o", "loop,umask=000", "/tmp/vfat.img", "/tmp/vfat"],
|
["sudo", "/usr/bin/mount", "-o", "loop,umask=000,utf8=no", "/tmp/vfat.img", "/tmp/vfat"],
|
||||||
]
|
]
|
||||||
setenv = { TEMP = "/tmp/vfat" }
|
setenv = { TEMP = "/tmp/vfat" }
|
||||||
commands = [["pytest", "-r", "s", "."]]
|
commands = [["pytest", "-r", "s", "."]]
|
||||||
@@ -114,6 +114,33 @@ commands_post = [
|
|||||||
["rm", "/tmp/vfat.img"]
|
["rm", "/tmp/vfat.img"]
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[tool.tox.env.py_filesystem_vfat_utf8]
|
||||||
|
allowlist_externals = [ "sudo", "dd", "mkfs.vfat", "mkdir", "rm", "rmdir", "chmod" ]
|
||||||
|
extras = ["test"]
|
||||||
|
deps = [
|
||||||
|
"pytest"
|
||||||
|
]
|
||||||
|
commands_pre = [
|
||||||
|
# create 64 MByte disk image
|
||||||
|
["dd", "if=/dev/zero", "of=/tmp/vfat-utf8.img", "bs=1M", "count=64"],
|
||||||
|
# create file system
|
||||||
|
["mkfs.vfat", "/tmp/vfat-utf8.img", "-n", "VFAT"],
|
||||||
|
# unconditionally create mount point
|
||||||
|
["mkdir", "-p", "/tmp/vfat-utf8"],
|
||||||
|
# mount image
|
||||||
|
["sudo", "/usr/bin/mount", "-o", "loop,umask=000,utf8", "/tmp/vfat-utf8.img", "/tmp/vfat-utf8"],
|
||||||
|
]
|
||||||
|
setenv = { TEMP = "/tmp/vfat-utf8" }
|
||||||
|
commands = [["pytest", "-r", "s", "."]]
|
||||||
|
commands_post = [
|
||||||
|
# umount image
|
||||||
|
["sudo", "/usr/bin/umount", "-d", "/tmp/vfat-utf8"],
|
||||||
|
# remove mount point
|
||||||
|
["rmdir", "/tmp/vfat-utf8"],
|
||||||
|
# remove image
|
||||||
|
["rm", "/tmp/vfat-utf8.img"]
|
||||||
|
]
|
||||||
|
|
||||||
[tool.tox.env.py_filesystem_hfsplus]
|
[tool.tox.env.py_filesystem_hfsplus]
|
||||||
# Fedora
|
# Fedora
|
||||||
allowlist_externals = [ "sudo", "dd", "mkfs.hfsplus", "mkdir", "rm", "rmdir", "chmod" ]
|
allowlist_externals = [ "sudo", "dd", "mkfs.hfsplus", "mkdir", "rm", "rmdir", "chmod" ]
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ from http import client
|
|||||||
from typing import Iterable, List, Mapping, Tuple, Union
|
from typing import Iterable, List, Mapping, Tuple, Union
|
||||||
|
|
||||||
from radicale import config, httputils, log, pathutils, types, utils
|
from radicale import config, httputils, log, pathutils, types, utils
|
||||||
|
from radicale.app import base as app_base
|
||||||
from radicale.app.base import ApplicationBase
|
from radicale.app.base import ApplicationBase
|
||||||
from radicale.app.delete import ApplicationPartDelete
|
from radicale.app.delete import ApplicationPartDelete
|
||||||
from radicale.app.get import ApplicationPartGet
|
from radicale.app.get import ApplicationPartGet
|
||||||
@@ -86,6 +87,8 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
|
|||||||
_profiling_per_request: bool = False
|
_profiling_per_request: bool = False
|
||||||
_profiling_per_request_method: bool = False
|
_profiling_per_request_method: bool = False
|
||||||
_limit_content: int
|
_limit_content: int
|
||||||
|
_validate_user_value: str
|
||||||
|
_validate_path_value: str
|
||||||
profiler_per_request_method: dict[str, cProfile.Profile] = {}
|
profiler_per_request_method: dict[str, cProfile.Profile] = {}
|
||||||
profiler_per_request_method_counter: dict[str, int] = {}
|
profiler_per_request_method_counter: dict[str, int] = {}
|
||||||
profiler_per_request_method_starttime: datetime.datetime
|
profiler_per_request_method_starttime: datetime.datetime
|
||||||
@@ -158,6 +161,19 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
|
|||||||
self._extra_headers[key] = configuration.get("headers", key)
|
self._extra_headers[key] = configuration.get("headers", key)
|
||||||
self._strict_preconditions = configuration.get("storage", "strict_preconditions")
|
self._strict_preconditions = configuration.get("storage", "strict_preconditions")
|
||||||
logger.info("strict preconditions check: %s", self._strict_preconditions)
|
logger.info("strict preconditions check: %s", self._strict_preconditions)
|
||||||
|
# Format checks
|
||||||
|
self._validate_user_value = configuration.get("server", "validate_user_value")
|
||||||
|
self._validate_path_value = configuration.get("server", "validate_path_value")
|
||||||
|
if not self._storage._supports_unicode:
|
||||||
|
if self._validate_user_value not in ["strict", "no-unicode"]:
|
||||||
|
self._validate_user_value = "no-unicode"
|
||||||
|
if self._validate_path_value not in ["strict", "no-unicode"]:
|
||||||
|
self._validate_path_value = "no-unicode"
|
||||||
|
logger.notice("validate user value: %r (enforced by limited support of collection storage)", self._validate_user_value)
|
||||||
|
logger.notice("validate path value: %r (enforced by limited support of collection storage)", self._validate_path_value)
|
||||||
|
else:
|
||||||
|
logger.info("validate user value: %r", self._validate_user_value)
|
||||||
|
logger.info("validate path value: %r", self._validate_path_value)
|
||||||
# Profiling options
|
# Profiling options
|
||||||
self._profiling = configuration.get("logging", "profiling")
|
self._profiling = configuration.get("logging", "profiling")
|
||||||
self._profiling_per_request_min_duration = configuration.get("logging", "profiling_per_request_min_duration")
|
self._profiling_per_request_min_duration = configuration.get("logging", "profiling_per_request_min_duration")
|
||||||
@@ -338,15 +354,23 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
|
|||||||
else:
|
else:
|
||||||
flags_text = ""
|
flags_text = ""
|
||||||
if answer is not None:
|
if answer is not None:
|
||||||
logger.info("%s response status for %r%s in %.3f seconds %s %s bytes%s: %s",
|
message = "%s response status for %r%s in %.3f seconds %s %s bytes%s: %s" % (
|
||||||
request_method, unsafe_path, depthinfo,
|
request_method, unsafe_path, depthinfo,
|
||||||
time_delta_seconds, content_encoding, str(len(answer)),
|
time_delta_seconds, content_encoding, str(len(answer)),
|
||||||
flags_text,
|
flags_text,
|
||||||
status_text)
|
status_text)
|
||||||
else:
|
else:
|
||||||
logger.info("%s response status for %r%s in %.3f seconds: %s",
|
message = "%s response status for %r%s in %.3f seconds: %s" % (
|
||||||
request_method, unsafe_path, depthinfo,
|
request_method, unsafe_path, depthinfo,
|
||||||
time_delta_seconds, status_text)
|
time_delta_seconds, status_text)
|
||||||
|
logger_method = logger.info # default
|
||||||
|
if status == 401 or status == 404 or status == 412 or status == 409 or request_method in ["PROPPATCH", "MKCALENDAR", "MKCOL"]:
|
||||||
|
logger_method = logger.notice
|
||||||
|
elif status >= 500 and status < 500:
|
||||||
|
logger_method = logger.error
|
||||||
|
elif status >= 500:
|
||||||
|
logger_method = logger.critical
|
||||||
|
logger_method(message)
|
||||||
|
|
||||||
# Profiling end
|
# Profiling end
|
||||||
if self._profiling_per_request:
|
if self._profiling_per_request:
|
||||||
@@ -459,6 +483,9 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
|
|||||||
logger.warning("Called by reverse proxy, cannot remove base prefix %r from path: %r as not matching (may cause authentication issues using internal WebUI)", base_prefix, path)
|
logger.warning("Called by reverse proxy, cannot remove base prefix %r from path: %r as not matching (may cause authentication issues using internal WebUI)", base_prefix, path)
|
||||||
else:
|
else:
|
||||||
logger.debug("Called by reverse proxy, cannot remove base prefix %r from path: %r as not matching", base_prefix, path)
|
logger.debug("Called by reverse proxy, cannot remove base prefix %r from path: %r as not matching", base_prefix, path)
|
||||||
|
if not app_base._check_path_format(self._storage, path, self._validate_path_value):
|
||||||
|
logger.error("request contains invalid path: %r (not compliant to %r)", path, self._validate_path_value)
|
||||||
|
return response(*httputils.BAD_REQUEST)
|
||||||
|
|
||||||
# Get function corresponding to method
|
# Get function corresponding to method
|
||||||
function = getattr(self, "do_%s" % request_method, None)
|
function = getattr(self, "do_%s" % request_method, None)
|
||||||
@@ -489,6 +516,10 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
|
|||||||
self.configuration, environ, base64.b64decode(
|
self.configuration, environ, base64.b64decode(
|
||||||
authorization.encode("ascii"))).split(":", 1)
|
authorization.encode("ascii"))).split(":", 1)
|
||||||
|
|
||||||
|
if login and not app_base._check_user_format(self._storage, login, self._validate_user_value):
|
||||||
|
info = "not compliant to %r" % self._validate_user_value
|
||||||
|
user = ""
|
||||||
|
else:
|
||||||
(user, info) = self._auth.login(login, password, context) or ("", "") if login else ("", "")
|
(user, info) = self._auth.login(login, password, context) or ("", "") if login else ("", "")
|
||||||
if self.configuration.get("auth", "type") == "ldap":
|
if self.configuration.get("auth", "type") == "ldap":
|
||||||
try:
|
try:
|
||||||
@@ -600,9 +631,9 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
|
|||||||
|
|
||||||
if (status, headers, answer, xml_request) == httputils.NOT_ALLOWED:
|
if (status, headers, answer, xml_request) == httputils.NOT_ALLOWED:
|
||||||
if path.startswith("/.token"):
|
if path.startswith("/.token"):
|
||||||
logger.info("Access to %r denied", path)
|
logger.notice("Access to %r denied", path)
|
||||||
else:
|
else:
|
||||||
logger.info("Access to %r denied for %s", path, repr(user) if user else "anonymous user")
|
logger.notice("Access to %r denied for %s", path, repr(user) if user else "anonymous user")
|
||||||
else:
|
else:
|
||||||
status, headers, answer, xml_request = httputils.NOT_ALLOWED
|
status, headers, answer, xml_request = httputils.NOT_ALLOWED
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,9 @@
|
|||||||
|
|
||||||
import io
|
import io
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
import sys
|
import sys
|
||||||
|
import unicodedata
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
from typing import Optional, Union
|
from typing import Optional, Union
|
||||||
|
|
||||||
@@ -30,6 +32,82 @@ from radicale.rights import intersect
|
|||||||
import defusedxml.ElementTree as DefusedET # isort:skip
|
import defusedxml.ElementTree as DefusedET # isort:skip
|
||||||
sys.modules["xml.etree"].ElementTree = ET # type:ignore[attr-defined]
|
sys.modules["xml.etree"].ElementTree = ET # type:ignore[attr-defined]
|
||||||
|
|
||||||
|
USER_PATTERN_STRICT: str = "a-zA-Z0-9@\\.\\-_"
|
||||||
|
PATH_PATTERN_STRICT: str = USER_PATTERN_STRICT + "\\/~" # / as separator
|
||||||
|
|
||||||
|
USER_PATTERN_STRICT_RE: str = "^[" + USER_PATTERN_STRICT + "]+$"
|
||||||
|
PATH_PATTERN_STRICT_RE: str = "^[" + PATH_PATTERN_STRICT + "]+$"
|
||||||
|
|
||||||
|
USER_BLACKLIST_MINIMAL: list = [":", "'", '"', '*', '?']
|
||||||
|
PATH_BLACKLIST_MINIMAL: list = USER_BLACKLIST_MINIMAL
|
||||||
|
|
||||||
|
USER_WHITELIST_UNICODE: list = ["-", ".", "@", "_"] # from USER_PATTERN_STRICT
|
||||||
|
PATH_WHITELIST_UNICODE: list = ["-", ".", "@", "_", "/", "~"] # from PATH_PATTERN_STRICT
|
||||||
|
|
||||||
|
|
||||||
|
def _check_format(self: storage.BaseStorage,
|
||||||
|
string: str,
|
||||||
|
blacklist_minimal: list[str],
|
||||||
|
whitelist_unicode: list[str],
|
||||||
|
validation_type: str,
|
||||||
|
) -> bool:
|
||||||
|
check_minimal = (validation_type == "minimal")
|
||||||
|
check_unicode_letter = (validation_type == "unicode-letter")
|
||||||
|
check_no_unicode = (validation_type == "no-unicode")
|
||||||
|
logger.trace("_check_format investigate %r (validation_type=%r check_minimal=%s check_unicode_letter=%s check_no_unicode=%s)", string, validation_type, check_minimal, check_unicode_letter, check_no_unicode)
|
||||||
|
if not self._supports_trailing_whitespace and string.endswith(' '):
|
||||||
|
return False
|
||||||
|
for c in string:
|
||||||
|
if c <= chr(31) or (c >= chr(127) and c <= chr(159)):
|
||||||
|
# ASCII: control char
|
||||||
|
return False
|
||||||
|
if unicodedata.category(c)[0] == "C":
|
||||||
|
# https://unicodeplus.com/category
|
||||||
|
# Unicode: control
|
||||||
|
return False
|
||||||
|
if check_minimal or not self._supports_problematic_chars:
|
||||||
|
if c in blacklist_minimal:
|
||||||
|
logger.trace("_check_format found %r", c)
|
||||||
|
return False
|
||||||
|
if check_unicode_letter:
|
||||||
|
if c not in whitelist_unicode:
|
||||||
|
if unicodedata.category(c)[0] != "L":
|
||||||
|
return False
|
||||||
|
if check_no_unicode:
|
||||||
|
if ord(c) > 255:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _check_user_format(self: storage.BaseStorage,
|
||||||
|
user: str,
|
||||||
|
validation_type: str
|
||||||
|
) -> bool:
|
||||||
|
if validation_type == "strict":
|
||||||
|
return (re.search(USER_PATTERN_STRICT_RE, user) is not None)
|
||||||
|
else:
|
||||||
|
return _check_format(self,
|
||||||
|
user,
|
||||||
|
USER_BLACKLIST_MINIMAL,
|
||||||
|
USER_WHITELIST_UNICODE,
|
||||||
|
validation_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _check_path_format(self: storage.BaseStorage,
|
||||||
|
path: str,
|
||||||
|
validation_type: str
|
||||||
|
) -> bool:
|
||||||
|
if validation_type == "strict":
|
||||||
|
return (re.search(PATH_PATTERN_STRICT_RE, path) is not None)
|
||||||
|
else:
|
||||||
|
return _check_format(self,
|
||||||
|
path,
|
||||||
|
PATH_BLACKLIST_MINIMAL,
|
||||||
|
PATH_WHITELIST_UNICODE,
|
||||||
|
validation_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ApplicationBase:
|
class ApplicationBase:
|
||||||
|
|
||||||
@@ -44,6 +122,8 @@ class ApplicationBase:
|
|||||||
_permit_delete_collection: bool
|
_permit_delete_collection: bool
|
||||||
_permit_overwrite_collection: bool
|
_permit_overwrite_collection: bool
|
||||||
_strict_preconditions: bool
|
_strict_preconditions: bool
|
||||||
|
_validate_user_value: str
|
||||||
|
_validate_path_format: str
|
||||||
_hook: hook.BaseHook
|
_hook: hook.BaseHook
|
||||||
|
|
||||||
def __init__(self, configuration: config.Configuration) -> None:
|
def __init__(self, configuration: config.Configuration) -> None:
|
||||||
@@ -58,6 +138,8 @@ class ApplicationBase:
|
|||||||
self._response_content_on_debug = configuration.get("logging", "response_content_on_debug")
|
self._response_content_on_debug = configuration.get("logging", "response_content_on_debug")
|
||||||
self._request_content_on_debug = configuration.get("logging", "request_content_on_debug")
|
self._request_content_on_debug = configuration.get("logging", "request_content_on_debug")
|
||||||
self._limit_content = configuration.get("logging", "limit_content")
|
self._limit_content = configuration.get("logging", "limit_content")
|
||||||
|
self._validate_user_value = configuration.get("server", "validate_user_value")
|
||||||
|
self._validate_path_value = configuration.get("server", "validate_path_value")
|
||||||
self._hook = hook.load(configuration)
|
self._hook = hook.load(configuration)
|
||||||
|
|
||||||
def _read_xml_request_body(self, environ: types.WSGIEnviron
|
def _read_xml_request_body(self, environ: types.WSGIEnviron
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ from http import client
|
|||||||
from urllib.parse import unquote, urlparse
|
from urllib.parse import unquote, urlparse
|
||||||
|
|
||||||
from radicale import httputils, pathutils, storage, types
|
from radicale import httputils, pathutils, storage, types
|
||||||
|
from radicale.app import base as app_base
|
||||||
from radicale.app.base import Access, ApplicationBase
|
from radicale.app.base import Access, ApplicationBase
|
||||||
from radicale.log import logger
|
from radicale.log import logger
|
||||||
|
|
||||||
@@ -82,6 +83,9 @@ class ApplicationPartMove(ApplicationBase):
|
|||||||
if not access.check("w"):
|
if not access.check("w"):
|
||||||
return httputils.NOT_ALLOWED
|
return httputils.NOT_ALLOWED
|
||||||
to_path = pathutils.sanitize_path(to_url.path)
|
to_path = pathutils.sanitize_path(to_url.path)
|
||||||
|
if not app_base._check_path_format(self._storage, to_path, self._validate_path_value):
|
||||||
|
logger.warning("request contains invalid path: %r (not compliant to %r)", to_path, self._validate_path_value)
|
||||||
|
return httputils.BAD_REQUEST
|
||||||
if not (to_path + "/").startswith(base_prefix + "/"):
|
if not (to_path + "/").startswith(base_prefix + "/"):
|
||||||
logger.warning("Destination %r from MOVE request on %r doesn't "
|
logger.warning("Destination %r from MOVE request on %r doesn't "
|
||||||
"start with base prefix", to_path, path)
|
"start with base prefix", to_path, path)
|
||||||
|
|||||||
@@ -127,13 +127,13 @@ class ApplicationPartProppatch(ApplicationBase):
|
|||||||
(not self._sharing.permit_properties_overlay and "P" not in raw_permissions and "P" not in share['Permissions'])):
|
(not self._sharing.permit_properties_overlay and "P" not in raw_permissions and "P" not in share['Permissions'])):
|
||||||
logger.info("PROPPATCH request on shared %r: write-access", path_orig)
|
logger.info("PROPPATCH request on shared %r: write-access", path_orig)
|
||||||
if permissions_filter is not None and "e" in permissions_filter:
|
if permissions_filter is not None and "e" in permissions_filter:
|
||||||
logger.info("PROPPATCH request on shared %r: write-access, overlay enforced, but disabled by share permission 'e'", path_orig)
|
logger.notice("PROPPATCH request on shared %r: write-access, overlay enforced, but disabled by share permission 'e'", path_orig)
|
||||||
elif "e" in raw_permissions:
|
elif "e" in raw_permissions:
|
||||||
logger.info("PROPPATCH request on shared %r: write-access, overlay enforced, but disabled by rights permission 'e'", path_orig)
|
logger.notice("PROPPATCH request on shared %r: write-access, overlay enforced, but disabled by rights permission 'e'", path_orig)
|
||||||
else:
|
else:
|
||||||
share_overlay = True
|
share_overlay = True
|
||||||
else:
|
else:
|
||||||
logger.info("PROPPATCH request on shared %r: no write-access", path_orig)
|
logger.notice("PROPPATCH request on shared %r: no write-access", path_orig)
|
||||||
return httputils.NOT_ALLOWED
|
return httputils.NOT_ALLOWED
|
||||||
else:
|
else:
|
||||||
return httputils.NOT_ALLOWED
|
return httputils.NOT_ALLOWED
|
||||||
@@ -144,9 +144,9 @@ class ApplicationPartProppatch(ApplicationBase):
|
|||||||
logger.trace("PROPPATCH/xml_proppatch: write-access/sharing: %r", path_orig)
|
logger.trace("PROPPATCH/xml_proppatch: write-access/sharing: %r", path_orig)
|
||||||
if self._sharing.enforce_properties_overlay:
|
if self._sharing.enforce_properties_overlay:
|
||||||
if permissions_filter is not None and "e" in permissions_filter:
|
if permissions_filter is not None and "e" in permissions_filter:
|
||||||
logger.info("PROPPATCH request on shared %r: write-permissions, overlay enforced, but disabled by share permission 'e'", path_orig)
|
logger.notice("PROPPATCH request on shared %r: write-permissions, overlay enforced, but disabled by share permission 'e'", path_orig)
|
||||||
elif "e" in raw_permissions:
|
elif "e" in raw_permissions:
|
||||||
logger.info("PROPPATCH request on shared %r: write-permissions, overlay enforced, but disabled by rights permission 'e'", path_orig)
|
logger.notice("PROPPATCH request on shared %r: write-permissions, overlay enforced, but disabled by rights permission 'e'", path_orig)
|
||||||
else:
|
else:
|
||||||
share_overlay = True
|
share_overlay = True
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -365,7 +365,7 @@ class ApplicationPartPut(ApplicationBase):
|
|||||||
errno_match = re.search("\\[Errno ([0-9]+)\\]", str(e))
|
errno_match = re.search("\\[Errno ([0-9]+)\\]", str(e))
|
||||||
if errno_match:
|
if errno_match:
|
||||||
logger.error(
|
logger.error(
|
||||||
"Failed PUT request on %r (upload): %s", path, e, exc_info=True)
|
"Failed PUT request on %r (upload): %s", path, e, exc_info=False)
|
||||||
errno_e = int(errno_match.group(1))
|
errno_e = int(errno_match.group(1))
|
||||||
if errno_e == errno.ENOSPC:
|
if errno_e == errno.ENOSPC:
|
||||||
return httputils.INSUFFICIENT_STORAGE
|
return httputils.INSUFFICIENT_STORAGE
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ DEFAULT_CONFIG_PATH: str = os.pathsep.join([
|
|||||||
|
|
||||||
PROFILING: Sequence[str] = ("per_request", "per_request_method", "none")
|
PROFILING: Sequence[str] = ("per_request", "per_request_method", "none")
|
||||||
|
|
||||||
|
VALIDATE_TYPES: Sequence[str] = ("none", "minimal", "unicode-letter", "unicode-none", "strict")
|
||||||
|
|
||||||
|
|
||||||
def positive_int(value: Any) -> int:
|
def positive_int(value: Any) -> int:
|
||||||
value = int(value)
|
value = int(value)
|
||||||
@@ -84,6 +86,12 @@ def logging_level(value: Any) -> str:
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def validate_types(value: Any) -> str:
|
||||||
|
if value not in VALIDATE_TYPES:
|
||||||
|
raise ValueError("unsupported validation type: %r" % value)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
def profiling(value: Any) -> str:
|
def profiling(value: Any) -> str:
|
||||||
if value not in PROFILING:
|
if value not in PROFILING:
|
||||||
raise ValueError("unsupported profiling: %r" % value)
|
raise ValueError("unsupported profiling: %r" % value)
|
||||||
@@ -221,6 +229,14 @@ DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([
|
|||||||
"value": "",
|
"value": "",
|
||||||
"help": "script name to strip from URI if called by reverse proxy (default taken from HTTP_X_SCRIPT_NAME or SCRIPT_NAME)",
|
"help": "script name to strip from URI if called by reverse proxy (default taken from HTTP_X_SCRIPT_NAME or SCRIPT_NAME)",
|
||||||
"type": str}),
|
"type": str}),
|
||||||
|
("validate_user_value", {
|
||||||
|
"value": "minimal",
|
||||||
|
"help": "validate user value (" + "|".join(VALIDATE_TYPES) + ")",
|
||||||
|
"type": validate_types}),
|
||||||
|
("validate_path_value", {
|
||||||
|
"value": "minimal",
|
||||||
|
"help": "validate path value (" + "|".join(VALIDATE_TYPES) + ")",
|
||||||
|
"type": validate_types}),
|
||||||
("_internal_server", {
|
("_internal_server", {
|
||||||
"value": "False",
|
"value": "False",
|
||||||
"help": "the internal server is used",
|
"help": "the internal server is used",
|
||||||
|
|||||||
@@ -289,22 +289,14 @@ def path_to_filesystem(root: str, sane_path: str, path_is_collision_free: bool =
|
|||||||
raise UnsafePathError(part)
|
raise UnsafePathError(part)
|
||||||
safe_path_parent = safe_path
|
safe_path_parent = safe_path
|
||||||
safe_path = os.path.join(safe_path, part)
|
safe_path = os.path.join(safe_path, part)
|
||||||
|
if not path_is_collision_free:
|
||||||
# Check for conflicting files (e.g. case-insensitive file systems
|
# Check for conflicting files (e.g. case-insensitive file systems
|
||||||
# or short names on Windows file systems)
|
# or short names on Windows file systems)
|
||||||
if not path_is_collision_free:
|
|
||||||
if sys.platform == "win32" and False: # temporary for testing
|
|
||||||
# logger.trace("path_to_filesystem check (win32): %r", part)
|
|
||||||
# if (os.path.lexists(safe_path) and not os.path.realpath(safe_path).endswith(part)) and not os.path.islink(safe_path):
|
|
||||||
if (os.path.lexists(safe_path) and not os.path.realpath(safe_path).endswith(part)):
|
|
||||||
raise CollidingPathError(part)
|
|
||||||
else:
|
|
||||||
# logger.trace("path_to_filesystem check (!win32): %r", part)
|
|
||||||
if os.path.lexists(safe_path):
|
if os.path.lexists(safe_path):
|
||||||
with os.scandir(safe_path_parent) as entries:
|
with os.scandir(safe_path_parent) as entries:
|
||||||
if part not in (e.name for e in entries):
|
if part not in (e.name for e in entries):
|
||||||
raise CollidingPathError(part)
|
raise CollidingPathError(part)
|
||||||
else:
|
else:
|
||||||
# logger.trace("path_to_filesystem check (skipped): %r", part)
|
|
||||||
pass
|
pass
|
||||||
return safe_path
|
return safe_path
|
||||||
|
|
||||||
|
|||||||
@@ -121,11 +121,7 @@ API_TYPES_V1: dict[str, type] = {
|
|||||||
|
|
||||||
TOKEN_PATTERN_V1: str = "v1/[a-zA-Z0-9_\\-]{44}"
|
TOKEN_PATTERN_V1: str = "v1/[a-zA-Z0-9_\\-]{44}"
|
||||||
|
|
||||||
PATH_PATTERN: str = "([a-zA-Z0-9/.\\-@]+)" # TODO: extend or find better source
|
OVERLAY_PROPERTIES_WHITELIST: Sequence[str] = ("C:calendar-description", "ICAL:calendar-color", "CR:addressbook-description", "INF:addressbook-color", "D:displayname", "ICAL:calendar-order")
|
||||||
|
|
||||||
USER_PATTERN: str = "([a-zA-Z0-9@.]+)" # TODO: extend or find better source
|
|
||||||
|
|
||||||
OVERLAY_PROPERTIES_WHITELIST: Sequence[str] = ("C:calendar-description", "ICAL:calendar-color", "CR:addressbook-description", "INF:addressbook-color", "D:displayname")
|
|
||||||
|
|
||||||
CONVERSIONS_WHITELIST: Sequence[str] = ("bday", "none")
|
CONVERSIONS_WHITELIST: Sequence[str] = ("bday", "none")
|
||||||
|
|
||||||
@@ -141,6 +137,7 @@ class BaseSharing:
|
|||||||
_rights: rights.BaseRights
|
_rights: rights.BaseRights
|
||||||
_auth_delay: float
|
_auth_delay: float
|
||||||
_enabled: bool = False
|
_enabled: bool = False
|
||||||
|
_encoding: str
|
||||||
default_permissions_create_token: str
|
default_permissions_create_token: str
|
||||||
default_permissions_create_map: str
|
default_permissions_create_map: str
|
||||||
sharing_db_type: str
|
sharing_db_type: str
|
||||||
@@ -157,6 +154,9 @@ class BaseSharing:
|
|||||||
self._rights = rights.load(configuration)
|
self._rights = rights.load(configuration)
|
||||||
self._storage = storage.load(configuration)
|
self._storage = storage.load(configuration)
|
||||||
self._auth_delay = configuration.get("auth", "delay")
|
self._auth_delay = configuration.get("auth", "delay")
|
||||||
|
self._encoding = configuration.get("encoding", "stock")
|
||||||
|
self._validate_user_value = configuration.get("server", "validate_user_value")
|
||||||
|
self._validate_path_value = configuration.get("server", "validate_path_value")
|
||||||
# Sharing
|
# Sharing
|
||||||
self.sharing_collection_by_map = configuration.get("sharing", "collection_by_map")
|
self.sharing_collection_by_map = configuration.get("sharing", "collection_by_map")
|
||||||
self.sharing_collection_by_token = configuration.get("sharing", "collection_by_token")
|
self.sharing_collection_by_token = configuration.get("sharing", "collection_by_token")
|
||||||
@@ -508,6 +508,7 @@ class BaseSharing:
|
|||||||
# *** POST API ***
|
# *** POST API ***
|
||||||
def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: str) -> types.WSGIResponse:
|
def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: str) -> types.WSGIResponse:
|
||||||
# Late import to avoid circular dependency in config
|
# Late import to avoid circular dependency in config
|
||||||
|
from radicale.app import base as app_base
|
||||||
from radicale.app.base import Access
|
from radicale.app.base import Access
|
||||||
|
|
||||||
"""POST request.
|
"""POST request.
|
||||||
@@ -724,20 +725,20 @@ class BaseSharing:
|
|||||||
logger.warning(api_info + ": unsupported " + key)
|
logger.warning(api_info + ": unsupported " + key)
|
||||||
return httputils.bad_request("Invalid value for PathOrToken")
|
return httputils.bad_request("Invalid value for PathOrToken")
|
||||||
else:
|
else:
|
||||||
if not re.search('^' + PATH_PATTERN + '$', request_data[key]):
|
if not app_base._check_path_format(self._storage, request_data[key], self._validate_path_value):
|
||||||
logger.warning(api_info + ": unsupported " + key)
|
logger.warning("%s: invalid %r: %r (not compliant to %r)", api_info, key, request_data[key], self._validate_path_value)
|
||||||
return httputils.bad_request("Invalid value for PathOrToken")
|
return httputils.bad_request("Invalid value for PathOrToken")
|
||||||
if not request_data[key].endswith("/"):
|
if not request_data[key].endswith("/"):
|
||||||
return httputils.bad_request("PathOrToken not ending with /")
|
return httputils.bad_request("PathOrToken not ending with /")
|
||||||
elif key == "PathMapped":
|
elif key == "PathMapped":
|
||||||
if not re.search('^' + PATH_PATTERN + '$', request_data[key]):
|
if not app_base._check_path_format(self._storage, request_data[key], self._validate_path_value):
|
||||||
logger.warning(api_info + ": unsupported " + key)
|
logger.warning("%s: invalid %r: %r (not compliant to %r)", api_info, key, request_data[key], self._validate_path_value)
|
||||||
return httputils.bad_request("Invalid value for PathMapped")
|
return httputils.bad_request("Invalid value for PathMapped")
|
||||||
elif not request_data[key].endswith("/"):
|
elif not request_data[key].endswith("/"):
|
||||||
return httputils.bad_request("PathMapped not ending with /")
|
return httputils.bad_request("PathMapped not ending with /")
|
||||||
elif key == "User":
|
elif key == "User":
|
||||||
if not re.search('^' + USER_PATTERN + '$', request_data[key]):
|
if not app_base._check_user_format(self._storage, request_data[key], self._validate_user_value):
|
||||||
logger.warning(api_info + ": unsupported " + key)
|
logger.warning("%s: invalid %r: %r (not compliant to %r)", api_info, key, request_data[key], self._validate_user_value)
|
||||||
return httputils.bad_request("Invalid value for User")
|
return httputils.bad_request("Invalid value for User")
|
||||||
|
|
||||||
# check for optional parameters
|
# check for optional parameters
|
||||||
@@ -852,6 +853,12 @@ class BaseSharing:
|
|||||||
logger.warning(api_info + ": missing PathMapped")
|
logger.warning(api_info + ": missing PathMapped")
|
||||||
return httputils.bad_request("Missing PathMapped")
|
return httputils.bad_request("Missing PathMapped")
|
||||||
|
|
||||||
|
# check access Permissions
|
||||||
|
access = Access(self._rights, user, PathMapped, None)
|
||||||
|
if not access.check("r"):
|
||||||
|
logger.warning(api_info + ": access to PathMapped=%r not allowed for owner %r", PathMapped, user)
|
||||||
|
return httputils.NOT_ALLOWED
|
||||||
|
|
||||||
if Conversion is None:
|
if Conversion is None:
|
||||||
Conversion = "none"
|
Conversion = "none"
|
||||||
|
|
||||||
@@ -902,12 +909,6 @@ class BaseSharing:
|
|||||||
HiddenByUser = Hidden
|
HiddenByUser = Hidden
|
||||||
|
|
||||||
if ShareType == "token":
|
if ShareType == "token":
|
||||||
# check access Permissions
|
|
||||||
access = Access(self._rights, user, PathMapped)
|
|
||||||
if not access.check("r"):
|
|
||||||
logger.warning(api_info + ": access to PathMapped=%r not allowed for owner %r", PathMapped, user)
|
|
||||||
return httputils.NOT_ALLOWED
|
|
||||||
|
|
||||||
if self.permit_create_token is False:
|
if self.permit_create_token is False:
|
||||||
if "t" not in access.permissions:
|
if "t" not in access.permissions:
|
||||||
logger.warning(api_info + ": access to PathMapped=%r not allowed for owner %r (permit=False but explict grant misses 't')", PathMapped, user)
|
logger.warning(api_info + ": access to PathMapped=%r not allowed for owner %r (permit=False but explict grant misses 't')", PathMapped, user)
|
||||||
@@ -971,12 +972,6 @@ class BaseSharing:
|
|||||||
logger.warning(api_info + ": share already exists with PathMapped=%r User=%r Conversion=%r", PathMapped, User, Conversion)
|
logger.warning(api_info + ": share already exists with PathMapped=%r User=%r Conversion=%r", PathMapped, User, Conversion)
|
||||||
return httputils.CONFLICT
|
return httputils.CONFLICT
|
||||||
|
|
||||||
# check access Permissions
|
|
||||||
access = Access(self._rights, user, PathMapped, None) # PathMapped is mandatory
|
|
||||||
if not access.check("r") and "i" not in access.permissions:
|
|
||||||
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 self.permit_create_map is False:
|
||||||
if "m" not in access.permissions:
|
if "m" not in access.permissions:
|
||||||
logger.warning(api_info + ": access to PathMapped=%r not allowed for owner %r (permit=False but explicit grant misses 'm')", PathMapped, user)
|
logger.warning(api_info + ": access to PathMapped=%r not allowed for owner %r (permit=False but explicit grant misses 'm')", PathMapped, user)
|
||||||
@@ -1041,7 +1036,7 @@ class BaseSharing:
|
|||||||
else:
|
else:
|
||||||
answer['PathOrToken'] = token
|
answer['PathOrToken'] = token
|
||||||
|
|
||||||
logger.info(api_info + " success: PathMapped=%r Permissions=%r PathOrToken=%r", PathMapped, Permissions, PathOrToken)
|
logger.notice(api_info + " success: PathMapped=%r Permissions=%r PathOrToken=%r", PathMapped, Permissions, PathOrToken)
|
||||||
|
|
||||||
# action: update
|
# action: update
|
||||||
elif action == "update":
|
elif action == "update":
|
||||||
@@ -1173,6 +1168,8 @@ class BaseSharing:
|
|||||||
logger.warning(api_info + ": %r not successful", request_data['PathOrToken'])
|
logger.warning(api_info + ": %r not successful", request_data['PathOrToken'])
|
||||||
return httputils.bad_request("Internal Error")
|
return httputils.bad_request("Internal Error")
|
||||||
|
|
||||||
|
logger.notice(api_info + " success: PathMapped=%r PathOrToken=%r", PathMapped, PathOrToken)
|
||||||
|
|
||||||
# action: delete
|
# action: delete
|
||||||
elif action == "delete":
|
elif action == "delete":
|
||||||
logger.trace("" + api_info + ": start")
|
logger.trace("" + api_info + ": start")
|
||||||
@@ -1213,6 +1210,8 @@ class BaseSharing:
|
|||||||
logger.warning(api_info + ": %r by user %r not successful", request_data['PathOrToken'], request_data['User'])
|
logger.warning(api_info + ": %r by user %r not successful", request_data['PathOrToken'], request_data['User'])
|
||||||
return httputils.bad_request("Internal Error")
|
return httputils.bad_request("Internal Error")
|
||||||
|
|
||||||
|
logger.notice(api_info + " success: PathMapped=%r PathOrToken=%r", PathMapped, PathOrToken)
|
||||||
|
|
||||||
# action: info
|
# action: info
|
||||||
elif action == "info":
|
elif action == "info":
|
||||||
logger.info(api_info + ": success")
|
logger.info(api_info + ": success")
|
||||||
@@ -1301,6 +1300,8 @@ class BaseSharing:
|
|||||||
logger.warning(api_info + ": %r by user %s not successful", request_data['PathOrToken'], user)
|
logger.warning(api_info + ": %r by user %s not successful", request_data['PathOrToken'], user)
|
||||||
return httputils.bad_request("Internal Error")
|
return httputils.bad_request("Internal Error")
|
||||||
|
|
||||||
|
logger.notice(api_info + " success: PathMapped=%r PathOrToken=%r", PathMapped, PathOrToken)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# default
|
# default
|
||||||
logger.warning(api_info + ": unsupported action=%r", action)
|
logger.warning(api_info + ": unsupported action=%r", action)
|
||||||
|
|||||||
@@ -400,7 +400,7 @@ class Sharing(sharing.BaseSharing):
|
|||||||
logger.debug("sharing database load begin: %r", file)
|
logger.debug("sharing database load begin: %r", file)
|
||||||
self._sharing_cache = []
|
self._sharing_cache = []
|
||||||
with self._storage.acquire_lock("r", None):
|
with self._storage.acquire_lock("r", None):
|
||||||
with open(file, 'r', newline='') as csvfile:
|
with open(file, 'r', newline='', encoding=self._encoding) as csvfile:
|
||||||
reader = csv.DictReader(csvfile, fieldnames=sharing.DB_FIELDS_V1, delimiter=';')
|
reader = csv.DictReader(csvfile, fieldnames=sharing.DB_FIELDS_V1, delimiter=';')
|
||||||
self._lines = 0
|
self._lines = 0
|
||||||
for row in reader:
|
for row in reader:
|
||||||
@@ -435,7 +435,14 @@ class Sharing(sharing.BaseSharing):
|
|||||||
if row[fieldname] is None or row[fieldname] == '':
|
if row[fieldname] is None or row[fieldname] == '':
|
||||||
row[fieldname] = {}
|
row[fieldname] = {}
|
||||||
else:
|
else:
|
||||||
field = row[fieldname].lstrip('"').rstrip('"').replace("'", '"')
|
field = row[fieldname].lstrip('"').rstrip('"')
|
||||||
|
logger.trace("json prep quote replacer match (before): %s", field)
|
||||||
|
field = field.replace('"', '\\"').replace("\\'", "'") # escape "
|
||||||
|
field = field.replace("{'", '{"') # replace for JSON start {' -> {"
|
||||||
|
field = field.replace("'}", '"}') # replace for JSON end '} -> "}
|
||||||
|
field = field.replace("': '", '": "') # replace for JSON entry/value ': ' -> ": "
|
||||||
|
field = field.replace("', '", '", "') # replace for JSON delimiter ', ' -> ", "
|
||||||
|
logger.trace("json prep quote replacer match (after) : %s", field)
|
||||||
try:
|
try:
|
||||||
row[fieldname] = json.loads(field)
|
row[fieldname] = json.loads(field)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -453,7 +460,7 @@ class Sharing(sharing.BaseSharing):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def _write_csv(self, file: str) -> bool:
|
def _write_csv(self, file: str) -> bool:
|
||||||
with open(file, 'w', newline='') as csvfile:
|
with open(file, 'w', newline='', encoding=self._encoding) as csvfile:
|
||||||
writer = csv.DictWriter(csvfile, fieldnames=sharing.DB_FIELDS_V1, delimiter=';')
|
writer = csv.DictWriter(csvfile, fieldnames=sharing.DB_FIELDS_V1, delimiter=';')
|
||||||
writer.writerows(self._sharing_cache)
|
writer.writerows(self._sharing_cache)
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -302,6 +302,11 @@ class BaseCollection:
|
|||||||
|
|
||||||
class BaseStorage:
|
class BaseStorage:
|
||||||
|
|
||||||
|
_is_collision_free: bool = False
|
||||||
|
_supports_unicode: bool = False
|
||||||
|
_supports_trailing_whitespace: bool = False
|
||||||
|
_supports_problematic_chars: bool = False
|
||||||
|
|
||||||
def __init__(self, configuration: "config.Configuration") -> None:
|
def __init__(self, configuration: "config.Configuration") -> None:
|
||||||
"""Initialize BaseStorage.
|
"""Initialize BaseStorage.
|
||||||
|
|
||||||
|
|||||||
@@ -173,16 +173,19 @@ class Storage(
|
|||||||
self._makedirs_synced(self._get_collection_root_folder())
|
self._makedirs_synced(self._get_collection_root_folder())
|
||||||
logger.info("Storage location subfolder permissions: %s", pathutils.path_permissions_as_string(self._get_collection_root_folder()))
|
logger.info("Storage location subfolder permissions: %s", pathutils.path_permissions_as_string(self._get_collection_root_folder()))
|
||||||
logger.info("Storage location subfolder softlink support: %s", pathutils.path_supports_symlink(self._get_collection_root_folder()))
|
logger.info("Storage location subfolder softlink support: %s", pathutils.path_supports_symlink(self._get_collection_root_folder()))
|
||||||
filesystem_root_folder_is_collision_free_case_sensitive = pathutils.path_is_collision_free_case_sensitive(self._get_collection_root_folder())
|
is_collision_free_case_sensitive = pathutils.path_is_collision_free_case_sensitive(self._get_collection_root_folder())
|
||||||
filesystem_root_folder_is_collision_free_no_short_filename = pathutils.path_is_collision_free_no_short_filename(self._get_collection_root_folder())
|
is_collision_free_no_short_filename = pathutils.path_is_collision_free_no_short_filename(self._get_collection_root_folder())
|
||||||
self._filesystem_root_folder_is_collision_free = filesystem_root_folder_is_collision_free_case_sensitive and filesystem_root_folder_is_collision_free_no_short_filename
|
self._is_collision_free = is_collision_free_case_sensitive and is_collision_free_no_short_filename
|
||||||
|
self._supports_unicode = pathutils.path_supports_unicode(self._get_collection_root_folder())
|
||||||
|
self._supports_trailing_whitespace = pathutils.path_supports_trailing_whitespace(self._get_collection_root_folder())
|
||||||
|
self._supports_problematic_chars = pathutils.path_supports_problematic_chars(self._get_collection_root_folder())
|
||||||
logger.info("Storage location subfolder is collision free: %s (case-sensitive=%s no-short-filename=%s)",
|
logger.info("Storage location subfolder is collision free: %s (case-sensitive=%s no-short-filename=%s)",
|
||||||
self._filesystem_root_folder_is_collision_free,
|
self._is_collision_free,
|
||||||
filesystem_root_folder_is_collision_free_case_sensitive,
|
is_collision_free_case_sensitive,
|
||||||
filesystem_root_folder_is_collision_free_no_short_filename)
|
is_collision_free_no_short_filename)
|
||||||
logger.info("Storage location subfolder suppports unicode: %s", pathutils.path_supports_unicode(self._get_collection_root_folder()))
|
logger.info("Storage location subfolder supports unicode: %s", self._supports_unicode)
|
||||||
logger.info("Storage location subfolder suppports trailing whitespace: %s", pathutils.path_supports_trailing_whitespace(self._get_collection_root_folder()))
|
logger.info("Storage location subfolder supports trailing whitespace: %s", self._supports_trailing_whitespace)
|
||||||
logger.info("Storage location subfolder suppports problematic chars: %s", pathutils.path_supports_problematic_chars(self._get_collection_root_folder()))
|
logger.info("Storage location subfolder supports problematic chars: %s", self._supports_problematic_chars)
|
||||||
logger.info("Storage cache subfolder usage for 'item': %s", self._use_cache_subfolder_for_item)
|
logger.info("Storage cache subfolder usage for 'item': %s", self._use_cache_subfolder_for_item)
|
||||||
logger.info("Storage cache subfolder usage for 'history': %s", self._use_cache_subfolder_for_history)
|
logger.info("Storage cache subfolder usage for 'history': %s", self._use_cache_subfolder_for_history)
|
||||||
logger.info("Storage cache subfolder usage for 'sync-token': %s", self._use_cache_subfolder_for_synctoken)
|
logger.info("Storage cache subfolder usage for 'sync-token': %s", self._use_cache_subfolder_for_synctoken)
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ class CollectionBase(storage.BaseCollection):
|
|||||||
_path: str
|
_path: str
|
||||||
_encoding: str
|
_encoding: str
|
||||||
_filesystem_path: str
|
_filesystem_path: str
|
||||||
_filesystem_root_folder_is_collision_free: bool
|
|
||||||
|
|
||||||
def __init__(self, storage_: "multifilesystem.Storage", path: str,
|
def __init__(self, storage_: "multifilesystem.Storage", path: str,
|
||||||
filesystem_path: Optional[str] = None) -> None:
|
filesystem_path: Optional[str] = None) -> None:
|
||||||
@@ -43,9 +42,12 @@ class CollectionBase(storage.BaseCollection):
|
|||||||
self._path = pathutils.strip_path(path)
|
self._path = pathutils.strip_path(path)
|
||||||
self._encoding = storage_.configuration.get("encoding", "stock")
|
self._encoding = storage_.configuration.get("encoding", "stock")
|
||||||
self._skip_broken_item = storage_.configuration.get("storage", "skip_broken_item")
|
self._skip_broken_item = storage_.configuration.get("storage", "skip_broken_item")
|
||||||
self._filesystem_root_folder_is_collision_free = storage_._filesystem_root_folder_is_collision_free
|
self._is_collision_free = storage_._is_collision_free
|
||||||
|
self._supports_unicode = storage_._supports_unicode
|
||||||
|
self._supports_trailing_whitespace = storage_._supports_trailing_whitespace
|
||||||
|
self._supports_problematic_chars = storage_._supports_problematic_chars
|
||||||
if filesystem_path is None:
|
if filesystem_path is None:
|
||||||
filesystem_path = pathutils.path_to_filesystem(folder, self.path, self._filesystem_root_folder_is_collision_free)
|
filesystem_path = pathutils.path_to_filesystem(folder, self.path, self._is_collision_free)
|
||||||
self._filesystem_path = filesystem_path
|
self._filesystem_path = filesystem_path
|
||||||
|
|
||||||
# TODO: better fix for "mypy"
|
# TODO: better fix for "mypy"
|
||||||
@@ -81,7 +83,6 @@ class StorageBase(storage.BaseStorage):
|
|||||||
_folder_umask: str
|
_folder_umask: str
|
||||||
_config_umask: int
|
_config_umask: int
|
||||||
_max_resource_size: int
|
_max_resource_size: int
|
||||||
_filesystem_root_folder_is_collision_free: bool = False
|
|
||||||
|
|
||||||
def __init__(self, configuration: config.Configuration) -> None:
|
def __init__(self, configuration: config.Configuration) -> None:
|
||||||
super().__init__(configuration)
|
super().__init__(configuration)
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ class StoragePartCreateCollection(StorageBase):
|
|||||||
|
|
||||||
# Path should already be sanitized
|
# Path should already be sanitized
|
||||||
sane_path = pathutils.strip_path(href)
|
sane_path = pathutils.strip_path(href)
|
||||||
filesystem_path = pathutils.path_to_filesystem(folder, sane_path, self._filesystem_root_folder_is_collision_free)
|
filesystem_path = pathutils.path_to_filesystem(folder, sane_path, self._is_collision_free)
|
||||||
logger.debug("Create collection: %r" % filesystem_path)
|
logger.debug("Create collection: %r" % filesystem_path)
|
||||||
|
|
||||||
if not props:
|
if not props:
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ class CollectionPartDelete(CollectionPartHistory, CollectionBase):
|
|||||||
# Delete an item
|
# Delete an item
|
||||||
if not pathutils.is_safe_filesystem_path_component(href):
|
if not pathutils.is_safe_filesystem_path_component(href):
|
||||||
raise pathutils.UnsafePathError(href)
|
raise pathutils.UnsafePathError(href)
|
||||||
path = pathutils.path_to_filesystem(self._filesystem_path, href, self._filesystem_root_folder_is_collision_free)
|
path = pathutils.path_to_filesystem(self._filesystem_path, href, self._is_collision_free)
|
||||||
if not os.path.isfile(path):
|
if not os.path.isfile(path):
|
||||||
raise storage.ComponentNotFoundError(href)
|
raise storage.ComponentNotFoundError(href)
|
||||||
os.remove(path)
|
os.remove(path)
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ class StoragePartDiscover(StorageBase):
|
|||||||
# Create the root collection
|
# Create the root collection
|
||||||
self._makedirs_synced(folder)
|
self._makedirs_synced(folder)
|
||||||
try:
|
try:
|
||||||
filesystem_path = pathutils.path_to_filesystem(folder, sane_path, self._filesystem_root_folder_is_collision_free)
|
filesystem_path = pathutils.path_to_filesystem(folder, sane_path, self._is_collision_free)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
# Path is unsafe
|
# Path is unsafe
|
||||||
logger.warning("Unsafe path %r requested from storage: %s",
|
logger.warning("Unsafe path %r requested from storage: %s",
|
||||||
@@ -110,7 +110,7 @@ class StoragePartDiscover(StorageBase):
|
|||||||
href = base64.b64encode(group.encode('utf-8')).decode('ascii')
|
href = base64.b64encode(group.encode('utf-8')).decode('ascii')
|
||||||
logger.debug(f"searching for group calendar {group} {href}")
|
logger.debug(f"searching for group calendar {group} {href}")
|
||||||
sane_child_path = f"GROUPS/{href}"
|
sane_child_path = f"GROUPS/{href}"
|
||||||
if not os.path.isdir(pathutils.path_to_filesystem(folder, sane_child_path, self._filesystem_root_folder_is_collision_free)):
|
if not os.path.isdir(pathutils.path_to_filesystem(folder, sane_child_path, self._is_collision_free)):
|
||||||
continue
|
continue
|
||||||
child_path = f"/GROUPS/{href}/"
|
child_path = f"/GROUPS/{href}/"
|
||||||
with child_context_manager(sane_child_path, None):
|
with child_context_manager(sane_child_path, None):
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ class CollectionPartGet(CollectionPartCache, CollectionPartLock,
|
|||||||
raise pathutils.UnsafePathError(href)
|
raise pathutils.UnsafePathError(href)
|
||||||
path = pathutils.path_to_filesystem(self._filesystem_path,
|
path = pathutils.path_to_filesystem(self._filesystem_path,
|
||||||
href,
|
href,
|
||||||
self._filesystem_root_folder_is_collision_free)
|
self._is_collision_free)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Can't translate name %r safely to filesystem in %r: %s",
|
"Can't translate name %r safely to filesystem in %r: %s",
|
||||||
|
|||||||
@@ -35,8 +35,8 @@ class StoragePartMove(StorageBase):
|
|||||||
assert isinstance(to_collection, multifilesystem.Collection)
|
assert isinstance(to_collection, multifilesystem.Collection)
|
||||||
assert isinstance(item.collection, multifilesystem.Collection)
|
assert isinstance(item.collection, multifilesystem.Collection)
|
||||||
assert item.href
|
assert item.href
|
||||||
move_from = pathutils.path_to_filesystem(item.collection._filesystem_path, item.href, self._filesystem_root_folder_is_collision_free)
|
move_from = pathutils.path_to_filesystem(item.collection._filesystem_path, item.href, self._is_collision_free)
|
||||||
move_to = pathutils.path_to_filesystem(to_collection._filesystem_path, to_href, self._filesystem_root_folder_is_collision_free)
|
move_to = pathutils.path_to_filesystem(to_collection._filesystem_path, to_href, self._is_collision_free)
|
||||||
try:
|
try:
|
||||||
os.replace(move_from, move_to)
|
os.replace(move_from, move_to)
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ class CollectionPartUpload(CollectionPartGet, CollectionPartCache,
|
|||||||
) -> Tuple[radicale_item.Item, Optional[radicale_item.Item]]:
|
) -> Tuple[radicale_item.Item, Optional[radicale_item.Item]]:
|
||||||
if not pathutils.is_safe_filesystem_path_component(href):
|
if not pathutils.is_safe_filesystem_path_component(href):
|
||||||
raise pathutils.UnsafePathError(href)
|
raise pathutils.UnsafePathError(href)
|
||||||
path = pathutils.path_to_filesystem(self._filesystem_path, href, self._filesystem_root_folder_is_collision_free)
|
path = pathutils.path_to_filesystem(self._filesystem_path, href, self._is_collision_free)
|
||||||
old_item = self._get(href, verify_href=False)
|
old_item = self._get(href, verify_href=False)
|
||||||
try:
|
try:
|
||||||
with self._atomic_write(path, newline="") as fo: # type: ignore
|
with self._atomic_write(path, newline="") as fo: # type: ignore
|
||||||
|
|||||||
@@ -94,6 +94,14 @@ class TestBaseAuthRequests(BaseTest):
|
|||||||
def test_htpasswd_plain(self) -> None:
|
def test_htpasswd_plain(self) -> None:
|
||||||
self._test_htpasswd("plain", "tmp:bepo")
|
self._test_htpasswd("plain", "tmp:bepo")
|
||||||
|
|
||||||
|
def test_htpasswd_blacklist_plain(self) -> None:
|
||||||
|
self._test_htpasswd("plain", "tmp:be:po", (
|
||||||
|
("tm" + chr(9) + "p", "be:po", True), ("tm" + chr(9) + "p", "bepo", False)), check=401)
|
||||||
|
self._test_htpasswd("plain", "tmp:be:po", (
|
||||||
|
("tm'p", "be:po", True), ("tm'p", "bepo", False)), check=401)
|
||||||
|
self._test_htpasswd("plain", "tmp:be:po", (
|
||||||
|
('tm"p', "be:po", True), ('tm"p', "bepo", False)), check=401)
|
||||||
|
|
||||||
def test_htpasswd_plain_autodetect(self) -> None:
|
def test_htpasswd_plain_autodetect(self) -> None:
|
||||||
self._test_htpasswd("autodetect", "tmp:bepo")
|
self._test_htpasswd("autodetect", "tmp:bepo")
|
||||||
|
|
||||||
@@ -103,11 +111,44 @@ class TestBaseAuthRequests(BaseTest):
|
|||||||
|
|
||||||
def test_htpasswd_plain_unicode(self) -> None:
|
def test_htpasswd_plain_unicode(self) -> None:
|
||||||
if not pathutils.path_supports_unicode(self.colpath):
|
if not pathutils.path_supports_unicode(self.colpath):
|
||||||
check = 500
|
check = 401
|
||||||
else:
|
else:
|
||||||
check = 207
|
check = 207
|
||||||
self._test_htpasswd("plain", "😀:🔑", "unicode", check=check)
|
self._test_htpasswd("plain", "😀:🔑", "unicode", check=check)
|
||||||
|
|
||||||
|
def test_htpasswd_strict_plain_unicode(self) -> None:
|
||||||
|
"""user with unicode chars is not permitted"""
|
||||||
|
self.configure({"server": {"validate_user_value": "strict"}})
|
||||||
|
if not pathutils.path_supports_unicode(self.colpath):
|
||||||
|
check = 401
|
||||||
|
else:
|
||||||
|
check = 401
|
||||||
|
self._test_htpasswd("plain", "😀:🔑", "unicode", check=check)
|
||||||
|
|
||||||
|
def test_htpasswd_minimal_plain_unicode(self) -> None:
|
||||||
|
"""user with unicode chars is permitted"""
|
||||||
|
self.configure({"server": {"validate_user_value": "minimal"}})
|
||||||
|
if not pathutils.path_supports_unicode(self.colpath):
|
||||||
|
check = 401
|
||||||
|
else:
|
||||||
|
check = 207
|
||||||
|
self._test_htpasswd("plain", "😀:🔑", "unicode", check=check)
|
||||||
|
|
||||||
|
def test_htpasswd_minimal_plain_special(self) -> None:
|
||||||
|
"""user with special chars is not permitted"""
|
||||||
|
self.configure({"server": {"validate_user_value": "minimal"}})
|
||||||
|
check = 401
|
||||||
|
self._test_htpasswd("plain", "*?*:bepo", "ascii", check=check)
|
||||||
|
|
||||||
|
def test_htpasswd_unicode_plain_unicode(self) -> None:
|
||||||
|
"""user with unicode symbols is not permitted"""
|
||||||
|
self.configure({"server": {"validate_user_value": "unicode-letter"}})
|
||||||
|
if not pathutils.path_supports_unicode(self.colpath):
|
||||||
|
check = 401
|
||||||
|
else:
|
||||||
|
check = 401
|
||||||
|
self._test_htpasswd("plain", "😀:🔑", "unicode", check=check)
|
||||||
|
|
||||||
def test_htpasswd_md5(self) -> None:
|
def test_htpasswd_md5(self) -> None:
|
||||||
self._test_htpasswd("md5", "tmp:$apr1$BI7VKCZh$GKW4vq2hqDINMr8uv7lDY/")
|
self._test_htpasswd("md5", "tmp:$apr1$BI7VKCZh$GKW4vq2hqDINMr8uv7lDY/")
|
||||||
|
|
||||||
@@ -116,7 +157,7 @@ class TestBaseAuthRequests(BaseTest):
|
|||||||
|
|
||||||
def test_htpasswd_md5_unicode(self):
|
def test_htpasswd_md5_unicode(self):
|
||||||
if not pathutils.path_supports_unicode(self.colpath):
|
if not pathutils.path_supports_unicode(self.colpath):
|
||||||
check = 500
|
check = 401
|
||||||
else:
|
else:
|
||||||
check = 207
|
check = 207
|
||||||
self._test_htpasswd(
|
self._test_htpasswd(
|
||||||
@@ -175,7 +216,7 @@ class TestBaseAuthRequests(BaseTest):
|
|||||||
@pytest.mark.skipif(has_bcrypt == 0, reason="No bcrypt module installed")
|
@pytest.mark.skipif(has_bcrypt == 0, reason="No bcrypt module installed")
|
||||||
def test_htpasswd_bcrypt_unicode(self) -> None:
|
def test_htpasswd_bcrypt_unicode(self) -> None:
|
||||||
if not pathutils.path_supports_unicode(self.colpath):
|
if not pathutils.path_supports_unicode(self.colpath):
|
||||||
check = 500
|
check = 401
|
||||||
else:
|
else:
|
||||||
check = 207
|
check = 207
|
||||||
self._test_htpasswd("bcrypt", "😀:$2y$10$Oyz5aHV4MD9eQJbk6GPemOs4T6edK6U9Sqlzr.W1mMVCS8wJUftnW", "unicode", check=check)
|
self._test_htpasswd("bcrypt", "😀:$2y$10$Oyz5aHV4MD9eQJbk6GPemOs4T6edK6U9Sqlzr.W1mMVCS8wJUftnW", "unicode", check=check)
|
||||||
@@ -295,16 +336,27 @@ class TestBaseAuthRequests(BaseTest):
|
|||||||
def test_htpasswd_whitespace_user(self) -> None:
|
def test_htpasswd_whitespace_user(self) -> None:
|
||||||
for user in (" tmp", "tmp ", " tmp "):
|
for user in (" tmp", "tmp ", " tmp "):
|
||||||
if not pathutils.path_supports_trailing_whitespace(self.colpath) and user.endswith(' '):
|
if not pathutils.path_supports_trailing_whitespace(self.colpath) and user.endswith(' '):
|
||||||
check = 500
|
check = 401
|
||||||
else:
|
else:
|
||||||
check = 207
|
check = 207
|
||||||
self._test_htpasswd("plain", "%s:bepo" % user, (
|
self._test_htpasswd("plain", "%s:bepo" % user, (
|
||||||
(user, "bepo", True), ("tmp", "bepo", False)), check=check)
|
(user, "bepo", True), ("tmp", "bepo", False)), check=check)
|
||||||
|
|
||||||
def test_htpasswd_problem_user(self) -> None:
|
def test_htpasswd_problem_user_none(self) -> None:
|
||||||
|
self.configure({"server": {"validate_user_value": "none"}})
|
||||||
for user in ("tm*p", "tm?p"):
|
for user in ("tm*p", "tm?p"):
|
||||||
if not pathutils.path_supports_problematic_chars(self.colpath):
|
if not pathutils.path_supports_problematic_chars(self.colpath):
|
||||||
check = 500
|
check = 401
|
||||||
|
else:
|
||||||
|
check = 207
|
||||||
|
self._test_htpasswd("plain", "%s:bepo" % user, (
|
||||||
|
(user, "bepo", True), ("tmp", "bepo", False)), check=check)
|
||||||
|
|
||||||
|
def test_htpasswd_problem_user_minimal(self) -> None:
|
||||||
|
self.configure({"server": {"validate_user_value": "none"}})
|
||||||
|
for user in ("tm*p", "tm?p"):
|
||||||
|
if not pathutils.path_supports_problematic_chars(self.colpath):
|
||||||
|
check = 401
|
||||||
else:
|
else:
|
||||||
check = 207
|
check = 207
|
||||||
self._test_htpasswd("plain", "%s:bepo" % user, (
|
self._test_htpasswd("plain", "%s:bepo" % user, (
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ import defusedxml.ElementTree as DefusedET
|
|||||||
import pytest
|
import pytest
|
||||||
import vobject
|
import vobject
|
||||||
|
|
||||||
from radicale import storage, utils, xmlutils
|
from radicale import pathutils, storage, utils, xmlutils
|
||||||
from radicale.tests import RESPONSES, BaseTest
|
from radicale.tests import RESPONSES, BaseTest
|
||||||
from radicale.tests.helpers import get_file_content
|
from radicale.tests.helpers import get_file_content
|
||||||
|
|
||||||
@@ -623,6 +623,56 @@ permissions: RrWw""")
|
|||||||
self.get(path1, check=404)
|
self.get(path1, check=404)
|
||||||
self.get(path2)
|
self.get(path2)
|
||||||
|
|
||||||
|
def test_move_unicode(self) -> None:
|
||||||
|
"""Move a item."""
|
||||||
|
self.mkcalendar("/calendar.ics/")
|
||||||
|
event = get_file_content("event1.ics")
|
||||||
|
path1 = "/calendar.ics/event😀1.ics"
|
||||||
|
path2 = "/calendar.ics/event😁2.ics"
|
||||||
|
if not pathutils.path_supports_unicode(self.colpath):
|
||||||
|
check_put = 400
|
||||||
|
check_move = 400
|
||||||
|
check_get1 = 400
|
||||||
|
check_get2 = 400
|
||||||
|
else:
|
||||||
|
check_put = 201
|
||||||
|
check_move = 201
|
||||||
|
check_get1 = 200
|
||||||
|
check_get2 = 404
|
||||||
|
self.put(path1, event, check=check_put)
|
||||||
|
self.get(path1, check=check_get1)
|
||||||
|
self.get(path2, check=check_get2)
|
||||||
|
self.request("MOVE", path1, check=check_move,
|
||||||
|
HTTP_DESTINATION="http://127.0.0.1/"+path2)
|
||||||
|
self.get(path1, check=check_get2)
|
||||||
|
self.get(path2, check=check_get1)
|
||||||
|
|
||||||
|
def test_move_strict_unicode_dst(self) -> None:
|
||||||
|
"""Move a item."""
|
||||||
|
self.configure({"server": {"validate_path_value": "strict"}})
|
||||||
|
self.mkcalendar("/calendar.ics/")
|
||||||
|
event = get_file_content("event1.ics")
|
||||||
|
path1 = "/calendar.ics/event1.ics"
|
||||||
|
path2 = "/calendar.ics/event😁2.ics"
|
||||||
|
self.put(path1, event)
|
||||||
|
self.request("MOVE", path1, check=400,
|
||||||
|
HTTP_DESTINATION="http://127.0.0.1/"+path2)
|
||||||
|
self.get(path1, check=200)
|
||||||
|
self.get(path2, check=400)
|
||||||
|
|
||||||
|
def test_move_strict_unicode_src(self) -> None:
|
||||||
|
"""Move a item."""
|
||||||
|
self.configure({"server": {"validate_path_value": "strict"}})
|
||||||
|
self.mkcalendar("/calendar.ics/")
|
||||||
|
event = get_file_content("event1.ics")
|
||||||
|
path1 = "/calendar.ics/event😀1.ics"
|
||||||
|
path2 = "/calendar.ics/event2.ics"
|
||||||
|
self.put(path1, event, check=400)
|
||||||
|
self.request("MOVE", path1, check=400,
|
||||||
|
HTTP_DESTINATION="http://127.0.0.1/"+path2)
|
||||||
|
self.get(path1, check=400)
|
||||||
|
self.get(path2, check=404)
|
||||||
|
|
||||||
def test_move_between_collections(self) -> None:
|
def test_move_between_collections(self) -> None:
|
||||||
"""Move a item."""
|
"""Move a item."""
|
||||||
self.mkcalendar("/calendar1.ics/")
|
self.mkcalendar("/calendar1.ics/")
|
||||||
|
|||||||
@@ -26,9 +26,12 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
|
import tempfile
|
||||||
from typing import Dict, Sequence, Tuple, Union
|
from typing import Dict, Sequence, Tuple, Union
|
||||||
|
|
||||||
from radicale import sharing, xmlutils
|
import pytest
|
||||||
|
|
||||||
|
from radicale import pathutils, sharing, xmlutils
|
||||||
from radicale.tests import BaseTest
|
from radicale.tests import BaseTest
|
||||||
from radicale.tests.helpers import get_file_content
|
from radicale.tests.helpers import get_file_content
|
||||||
|
|
||||||
@@ -45,6 +48,7 @@ class TestSharingApiSanity(BaseTest):
|
|||||||
encoding: str = self.configuration.get("encoding", "stock")
|
encoding: str = self.configuration.get("encoding", "stock")
|
||||||
htpasswd = ["owner:ownerpw", "user:userpw",
|
htpasswd = ["owner:ownerpw", "user:userpw",
|
||||||
"owner1:owner1pw", "user1:user1pw",
|
"owner1:owner1pw", "user1:user1pw",
|
||||||
|
"us😀er:user😀pw",
|
||||||
"owner2:owner2pw", "user2:user2pw"]
|
"owner2:owner2pw", "user2:user2pw"]
|
||||||
htpasswd_content = "\n".join(htpasswd)
|
htpasswd_content = "\n".join(htpasswd)
|
||||||
with open(self.htpasswd_file_path, "w", encoding=encoding) as f:
|
with open(self.htpasswd_file_path, "w", encoding=encoding) as f:
|
||||||
@@ -1391,6 +1395,7 @@ class TestSharingApiSanity(BaseTest):
|
|||||||
json_dict: dict
|
json_dict: dict
|
||||||
|
|
||||||
path_shared = "/user/calendarUP-shared-by-owner.ics/"
|
path_shared = "/user/calendarUP-shared-by-owner.ics/"
|
||||||
|
path_shared2 = "/user/calendarUP-shared-by-owner2.ics/"
|
||||||
path_mapped = "/owner/calendarUP.ics/"
|
path_mapped = "/owner/calendarUP.ics/"
|
||||||
path_mapped2 = "/owner/calendarUP2.ics/"
|
path_mapped2 = "/owner/calendarUP2.ics/"
|
||||||
path_mapped_o2 = "/owner2/calendarUP3.ics/"
|
path_mapped_o2 = "/owner2/calendarUP3.ics/"
|
||||||
@@ -1425,6 +1430,12 @@ class TestSharingApiSanity(BaseTest):
|
|||||||
json_dict['PathOrToken'] = path_shared
|
json_dict['PathOrToken'] = path_shared
|
||||||
_, headers, answer = self._sharing_api_json("map", "update", check=200, login="owner:ownerpw", json_dict=json_dict)
|
_, headers, answer = self._sharing_api_json("map", "update", check=200, login="owner:ownerpw", json_dict=json_dict)
|
||||||
|
|
||||||
|
logging.info("\n*** update map by owner: PathOrToken (json->json) -> 404 (is primary key, therefore not found)")
|
||||||
|
json_dict = {}
|
||||||
|
json_dict['PathMapped'] = path_mapped
|
||||||
|
json_dict['PathOrToken'] = path_shared2
|
||||||
|
_, headers, answer = self._sharing_api_json("map", "update", check=404, login="owner:ownerpw", json_dict=json_dict)
|
||||||
|
|
||||||
logging.info("\n*** update map by owner: PathMapped(owner2) (json->json) -> 403")
|
logging.info("\n*** update map by owner: PathMapped(owner2) (json->json) -> 403")
|
||||||
json_dict = {}
|
json_dict = {}
|
||||||
json_dict['PathMapped'] = path_mapped_o2
|
json_dict['PathMapped'] = path_mapped_o2
|
||||||
@@ -3308,6 +3319,8 @@ permissions: RrWw""")
|
|||||||
json_dict: dict
|
json_dict: dict
|
||||||
|
|
||||||
path_owner1 = "/owner1/calendarPGo1.ics/"
|
path_owner1 = "/owner1/calendarPGo1.ics/"
|
||||||
|
path_owner1 = "/owner1/calendarPGo1.ics/"
|
||||||
|
path_owner2 = "/owner2/calendarPGo1.ics/"
|
||||||
path_owner1_rw = "/owner1/calendarPGo1rw.ics/"
|
path_owner1_rw = "/owner1/calendarPGo1rw.ics/"
|
||||||
path_owner1_RrWw = "/owner1/calendarPGo1RrWw.ics/"
|
path_owner1_RrWw = "/owner1/calendarPGo1RrWw.ics/"
|
||||||
path_user1_r = "/user1/calendarPGu1-r.ics/"
|
path_user1_r = "/user1/calendarPGu1-r.ics/"
|
||||||
@@ -3326,6 +3339,15 @@ permissions: RrWw""")
|
|||||||
# create map
|
# create map
|
||||||
self.configure({"sharing": {"default_permissions_create_map": "r"}})
|
self.configure({"sharing": {"default_permissions_create_map": "r"}})
|
||||||
|
|
||||||
|
logging.info("\n*** create map user1/owner1 with path of owner 2-> 403")
|
||||||
|
json_dict = {}
|
||||||
|
json_dict['User'] = "user1"
|
||||||
|
json_dict['PathMapped'] = path_owner2
|
||||||
|
json_dict['PathOrToken'] = path_user1_r
|
||||||
|
json_dict['Enabled'] = True
|
||||||
|
json_dict['Hidden'] = False
|
||||||
|
_, headers, answer = self._sharing_api_json("map", "create", check=403, login="owner1:owner1pw", json_dict=json_dict)
|
||||||
|
|
||||||
logging.info("\n*** create map user1/owner1 r -> 200")
|
logging.info("\n*** create map user1/owner1 r -> 200")
|
||||||
json_dict = {}
|
json_dict = {}
|
||||||
json_dict['User'] = "user1"
|
json_dict['User'] = "user1"
|
||||||
@@ -5400,3 +5422,310 @@ permissions: RrWw""")
|
|||||||
json_dict['Hidden'] = False
|
json_dict['Hidden'] = False
|
||||||
json_dict['Conversion'] = "bday"
|
json_dict['Conversion'] = "bday"
|
||||||
_, headers, answer = self._sharing_api_json("token", "create", check=405, login="owner:ownerpw", json_dict=json_dict)
|
_, headers, answer = self._sharing_api_json("token", "create", check=405, login="owner:ownerpw", json_dict=json_dict)
|
||||||
|
|
||||||
|
def test_sharing_api_map_properies_overlay_unicode(self) -> None:
|
||||||
|
"""share-by-map API usage tests related to properties overlay using unicode."""
|
||||||
|
self.configure({"auth": {"type": "htpasswd",
|
||||||
|
"htpasswd_filename": self.htpasswd_file_path,
|
||||||
|
"htpasswd_encryption": "plain"},
|
||||||
|
"sharing": {
|
||||||
|
"type": "csv",
|
||||||
|
"permit_create_map": "True",
|
||||||
|
"permit_create_token": "True",
|
||||||
|
"collection_by_map": "True",
|
||||||
|
"collection_by_token": "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/calendarPFP-" + db_type + ".ics/"
|
||||||
|
path_shared_r = "/user/calendarPFP-shared-by-owner-r-" + db_type + ".ics/"
|
||||||
|
self.mkcalendar(path_mapped, 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:">
|
||||||
|
<prop>
|
||||||
|
<current-user-principal />
|
||||||
|
</prop>
|
||||||
|
</propfind>""", login="owner:ownerpw")
|
||||||
|
logging.info("response: %r", responses)
|
||||||
|
response = responses[path_mapped]
|
||||||
|
assert not isinstance(response, int) and len(response) == 1
|
||||||
|
status, prop = response["D:current-user-principal"]
|
||||||
|
assert status == 200 and len(prop) == 1
|
||||||
|
element = prop.find(xmlutils.make_clark("D:href"))
|
||||||
|
assert element is not None and element.text == "/owner/"
|
||||||
|
|
||||||
|
description_owner = "Test-Uni😀code-Single'Quote-UmÄlaut-Double\"Quote"
|
||||||
|
description_user = 'Test-Uni😁code-Single\'Quote-Sßz-Double"quote'
|
||||||
|
|
||||||
|
# execute PROPPATCH as owner
|
||||||
|
logging.info("\n*** PROPPATCH collection owner -> ok")
|
||||||
|
self._proppatch_calendar_description(path_mapped, login="owner:ownerpw", description=description_owner)
|
||||||
|
|
||||||
|
# verify PROPPATCH by owner
|
||||||
|
logging.info("\n*** PROPFIND collection owner (verify collection change) -> ok")
|
||||||
|
description = self._propfind_calendar_description(path_mapped, login="owner:ownerpw")
|
||||||
|
assert description == description_owner
|
||||||
|
|
||||||
|
# create map
|
||||||
|
logging.info("\n*** create map user/owner:rP -> ok")
|
||||||
|
json_dict = {}
|
||||||
|
json_dict['User'] = "user"
|
||||||
|
json_dict['PathMapped'] = path_mapped
|
||||||
|
json_dict['PathOrToken'] = path_shared_r
|
||||||
|
json_dict['Permissions'] = "rP"
|
||||||
|
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_r
|
||||||
|
_, headers, answer = self._sharing_api_json("map", "enable", check=200, login="user:userpw", json_dict=json_dict)
|
||||||
|
|
||||||
|
# verify PROPFIND as user
|
||||||
|
logging.info("\n*** PROPFIND collection user")
|
||||||
|
description = self._propfind_calendar_description(path_shared_r, login="user:userpw")
|
||||||
|
assert description == description_owner
|
||||||
|
|
||||||
|
# execute PROPPATCH as user
|
||||||
|
logging.info("\n*** PROPPATCH collection user -> ok")
|
||||||
|
self._proppatch_calendar_description(path_shared_r, login="user:userpw", description=description_user)
|
||||||
|
self._proppatch_calendar_color(path_shared_r, login="user:userpw", color="#FFFFFF")
|
||||||
|
|
||||||
|
logging.info("\n*** list (json->json)")
|
||||||
|
json_dict['PathOrToken'] = path_shared_r
|
||||||
|
_, headers, answer = self._sharing_api_json("map", "list", check=200, login="owner:ownerpw", json_dict=json_dict)
|
||||||
|
answer_dict = json.loads(answer)
|
||||||
|
assert answer_dict['Status'] == "success"
|
||||||
|
assert answer_dict['Lines'] == 1
|
||||||
|
|
||||||
|
# verify overlay as user
|
||||||
|
logging.info("\n*** PROPFIND collection user (overlay) -> ok")
|
||||||
|
description = self._propfind_calendar_description(path_shared_r, login="user:userpw")
|
||||||
|
assert description == description_user
|
||||||
|
|
||||||
|
# verify overlay not visible by owner
|
||||||
|
logging.info("\n*** PROPFIND collection owner (no collection change) -> ok")
|
||||||
|
description = self._propfind_calendar_description(path_mapped, login="owner:ownerpw")
|
||||||
|
assert description == description_owner
|
||||||
|
|
||||||
|
# check properties file
|
||||||
|
collection_props_path = os.path.join(self.colpath, "collection-root", path_mapped.removeprefix('/'), ".Radicale.props")
|
||||||
|
logging.info("collection_props path: %r", collection_props_path)
|
||||||
|
with open(collection_props_path) as f:
|
||||||
|
props = json.load(f)
|
||||||
|
logging.info("collection_props: %r", props)
|
||||||
|
assert props['C:calendar-description'] == description_owner
|
||||||
|
|
||||||
|
# reconfigure to trigger restart and reparsing of database
|
||||||
|
self.configure({"auth": {"type": "htpasswd"}})
|
||||||
|
|
||||||
|
# verify overlay as user
|
||||||
|
logging.info("\n*** PROPFIND collection user (overlay) -> ok")
|
||||||
|
description = self._propfind_calendar_description(path_shared_r, login="user:userpw")
|
||||||
|
assert description == description_user
|
||||||
|
|
||||||
|
@pytest.mark.skipif(not pathutils.path_supports_unicode(tempfile.mkdtemp()), reason="TEMP is not supporting unicode")
|
||||||
|
def test_sharing_api_map_user_unicode(self) -> None:
|
||||||
|
"""share-by-map API usage tests related to properties overlay using unicode."""
|
||||||
|
self.configure({"auth": {"type": "htpasswd",
|
||||||
|
"htpasswd_filename": self.htpasswd_file_path,
|
||||||
|
"htpasswd_encryption": "plain"},
|
||||||
|
"sharing": {
|
||||||
|
"type": "csv",
|
||||||
|
"permit_create_map": "True",
|
||||||
|
"permit_create_token": "True",
|
||||||
|
"collection_by_map": "True",
|
||||||
|
"collection_by_token": "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/calendarPFP-" + db_type + ".ics/"
|
||||||
|
path_shared_r = "/us😀er/calendarPFP-shared-by-owner-r-" + db_type + ".ics/"
|
||||||
|
self.mkcalendar(path_mapped, login="owner:ownerpw")
|
||||||
|
|
||||||
|
# create map
|
||||||
|
logging.info("\n*** create map user/owner:rP -> ok")
|
||||||
|
json_dict = {}
|
||||||
|
json_dict['User'] = "us😀er"
|
||||||
|
json_dict['PathMapped'] = path_mapped
|
||||||
|
json_dict['PathOrToken'] = path_shared_r
|
||||||
|
json_dict['Permissions'] = "rP"
|
||||||
|
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'] = "us😀er"
|
||||||
|
json_dict['PathMapped'] = path_mapped
|
||||||
|
json_dict['PathOrToken'] = path_shared_r
|
||||||
|
_, headers, answer = self._sharing_api_json("map", "enable", check=200, login="us😀er:user😀pw", json_dict=json_dict)
|
||||||
|
|
||||||
|
@pytest.mark.skipif(not pathutils.path_supports_unicode(tempfile.mkdtemp()), reason="TEMP is not supporting unicode")
|
||||||
|
def test_sharing_api_map_path_unicode(self) -> None:
|
||||||
|
"""share-by-map API usage tests related to properties overlay using unicode."""
|
||||||
|
self.configure({"auth": {"type": "htpasswd",
|
||||||
|
"htpasswd_filename": self.htpasswd_file_path,
|
||||||
|
"htpasswd_encryption": "plain"},
|
||||||
|
"sharing": {
|
||||||
|
"type": "csv",
|
||||||
|
"permit_create_map": "True",
|
||||||
|
"permit_create_token": "True",
|
||||||
|
"collection_by_map": "True",
|
||||||
|
"collection_by_token": "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/calendar😀PFP-" + db_type + ".ics/"
|
||||||
|
path_shared_r = "/user/calendar😁PFP-shared-by-owner-r-" + db_type + ".ics/"
|
||||||
|
self.mkcalendar(path_mapped, login="owner:ownerpw")
|
||||||
|
|
||||||
|
# create map
|
||||||
|
logging.info("\n*** create map user/owner:rP -> ok")
|
||||||
|
json_dict = {}
|
||||||
|
json_dict['User'] = "user"
|
||||||
|
json_dict['PathMapped'] = path_mapped
|
||||||
|
json_dict['PathOrToken'] = path_shared_r
|
||||||
|
json_dict['Permissions'] = "rP"
|
||||||
|
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_r
|
||||||
|
_, headers, answer = self._sharing_api_json("map", "enable", check=200, login="user:userpw", json_dict=json_dict)
|
||||||
|
|
||||||
|
def test_sharing_api_map_strict_user_unicode(self) -> None:
|
||||||
|
"""share-by-map API usage tests related to properties overlay using unicode in user."""
|
||||||
|
self.configure({"auth": {"type": "htpasswd",
|
||||||
|
"htpasswd_filename": self.htpasswd_file_path,
|
||||||
|
"htpasswd_encryption": "plain"},
|
||||||
|
"sharing": {
|
||||||
|
"type": "csv",
|
||||||
|
"permit_create_map": "True",
|
||||||
|
"permit_create_token": "True",
|
||||||
|
"collection_by_map": "True",
|
||||||
|
"collection_by_token": "True"},
|
||||||
|
"logging": {"request_header_on_debug": "False",
|
||||||
|
"response_content_on_debug": "True",
|
||||||
|
"request_content_on_debug": "True"},
|
||||||
|
"server": {"validate_user_value": "strict"},
|
||||||
|
"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/calendarPFP-" + db_type + ".ics/"
|
||||||
|
path_shared_r = "/user/calendarPFP-shared-by-owner-r-" + db_type + ".ics/"
|
||||||
|
self.mkcalendar(path_mapped, login="owner:ownerpw")
|
||||||
|
|
||||||
|
# create map
|
||||||
|
logging.info("\n*** create map user/owner:rP -> failed")
|
||||||
|
json_dict = {}
|
||||||
|
json_dict['User'] = "us😁er"
|
||||||
|
json_dict['PathMapped'] = path_mapped
|
||||||
|
json_dict['PathOrToken'] = path_shared_r
|
||||||
|
json_dict['Permissions'] = "rP"
|
||||||
|
json_dict['Enabled'] = True
|
||||||
|
json_dict['Hidden'] = False
|
||||||
|
_, headers, answer = self._sharing_api_json("map", "create", check=400, login="owner:ownerpw", json_dict=json_dict)
|
||||||
|
|
||||||
|
def test_sharing_api_map_strict_path_unicode(self) -> None:
|
||||||
|
"""share-by-map API usage tests related to properties overlay using unicode in user."""
|
||||||
|
self.configure({"auth": {"type": "htpasswd",
|
||||||
|
"htpasswd_filename": self.htpasswd_file_path,
|
||||||
|
"htpasswd_encryption": "plain"},
|
||||||
|
"sharing": {
|
||||||
|
"type": "csv",
|
||||||
|
"permit_create_map": "True",
|
||||||
|
"permit_create_token": "True",
|
||||||
|
"collection_by_map": "True",
|
||||||
|
"collection_by_token": "True"},
|
||||||
|
"logging": {"request_header_on_debug": "False",
|
||||||
|
"response_content_on_debug": "True",
|
||||||
|
"request_content_on_debug": "True"},
|
||||||
|
"server": {"validate_path_value": "strict"},
|
||||||
|
"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}})
|
||||||
|
|
||||||
|
logging.info("\n*** create collection, already rejected in early state")
|
||||||
|
path_mapped = "/owner/calendar😀PFP-" + db_type + ".ics/"
|
||||||
|
path_shared_r = "/user/calendarPFP-shared-by-owner-r-" + db_type + ".ics/"
|
||||||
|
self.mkcalendar(path_mapped, login="owner:ownerpw", check=400)
|
||||||
|
|
||||||
|
logging.info("\n*** create collection")
|
||||||
|
path_mapped = "/owner/calendarPFP-" + db_type + ".ics/"
|
||||||
|
path_shared_r = "/user/calendar😁PFP-shared-by-owner-r-" + db_type + ".ics/"
|
||||||
|
self.mkcalendar(path_mapped, login="owner:ownerpw")
|
||||||
|
|
||||||
|
# create map
|
||||||
|
logging.info("\n*** create map user/owner:rP -> ok")
|
||||||
|
json_dict = {}
|
||||||
|
json_dict['User'] = "user"
|
||||||
|
json_dict['PathMapped'] = path_mapped
|
||||||
|
json_dict['PathOrToken'] = path_shared_r
|
||||||
|
json_dict['Permissions'] = "rP"
|
||||||
|
json_dict['Enabled'] = True
|
||||||
|
json_dict['Hidden'] = False
|
||||||
|
_, headers, answer = self._sharing_api_json("map", "create", check=400, login="owner:ownerpw", json_dict=json_dict)
|
||||||
|
|||||||
Reference in New Issue
Block a user