Merge branch 'master' of github.com:metallerok/Radicale into overridden_recurrence_validation
This commit is contained in:
41
.github/workflows/docker-nightly-cleanup.yml
vendored
Normal file
41
.github/workflows/docker-nightly-cleanup.yml
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
name: Cleanup old nightly docker images
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '10 0 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
|
||||
jobs:
|
||||
delete-package-versions:
|
||||
name: Cleanup old nightly docker images
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Get list of all docker image versions in registry
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh api --paginate -X GET "/orgs/Kozea/packages/container/Radicale/versions" -F package_type=container -F per_page=200 > data.json
|
||||
|
||||
- name: Delete each nightly image older than cutoff date
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
cutoff_date=$(date --date="30 days ago" --iso-8601)
|
||||
echo "Cutoff date is: $cutoff_date"
|
||||
|
||||
# Loop through each nightly image version (tag) older than the cutoff date
|
||||
jq --arg cutoff_date "$cutoff_date" -r '.[] | select((.metadata.container.tags | any(. | contains("nightly"))) and (.created_at < $cutoff_date)) | [.metadata.container.tags[], .id] | @tsv' data.json | while IFS=$'\t' read -r tag nightly_image_id ; do
|
||||
echo "Tag - $tag"
|
||||
|
||||
# Because of multi-platform, manifest for each tag would contain more than 1 image. Loop through all
|
||||
all_digests=$(docker manifest inspect "ghcr.io/kozea/radicale:${tag}" | jq -r 'if .manifests then .manifests[]?.digest else empty end')
|
||||
for digest in $all_digests; do
|
||||
image_id=$(jq -r --arg digest "$digest" '.[] | select(.name == $digest) | .id' data.json)
|
||||
echo "Deleting $image_id"
|
||||
gh api -X DELETE "/orgs/Kozea/packages/container/Radicale/versions/$image_id"
|
||||
done
|
||||
# Now that all dependents are deleted, delete this tag
|
||||
echo "Deleting $tag with ID: $nightly_image_id"
|
||||
gh api -X DELETE "/orgs/Kozea/packages/container/Radicale/versions/$nightly_image_id"
|
||||
done
|
||||
21
.github/workflows/docker-publish.yml
vendored
21
.github/workflows/docker-publish.yml
vendored
@@ -2,13 +2,13 @@ name: Build and publish Docker image
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
types: [released]
|
||||
schedule:
|
||||
- cron: '0 0 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
GHCR_REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
@@ -22,23 +22,34 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to the Container registry
|
||||
- name: Log in to the ghcr container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
registry: ${{ env.GHCR_REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Log in to the dockerhub container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ vars.DOCKERHUB_ORGNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata for Docker build
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
images: |
|
||||
name=${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
name=${{ env.IMAGE_NAME }}
|
||||
flavor: latest=true
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=schedule,prefix=nightly-,pattern={{date 'YYYYMMDD'}}
|
||||
type=raw,enable=${{ github.event_name == 'workflow_dispatch' }},value=workflow_dispatch-{{branch}}-{{sha}}
|
||||
type=raw,enable=${{ github.event_name == 'release' }},value=stable
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
2
.github/workflows/pypi-publish.yml
vendored
2
.github/workflows/pypi-publish.yml
vendored
@@ -1,7 +1,7 @@
|
||||
name: PyPI publish
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
types: [released]
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
|
||||
105
.github/workflows/test.yml
vendored
105
.github/workflows/test.yml
vendored
@@ -2,14 +2,105 @@ name: Test
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
|
||||
test-ubuntu-python-newest:
|
||||
needs: lint
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest]
|
||||
python-version: ['3.14']
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Install Test dependencies
|
||||
run: pip install tox
|
||||
- name: Test with latest Python on Ubuntu
|
||||
run: tox -c pyproject.toml -e py
|
||||
|
||||
test-ubuntu-python-oldest:
|
||||
needs: [lint, test-ubuntu-python-newest]
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest]
|
||||
python-version: ['3.9']
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Install Test dependencies
|
||||
run: pip install tox
|
||||
- name: Test with oldest Python on Ubuntu
|
||||
run: tox -c pyproject.toml -e py
|
||||
|
||||
test-otheros-python-newest:
|
||||
needs: [lint, test-ubuntu-python-newest]
|
||||
strategy:
|
||||
matrix:
|
||||
os: [macos-latest, windows-latest]
|
||||
python-version: ['3.14']
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Install Test dependencies
|
||||
run: pip install tox
|
||||
- name: Test with latest Python on other OS
|
||||
run: tox -c pyproject.toml -e py
|
||||
|
||||
test-otheros-python-oldest:
|
||||
needs: [lint, test-ubuntu-python-oldest, test-otheros-python-newest]
|
||||
strategy:
|
||||
matrix:
|
||||
os: [macos-latest, windows-latest]
|
||||
python-version: ['3.9']
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Install Test dependencies
|
||||
run: pip install tox
|
||||
- name: Test with oldest Python on other OS
|
||||
run: tox -c pyproject.toml -e py
|
||||
|
||||
test-python-versions:
|
||||
needs: [lint, test-otheros-python-oldest, test-otheros-python-newest, test-ubuntu-python-oldest, test-ubuntu-python-newest]
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
python-version: ['3.9', '3.10', '3.11', '3.12.3', '3.13.0', pypy-3.9]
|
||||
python-version: ['3.10', '3.11', '3.12', '3.13', 'pypy-3.9', 'pypy-3.10', 'pypy-3.11']
|
||||
exclude:
|
||||
- os: windows-latest
|
||||
python-version: pypy-3.9
|
||||
python-version: 'pypy-3.9'
|
||||
- os: windows-latest
|
||||
python-version: 'pypy-3.10'
|
||||
- os: windows-latest
|
||||
python-version: 'pypy-3.11'
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Install Test dependencies
|
||||
run: pip install tox
|
||||
- name: Test with older Python
|
||||
run: tox -c pyproject.toml -e py
|
||||
|
||||
coveralls-test:
|
||||
if: github.event_name == 'push'
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest]
|
||||
python-version: ['3.13']
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -19,7 +110,7 @@ jobs:
|
||||
- name: Install Test dependencies
|
||||
run: pip install tox
|
||||
- name: Test
|
||||
run: tox -e py
|
||||
run: tox -c pyproject.toml -e py
|
||||
- name: Install Coveralls
|
||||
if: github.event_name == 'push'
|
||||
run: pip install coveralls
|
||||
@@ -31,7 +122,7 @@ jobs:
|
||||
run: coveralls --service=github
|
||||
|
||||
coveralls-finish:
|
||||
needs: test
|
||||
needs: coveralls-test
|
||||
if: github.event_name == 'push'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -51,8 +142,8 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
python-version: '3.13'
|
||||
- name: Install tox
|
||||
run: pip install tox
|
||||
- name: Lint
|
||||
run: tox -e flake8,mypy,isort
|
||||
run: tox -c pyproject.toml -e flake8,mypy,isort
|
||||
|
||||
61
CHANGELOG.md
61
CHANGELOG.md
@@ -1,6 +1,61 @@
|
||||
# Changelog
|
||||
|
||||
## 3.5.5.dev
|
||||
## 3.6.1.dev
|
||||
|
||||
* Fix: MOVE failing with URL-encoded destination header
|
||||
* Improve: add workaround to remove empty lines in item to avoid reject by vobject parser
|
||||
|
||||
## 3.6.0
|
||||
|
||||
* Extend: logwatch script
|
||||
* Extend: [logging] bad_put_request_content: log checksum and hexdump of request on debug level
|
||||
* Extend: [logging] request_content_on_debug: log checksum of request on debug level
|
||||
* Extend: add command line option "--verify-item <file>" for dedicated item file analysis
|
||||
* Extend: PROPFIND response for VADDRESSBOOK with "CR:supported-address-data" and "CS:getctag"
|
||||
* Extend: conditionally announce vCard 4.0 in case vobject version is >= 1.0.0
|
||||
* Fix: hook for server-side e-mail notification
|
||||
* Change: dependency PyPI/passlib (stale since 2020) replaced with PyPI/libpass >= 1.9.3
|
||||
* Extend: add a check whether bcrypt version is compatible with passlib(libpass) version (requires "packaging")
|
||||
* Improve: autodetection of hashes in htpasswd (SHA256/SHA512 "rounds" are now supported)
|
||||
|
||||
## 3.5.10
|
||||
* Improve: logging of broken calendar items during PUT
|
||||
* Add: logging of broken contact items during PUT
|
||||
* Extend: [auth] imap: add fallback support for LOGIN towards remote IMAP server (replaced in 3.5.0)
|
||||
* Fix: improper detection of HTTP_X_FORWARDED_PORT on MOVE
|
||||
* Extend: [logging] with profiling log per reqest or regular per request method
|
||||
* Add: [logging] option to log response header on debug loglevel
|
||||
* Adjust: [logging] header/content debug log indended by space to be skipped by logwatch
|
||||
* Improve: remove unnecessary open+read for mtime+size cache
|
||||
* Extend: add selected XML query properties to request result log line for improved timing analysis incl. logwatch support
|
||||
* Add: [server] max_resource_size option
|
||||
* Add: support PROPFIND/max-resource-size by max_resource_size (capped to 80% of max_content_length)
|
||||
|
||||
## 3.5.9
|
||||
* Extend: [auth] add support for type http_remote_user
|
||||
* Extend: logging of invalid sync-token with user, path, remote host and useragent
|
||||
* Fix: typo related to collection delete hook
|
||||
|
||||
## 3.5.8
|
||||
* Extend: [auth] re-factor & overhaul LDAP authentication, especially for Python's ldap module
|
||||
* Fix: out-of-range timestamp on 32-bit systems
|
||||
* Feature: extend logging with response size in bytes and flag served as plain or gzip
|
||||
* Feature: [storage] strict_preconditions: new config option to enforce strict preconditions check on PUT in case item already exists [RFC6352#9.2]
|
||||
* Fix: format_ut problem on 32-bit systems
|
||||
* Doc: Telugu translation
|
||||
|
||||
## 3.5.7
|
||||
* Extend: [auth] dovecot: add support for version >= 2.4
|
||||
* Fix: report/getetag with enabled expand
|
||||
* Adjust: use of option [auth] ldap_ignore_attribute_create_modify_timestamp for support of Authentik LDAP server
|
||||
|
||||
## 3.5.6
|
||||
* Fix: broken start when UID does not exist (potential container startup case)
|
||||
* Improve: user/group retrievement for running service and directories
|
||||
* Extend/Improve: [auth] ldap: group membership lookup
|
||||
* Add: [auth] remote_ip_source: set the remote IP source for auth algorithms
|
||||
|
||||
## 3.5.5
|
||||
* Improve: [auth] ldap: do not read server info by bind to avoid needless network traffic
|
||||
* Fix: [storage] broken support of 'folder_umask'
|
||||
* Improve: add details about platform and effective user on startup
|
||||
@@ -15,6 +70,8 @@
|
||||
* Add: [hook] dryrun: option to disable real hook action for testing, add tests for email+rabbitmq
|
||||
* Fix: storage hook path now added to DELETE, MKCOL, MKCALENDAR, MOVE, and PROPPATCH
|
||||
* Add: storage hook placeholder now supports "request" and "to_path" (MOVE only)
|
||||
* Improve: catch items having tzinfo only on dtstart or dtend set for whatever reason, overtake tzinfo from the other one
|
||||
* Improve: conditional log level for base_prefix strip action depending on auth and web type
|
||||
|
||||
## 3.5.4
|
||||
* Improve: item filter enhanced for 3rd level supporting VALARM and honoring TRIGGER (offset or absolute)
|
||||
@@ -127,7 +184,7 @@
|
||||
* Fix: Using icalendar's tzinfo on created datetime to fix issue with icalendar
|
||||
* Fix: typos in code
|
||||
* Enhancement: Added free-busy report
|
||||
* Enhancement: Added 'max_freebusy_occurrences` setting to avoid potential DOS on reports
|
||||
* Enhancement: Added 'max_freebusy_occurrences` setting to avoid potential DoS on reports
|
||||
* Enhancement: remove unexpected control codes from uploaded items
|
||||
* Enhancement: add 'strip_domain' setting for username handling
|
||||
* Enhancement: add option to toggle debug log of rights rule with doesn't match
|
||||
|
||||
1163
DOCUMENTATION.md
1163
DOCUMENTATION.md
File diff suppressed because it is too large
Load Diff
@@ -19,14 +19,12 @@ WORKDIR /app
|
||||
|
||||
RUN addgroup -g 1000 radicale \
|
||||
&& adduser radicale --home /var/lib/radicale --system --uid 1000 --disabled-password -G radicale \
|
||||
&& apk add --no-cache ca-certificates openssl
|
||||
&& apk add --no-cache ca-certificates openssl curl
|
||||
|
||||
COPY --chown=radicale:radicale --from=builder /app/venv /app
|
||||
|
||||
# Persistent storage for data
|
||||
VOLUME /var/lib/radicale
|
||||
# TCP port of Radicale
|
||||
EXPOSE 5232
|
||||
# Run Radicale
|
||||
ENTRYPOINT [ "/app/bin/python", "/app/bin/radicale"]
|
||||
CMD ["--hosts", "0.0.0.0:5232,[::]:5232"]
|
||||
|
||||
25
compose.yaml
Normal file
25
compose.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
name: Radicale
|
||||
services:
|
||||
radicale:
|
||||
image: ghcr.io/kozea/radicale:stable
|
||||
ports:
|
||||
- 5232:5232
|
||||
volumes:
|
||||
- config:/etc/radicale
|
||||
- data:/var/lib/radicale
|
||||
|
||||
volumes:
|
||||
config:
|
||||
name: radicale-config
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: none
|
||||
o: bind
|
||||
device: ./config
|
||||
data:
|
||||
name: radicale-data
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: none
|
||||
o: bind
|
||||
device: ./data
|
||||
116
config
116
config
@@ -21,10 +21,15 @@
|
||||
# Max parallel connections
|
||||
#max_connections = 8
|
||||
|
||||
# Max size of request body (bytes)
|
||||
# Max size of request body (bytes), default: 100 Mbyte
|
||||
# In case of using a reverse proxy in front of check also there related option
|
||||
#max_content_length = 100000000
|
||||
|
||||
# Max resource size (bytes), default: 10 Mbyte
|
||||
# Limited to 80% of max_content_length to cover plain base64 encoded payload
|
||||
# Announced to clients requesting "max-resource-size" via PROPFIND
|
||||
#max_ressource_size = 10000000
|
||||
|
||||
# Socket timeout (seconds)
|
||||
#timeout = 30
|
||||
|
||||
@@ -63,7 +68,7 @@
|
||||
[auth]
|
||||
|
||||
# Authentication method
|
||||
# Value: none | htpasswd | remote_user | http_x_remote_user | dovecot | ldap | oauth2 | pam | denyall
|
||||
# Value: none | htpasswd | remote_user | http_remote_user | http_x_remote_user | dovecot | ldap | oauth2 | pam | denyall
|
||||
#type = denyall
|
||||
|
||||
# Cache logins for until expiration time
|
||||
@@ -75,46 +80,54 @@
|
||||
## Expiration time of caching failed logins in seconds
|
||||
#cache_failed_logins_expiry = 90
|
||||
|
||||
# Ignore modifyTimestamp and createTimestamp attributes. Required e.g. for Authentik LDAP server
|
||||
#ldap_ignore_attribute_create_modify_timestamp = false
|
||||
|
||||
# URI to the LDAP server
|
||||
#ldap_uri = ldap://localhost
|
||||
|
||||
# The base DN where the user accounts have to be searched
|
||||
# Base DN of the LDAP server to search for user accounts
|
||||
#ldap_base = ##BASE_DN##
|
||||
|
||||
# The reader DN of the LDAP server
|
||||
# Reader DN of the LDAP server; (needs read access to users and - if defined - groups)
|
||||
#ldap_reader_dn = CN=ldapreader,CN=Users,##BASE_DN##
|
||||
|
||||
# Password of the reader DN
|
||||
# Password of the reader DN (better: use 'ldap_secret_file'!)
|
||||
#ldap_secret = ldapreader-secret
|
||||
|
||||
# Path of the file containing password of the reader DN
|
||||
# Path to the file containing the password of the reader DN
|
||||
#ldap_secret_file = /run/secrets/ldap_password
|
||||
|
||||
# the attribute to read the group memberships from in the user's LDAP entry (default: not set)
|
||||
#ldap_groups_attribute = memberOf
|
||||
|
||||
# The filter to find the DN of the user. This filter must contain a python-style placeholder for the login
|
||||
# Filter to search for the LDAP entry of the user to authenticate. It must contain '{0}' as placeholder for the login name.
|
||||
#ldap_filter = (&(objectClass=person)(uid={0}))
|
||||
|
||||
# the attribute holding the value to be used as username after authentication
|
||||
# Attribute holding the value to be used as username after authentication
|
||||
#ldap_user_attribute = cn
|
||||
|
||||
# Use ssl on the ldap connection
|
||||
# Soon to be deprecated, use ldap_security instead
|
||||
# Use ssl on the LDAP connection (DEPRECATED - use 'ldap_security'!)
|
||||
#ldap_use_ssl = False
|
||||
|
||||
# the encryption mode to be used: tls, starttls, default is none
|
||||
# Encryption mode to be used. Default: none; one of: none, tls, starttls
|
||||
#ldap_security = none
|
||||
|
||||
# The certificate verification mode. Works for ssl and starttls. NONE, OPTIONAL, default is REQUIRED
|
||||
# Certificate verification mode for tls & starttls. Default: REQUIRED; one of NONE, OPTIONAL, REQUIRED
|
||||
#ldap_ssl_verify_mode = REQUIRED
|
||||
|
||||
# The path to the CA file in pem format which is used to certificate the server certificate
|
||||
# Path to the CA file in PEM format to certify the server certificate
|
||||
#ldap_ssl_ca_file =
|
||||
|
||||
# Attribute in the user's LDAP entry to read the group memberships from; default: not set
|
||||
#ldap_groups_attribute = memberOf
|
||||
|
||||
# Attribute in the group entries to read the group's members from, e.g. member; default: not set
|
||||
#ldap_group_members_attribute = member
|
||||
|
||||
# Base DN to search for groups; only if it differs from 'ldap_base' and if 'ldap_group_members_attribute' is set
|
||||
#ldap_group_base = ##GROUP_BASE_DN##
|
||||
|
||||
# Search filter to search for groups having the user DN found as member; only if 'ldap_group_members_attribute' is set
|
||||
#ldap_group_filter = (objectclass=groupOfNames)
|
||||
|
||||
# Quirks for Authentik LDAP server: ignore modifyTimestamp and createTimestamp attributes
|
||||
#ldap_ignore_attribute_create_modify_timestamp = false
|
||||
|
||||
# Connection type for dovecot authentication (AF_UNIX|AF_INET|AF_INET6)
|
||||
# Note: credentials are transmitted in cleartext
|
||||
#dovecot_connection_type = AF_UNIX
|
||||
@@ -128,6 +141,10 @@
|
||||
# Port of via network exposed dovecot socket
|
||||
#dovecot_port = 12345
|
||||
|
||||
# Remote address source for authentication mechanisms (such as dovecot)
|
||||
# that are passed this information.
|
||||
#remote_ip_source = REMOTE_ADDR
|
||||
|
||||
# IMAP server hostname
|
||||
# Syntax: address | address:port | [address]:port | imap.server.tld
|
||||
#imap_host = localhost
|
||||
@@ -169,6 +186,9 @@
|
||||
# Strip domain name from username
|
||||
#strip_domain = False
|
||||
|
||||
# URL Decode the given username (when URL-encoded by the client - useful for iOS devices when using email address)
|
||||
#urldecode_username = False
|
||||
|
||||
|
||||
[rights]
|
||||
|
||||
@@ -185,8 +205,6 @@
|
||||
# Permit overwrite of a collection (global)
|
||||
#permit_overwrite_collection = True
|
||||
|
||||
# URL Decode the given username (when URL-encoded by the client - useful for iOS devices when using email address)
|
||||
# urldecode_username = False
|
||||
|
||||
[storage]
|
||||
|
||||
@@ -229,6 +247,9 @@
|
||||
# Skip broken item instead of triggering an exception
|
||||
#skip_broken_item = True
|
||||
|
||||
# Strict preconditions check on PUT
|
||||
#strict_preconditions = False
|
||||
|
||||
# Command that is run after changes to storage, default is emtpy
|
||||
# Supported placeholders:
|
||||
# %(user)s: logged-in user
|
||||
@@ -247,18 +268,31 @@
|
||||
#
|
||||
# json format:
|
||||
#
|
||||
# {
|
||||
# "def-addressbook": {
|
||||
# predefined_collections = {
|
||||
# "def-personal-addressbook": {
|
||||
# "D:displayname": "Personal Address Book",
|
||||
# "tag": "VADDRESSBOOK"
|
||||
# },
|
||||
# "def-calendar": {
|
||||
# "def-work-addressbook": {
|
||||
# "D:displayname": "Work Address Book",
|
||||
# "tag": "VADDRESSBOOK"
|
||||
# },
|
||||
# "def-personal-calendar": {
|
||||
# "C:supported-calendar-component-set": "VEVENT,VJOURNAL,VTODO",
|
||||
# "D:displayname": "Personal Calendar",
|
||||
# "tag": "VCALENDAR"
|
||||
# }
|
||||
# }
|
||||
#
|
||||
# },
|
||||
# "def-birthday-calendar": {
|
||||
# "C:supported-calendar-component-set": "VEVENT",
|
||||
# "D:displayname": "Birthday Calendar",
|
||||
# "tag": "VCALENDAR"
|
||||
# },
|
||||
# "def-work-calendar": {
|
||||
# "C:supported-calendar-component-set": "VEVENT",
|
||||
# "D:displayname": "Work Calendar",
|
||||
# "tag": "VCALENDAR"
|
||||
# },
|
||||
# }
|
||||
#predefined_collections =
|
||||
|
||||
|
||||
@@ -296,6 +330,9 @@
|
||||
# Log request content on level=debug
|
||||
#request_content_on_debug = False
|
||||
|
||||
# Log response header on level=debug
|
||||
#response_header_on_debug = False
|
||||
|
||||
# Log response content on level=debug
|
||||
#response_content_on_debug = False
|
||||
|
||||
@@ -305,6 +342,26 @@
|
||||
# Log storage cache actions on level=debug
|
||||
#storage_cache_actions_on_debug = False
|
||||
|
||||
# Log profiling data on level=info
|
||||
# Value: per_request | per_request_method | none
|
||||
#profiling = none
|
||||
|
||||
# Log profiling data per request minimum duration (seconds)
|
||||
#profiling_per_request_min_duration = 3
|
||||
|
||||
# Log profiling request header (if passing minimum duration)
|
||||
#profiling_per_request_header = False
|
||||
|
||||
# Log profiling request XML (if passing minimum duration)
|
||||
#profiling_per_request_xml = False
|
||||
|
||||
# Log profiling data per request method interval (seconds)
|
||||
#profiling_per_request_method_interval = 600
|
||||
|
||||
# Log profiling top X functions (limit)
|
||||
#profiling_top_x_functions = 10
|
||||
|
||||
|
||||
[headers]
|
||||
|
||||
# Additional HTTP headers
|
||||
@@ -334,10 +391,13 @@
|
||||
#smtp_password =
|
||||
#from_email =
|
||||
#mass_email = False
|
||||
#new_or_added_to_event_template =
|
||||
#deleted_or_removed_from_event_template =
|
||||
#updated_event_template =
|
||||
|
||||
|
||||
[reporting]
|
||||
|
||||
# When returning a free-busy report, limit the number of returned
|
||||
# occurences per event to prevent DOS attacks.
|
||||
# occurences per event to prevent DoS attacks.
|
||||
#max_freebusy_occurrence = 10000
|
||||
|
||||
@@ -59,6 +59,9 @@
|
||||
|
||||
ProxyPass http://localhost:5232/ retry=0
|
||||
ProxyPassReverse http://localhost:5232/
|
||||
<IfVersion >= 2.4.40>
|
||||
Proxy100Continue Off
|
||||
</IfVersion>
|
||||
|
||||
Require local
|
||||
<IfDefine RADICALE_PERMIT_PUBLIC_ACCESS>
|
||||
@@ -74,6 +77,9 @@
|
||||
|
||||
ProxyPass http://localhost:5232/ retry=0
|
||||
ProxyPassReverse http://localhost:5232/
|
||||
<IfVersion >= 2.4.40>
|
||||
Proxy100Continue Off
|
||||
</IfVersion>
|
||||
|
||||
<IfDefine !RADICALE_SERVER_USER_AUTHENTICATION>
|
||||
## User authentication handled by "radicale"
|
||||
@@ -221,6 +227,9 @@ CustomLog logs/ssl_request_log "%t %h %{SSL_PROTOCOL}x %{SSL_CIPHER}x \"%r\" %b"
|
||||
|
||||
ProxyPass http://localhost:5232/ retry=0
|
||||
ProxyPassReverse http://localhost:5232/
|
||||
<IfVersion >= 2.4.40>
|
||||
Proxy100Continue Off
|
||||
</IfVersion>
|
||||
|
||||
Require local
|
||||
<IfDefine RADICALE_PERMIT_PUBLIC_ACCESS>
|
||||
@@ -234,6 +243,9 @@ CustomLog logs/ssl_request_log "%t %h %{SSL_PROTOCOL}x %{SSL_CIPHER}x \"%r\" %b"
|
||||
|
||||
ProxyPass http://localhost:5232/ retry=0
|
||||
ProxyPassReverse http://localhost:5232/
|
||||
<IfVersion >= 2.4.40>
|
||||
Proxy100Continue Off
|
||||
</IfVersion>
|
||||
|
||||
<IfDefine !RADICALE_SERVER_USER_AUTHENTICATION>
|
||||
## User authentication handled by "radicale"
|
||||
|
||||
@@ -16,11 +16,17 @@ caldav.example.com {
|
||||
not path /.web/*
|
||||
}
|
||||
|
||||
# disable this in case authentication is handled by Radicale
|
||||
basic_auth @not-webui {
|
||||
USER HASH
|
||||
}
|
||||
|
||||
reverse_proxy localhost:5232 {
|
||||
# disable this in case authentication is handled by Radicale
|
||||
header_up X-Remote-User {http.auth.user.id}
|
||||
# replace "HOST" with configured hostname of URL (FQDN) in client
|
||||
header_up Host HOST
|
||||
# replace "PORT" with configured port of URL in client
|
||||
header_up X-Forwarded-Port PORT
|
||||
}
|
||||
}
|
||||
|
||||
6
contrib/lighttpd/radicale.conf
Normal file
6
contrib/lighttpd/radicale.conf
Normal file
@@ -0,0 +1,6 @@
|
||||
server.modules += ( "mod_proxy" , "mod_setenv" )
|
||||
|
||||
$HTTP["url"] =~ "^/radicale/" {
|
||||
proxy.server = ( "" => (( "host" => "127.0.0.1", "port" => "5232" )) )
|
||||
setenv.add-request-header = ( "X-Script-Name" => "/radicale" )
|
||||
}
|
||||
@@ -1,20 +1,34 @@
|
||||
# This file is related to Radicale - CalDAV and CardDAV server
|
||||
# for logwatch (script)
|
||||
# Copyright © 2024-2024 Peter Bieringer <pb@bieringer.de>
|
||||
# Copyright © 2024-2025 Peter Bieringer <pb@bieringer.de>
|
||||
#
|
||||
# Detail levels
|
||||
# >= 5: Logins
|
||||
# >= 10: ResponseTimes
|
||||
# < 5 : Request + ResponseCounters
|
||||
# >= 5 : incl. Logins
|
||||
# >= 10: incl. ResponseTimes + ResponseSize
|
||||
# >= 15: incl. ResponseTimes + ResponseSize incl. RequestFlags
|
||||
# >= 18: incl. UserAgents
|
||||
# >= 20: incl. Locations where supported, anonymize logins
|
||||
|
||||
use Digest::SHA;
|
||||
|
||||
$Detail = $ENV{'LOGWATCH_DETAIL_LEVEL'} || 0;
|
||||
|
||||
my %ResponseTimesLocUsr;
|
||||
my %ResponseSizesLocUsr;
|
||||
my %ResponseTimes;
|
||||
my %ResponseSizes;
|
||||
my %Responses;
|
||||
my %Requests;
|
||||
my %UserAgents;
|
||||
my %Logins;
|
||||
my %Loglevel;
|
||||
my %OtherEvents;
|
||||
|
||||
my %Locations;
|
||||
my %LocationsFile;
|
||||
my %LoginsHash;
|
||||
|
||||
my $sum;
|
||||
my $length;
|
||||
|
||||
@@ -26,7 +40,7 @@ sub ResponseTimesMinMaxSum($$) {
|
||||
|
||||
if (! defined $ResponseTimes{$req}->{'min'}) {
|
||||
$ResponseTimes{$req}->{'min'} = $time;
|
||||
} elsif ($ResponseTimes->{$req}->{'min'} > $time) {
|
||||
} elsif ($ResponseTimes{$req}->{'min'} > $time) {
|
||||
$ResponseTimes{$req}->{'min'} = $time;
|
||||
}
|
||||
|
||||
@@ -39,6 +53,28 @@ sub ResponseTimesMinMaxSum($$) {
|
||||
$ResponseTimes{$req}->{'sum'} += $time;
|
||||
}
|
||||
|
||||
sub ResponseSizesMinMaxSum($$$) {
|
||||
my $req = $_[0];
|
||||
my $type = $_[1];
|
||||
my $size = $_[2];
|
||||
|
||||
$ResponseSizes{$type}->{$req}->{'cnt'}++;
|
||||
|
||||
if (! defined $ResponseSizes{$type}->{$req}->{'min'}) {
|
||||
$ResponseSizes{$type}->{$req}->{'min'} = $size;
|
||||
} elsif ($ResponseSizes{$type}->{$req}->{'min'} > $size) {
|
||||
$ResponseSizes{$type}->{$req}->{'min'} = $size;
|
||||
}
|
||||
|
||||
if (! defined $ResponseSizes{$type}->{$req}->{'max'}) {
|
||||
$ResponseSizes{$type}->{$req}{'max'} = $size;
|
||||
} elsif ($ResponseSizes{$type}->{$req}->{'max'} < $size) {
|
||||
$ResponseSizes{$type}->{$req}{'max'} = $size;
|
||||
}
|
||||
|
||||
$ResponseSizes{$type}->{$req}->{'sum'} += $size;
|
||||
}
|
||||
|
||||
sub Sum($) {
|
||||
my $phash = $_[0];
|
||||
my $sum = 0;
|
||||
@@ -57,6 +93,64 @@ sub MaxLength($) {
|
||||
return $length;
|
||||
}
|
||||
|
||||
sub ConvertTokens($) {
|
||||
my %tokens_h;
|
||||
# unique
|
||||
foreach my $token (split(" ", $_[0])) {
|
||||
$tokens_h{$token} = 1;
|
||||
}
|
||||
# map tokens
|
||||
my @result_a;
|
||||
if (defined $tokens_h{"sync-token"}) {
|
||||
push @result_a, "ST";
|
||||
}
|
||||
if (defined $tokens_h{"sync-collection"}) {
|
||||
push @result_a, "SC";
|
||||
}
|
||||
if (defined $tokens_h{"getctag"}) {
|
||||
push @result_a, "GCT";
|
||||
}
|
||||
if (defined $tokens_h{"getetag"}) {
|
||||
push @result_a, "GET";
|
||||
}
|
||||
# TODO: add potential others which causing long duration
|
||||
$result = "";
|
||||
if (scalar(@result_a) > 0) {
|
||||
$result = ":F=" . join(",", @result_a);
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
sub ConvertLoc($) {
|
||||
my $loc = $_[0];
|
||||
if (defined $Locations{$loc}) {
|
||||
# from cache
|
||||
return ":L=" . $Locations{$loc};
|
||||
} elsif (defined $LocationsFile{$loc}) {
|
||||
# from cache
|
||||
return ":L=" . $LocationsFile{$loc};
|
||||
}
|
||||
|
||||
if ($loc =~ /\/'$/o) {
|
||||
$Locations{$loc} = "L=" . substr(Digest::SHA::sha256_hex($loc), 0, 8);
|
||||
return ":" . $Locations{$loc};
|
||||
} else {
|
||||
$LocationsFile{$loc} = "L=<FILE>";
|
||||
return ":" . $Locations{$loc};
|
||||
}
|
||||
}
|
||||
|
||||
sub ConvertLogin($) {
|
||||
my $login = $_[0];
|
||||
if (defined $LoginsHash{$loginc}) {
|
||||
# from cache
|
||||
return $LoginsHash{$login};
|
||||
}
|
||||
|
||||
$LoginsHash{$login} = "U=" . substr(Digest::SHA::sha256_hex($login), 0, 8);
|
||||
return $LoginsHash{$login};
|
||||
}
|
||||
|
||||
while (defined($ThisLine = <STDIN>)) {
|
||||
# count loglevel
|
||||
if ( $ThisLine =~ /\[(DEBUG|INFO|WARNING|ERROR|CRITICAL)\] /o ) {
|
||||
@@ -72,34 +166,94 @@ while (defined($ThisLine = <STDIN>)) {
|
||||
}
|
||||
elsif ( $ThisLine =~ / (\S+) response status/o ) {
|
||||
my $req = $1;
|
||||
if ( $ThisLine =~ / \S+ response status for .* with depth '(\d)' in ([0-9.]+) seconds: (\d+)/o ) {
|
||||
$req .= ":D=" . $1 . ":R=" . $3;
|
||||
if ( $ThisLine =~ / \S+ response status for (.*) with depth '(\d)' in ([0-9.]+) seconds: (\d+)/o ) {
|
||||
$req .= ":D=" . $2 . ":R=" . $4;
|
||||
$req .= ConvertLoc($1) if ($Detail >= 20);
|
||||
ResponseTimesMinMaxSum($req, $2) if ($Detail >= 10);
|
||||
} elsif ( $ThisLine =~ / \S+ response status for .* in ([0-9.]+) seconds: (\d+)/ ) {
|
||||
$req .= ":R=" . $2;
|
||||
} elsif ( $ThisLine =~ / \S+ response status for (.*) in ([0-9.]+) seconds: (\d+)/o ) {
|
||||
$req .= ":R=" . $3;
|
||||
$req .= ConvertLoc($1) if ($Detail >= 20);
|
||||
ResponseTimesMinMaxSum($req, $1) if ($Detail >= 10);
|
||||
} elsif ( $ThisLine =~ / \S+ response status for (.*) with depth '(\d)' in ([0-9.]+) seconds (\S+) (\d+) bytes: (\d+)/o ) {
|
||||
$req .= ":D=" . $2 . ":R=" . $6;
|
||||
$req .= ConvertLoc($1) if ($Detail >= 20);
|
||||
ResponseTimesMinMaxSum($req, $3) if ($Detail >= 10);
|
||||
ResponseSizesMinMaxSum($req, $4, $5) if ($Detail >= 10);
|
||||
} elsif ( $ThisLine =~ / \S+ response status for (.*) in ([0-9.]+) seconds (\S+) (\d+) bytes: (\d+)/o ) {
|
||||
$req .= ":R=" . $5;
|
||||
$req .= ConvertLoc($1) if ($Detail >= 20);
|
||||
ResponseTimesMinMaxSum($req, $2) if ($Detail >= 10);
|
||||
ResponseSizesMinMaxSum($req, $3, $4) if ($Detail >= 10);
|
||||
} elsif ( $ThisLine =~ / \S+ response status for (.*) with depth '(\d)' in ([0-9.]+) seconds (\S+) (\d+) bytes \((.*)\): (\d+)/o ) {
|
||||
$req .= ":D=" . $2 . ":R=" . $7;
|
||||
$req .= ConvertLoc($1) if ($Detail >= 20);
|
||||
$req .= ConvertTokens($6) if ($Detail >= 15);
|
||||
ResponseTimesMinMaxSum($req, $3) if ($Detail >= 10);
|
||||
ResponseSizesMinMaxSum($req, $4, $5) if ($Detail >= 10);
|
||||
} elsif ( $ThisLine =~ / \S+ response status for (.*) in ([0-9.]+) seconds (\S+) (\d+) bytes \((.*)\): (\d+)/o ) {
|
||||
$req .= ":R=" . $6;
|
||||
$req .= ConvertLoc($1) if ($Detail >= 20);
|
||||
$req .= ConvertTokens($6) if ($Detail >= 15);
|
||||
ResponseTimesMinMaxSum($req, $2) if ($Detail >= 10);
|
||||
ResponseSizesMinMaxSum($req, $3, $4) if ($Detail >= 10);
|
||||
}
|
||||
$Responses{$req}++;
|
||||
}
|
||||
elsif ( $ThisLine =~ / (\S+) request for/o ) {
|
||||
elsif ( $ThisLine =~ / (\S+) request for ('[^']+')/o ) {
|
||||
my $req = $1;
|
||||
if ( $ThisLine =~ / \S+ request for .* with depth '(\d)' received/o ) {
|
||||
my $loc = $2;
|
||||
if ( $ThisLine =~ / with depth '(\d)' received/o ) {
|
||||
$req .= ":D=" . $1;
|
||||
}
|
||||
$req .= ConvertLoc($loc) if ($Detail >= 20);
|
||||
$Requests{$req}++;
|
||||
|
||||
if ( $ThisLine =~ /using ('.*')/o ) {
|
||||
my $ua = $1;
|
||||
# remove unexpected chars
|
||||
$ua =~ s/[\x00-\x1F\x7F-\xFF]//g;
|
||||
$ua .= ConvertLoc($loc) if ($Detail >= 20);
|
||||
$UserAgents{$ua}++ if ($Detail >= 18);
|
||||
}
|
||||
}
|
||||
elsif ( $ThisLine =~ / (Successful login): '([^']+)'/o ) {
|
||||
$Logins{$2}++ if ($Detail >= 5);
|
||||
my $login = $2;
|
||||
$login = ConvertLogin($login) if ($Detail >= 20);
|
||||
$Logins{$login}++ if ($Detail >= 5);
|
||||
$OtherEvents{$1}++;
|
||||
}
|
||||
elsif ( $ThisLine =~ / (Failed login attempt) /o ) {
|
||||
$OtherEvents{$1}++;
|
||||
}
|
||||
elsif ( $ThisLine =~ / (Profiling data per request method \S+) /o ) {
|
||||
my $info = $1;
|
||||
if ( $ThisLine =~ /(no request seen so far)/o ) {
|
||||
$OtherEvents{$info . " - " . $1}++;
|
||||
} else {
|
||||
$OtherEvents{$info}++;
|
||||
};
|
||||
}
|
||||
elsif ( $ThisLine =~ / (Profiling data per request \S+) /o ) {
|
||||
my $info = $1;
|
||||
if ( $ThisLine =~ /(suppressed because duration below minimum|suppressed because of no data)/o ) {
|
||||
$OtherEvents{$info . " - " . $1}++;
|
||||
} else {
|
||||
$OtherEvents{$info}++;
|
||||
};
|
||||
}
|
||||
elsif ( $ThisLine =~ /\[(DEBUG|INFO)\] /o ) {
|
||||
# skip if DEBUG+INFO
|
||||
}
|
||||
else {
|
||||
# Report any unmatched entries...
|
||||
if ($ThisLine =~ /^({\'| )/o) {
|
||||
# skip profiling or raw header data
|
||||
next;
|
||||
};
|
||||
if ($ThisLine =~ /^$/o) {
|
||||
# skip empty line
|
||||
next;
|
||||
};
|
||||
$ThisLine =~ s/^\[\d+(\/Thread-\d+)?\] //; # remove process/Thread ID
|
||||
chomp($ThisLine);
|
||||
$OtherList{$ThisLine}++;
|
||||
@@ -114,64 +268,105 @@ if ($Started) {
|
||||
if (keys %Loglevel) {
|
||||
$sum = Sum(\%Loglevel);
|
||||
print "\n**Loglevel counters**\n";
|
||||
printf "%-18s | %7s | %5s |\n", "Loglevel", "cnt", "ratio";
|
||||
print "-" x38 . "\n";
|
||||
printf "%-18s | %7s | %9s |\n", "Loglevel", "cnt", "ratio";
|
||||
print "-" x42 . "\n";
|
||||
foreach my $level (sort keys %Loglevel) {
|
||||
printf "%-18s | %7d | %3d%% |\n", $level, $Loglevel{$level}, int(($Loglevel{$level} * 100) / $sum);
|
||||
printf "%-18s | %7d | %7.3f%% |\n", $level, $Loglevel{$level}, (($Loglevel{$level} * 100) / $sum);
|
||||
}
|
||||
print "-" x38 . "\n";
|
||||
printf "%-18s | %7d | %3d%% |\n", "", $sum, 100;
|
||||
}
|
||||
|
||||
if (keys %Requests) {
|
||||
$sum = Sum(\%Requests);
|
||||
print "\n**Request counters (D=<depth>)**\n";
|
||||
printf "%-18s | %7s | %5s |\n", "Request", "cnt", "ratio";
|
||||
print "-" x38 . "\n";
|
||||
foreach my $req (sort keys %Requests) {
|
||||
printf "%-18s | %7d | %3d%% |\n", $req, $Requests{$req}, int(($Requests{$req} * 100) / $sum);
|
||||
}
|
||||
print "-" x38 . "\n";
|
||||
printf "%-18s | %7d | %3d%% |\n", "", $sum, 100;
|
||||
}
|
||||
|
||||
if (keys %Responses) {
|
||||
$sum = Sum(\%Responses);
|
||||
print "\n**Response result counters ((D=<depth> R=<result>)**\n";
|
||||
printf "%-18s | %7s | %5s |\n", "Response", "cnt", "ratio";
|
||||
print "-" x38 . "\n";
|
||||
foreach my $req (sort keys %Responses) {
|
||||
printf "%-18s | %7d | %3d%% |\n", $req, $Responses{$req}, int(($Responses{$req} * 100) / $sum);
|
||||
}
|
||||
print "-" x38 . "\n";
|
||||
printf "%-18s | %7d | %3d%% |\n", "", $sum, 100;
|
||||
print "-" x42 . "\n";
|
||||
printf "%-18s | %7d | %7.3f%% |\n", "", $sum, 100;
|
||||
}
|
||||
|
||||
if (keys %Logins) {
|
||||
$sum = Sum(\%Logins);
|
||||
$length = MaxLength(\%Logins);
|
||||
print "\n**Successful login counters**\n";
|
||||
printf "%-" . $length . "s | %7s | %5s |\n", "Login", "cnt", "ratio";
|
||||
print "-" x($length + 20) . "\n";
|
||||
printf "%-" . $length . "s | %7s | %9s |\n", "Login", "cnt", "ratio";
|
||||
print "-" x($length + 24) . "\n";
|
||||
foreach my $login (sort keys %Logins) {
|
||||
printf "%-" . $length . "s | %7d | %3d%% |\n", $login, $Logins{$login}, int(($Logins{$login} * 100) / $sum);
|
||||
printf "%-" . $length . "s | %7d | %7.3f%% |\n", $login, $Logins{$login}, (($Logins{$login} * 100) / $sum);
|
||||
}
|
||||
print "-" x($length + 20) . "\n";
|
||||
printf "%-" . $length . "s | %7d | %3d%% |\n", "", $sum, 100;
|
||||
print "-" x($length + 24) . "\n";
|
||||
printf "%-" . $length . "s | %7d | %7.3d%% |\n", "", $sum, 100;
|
||||
}
|
||||
|
||||
if (keys %UserAgents) {
|
||||
$sum = Sum(\%UserAgents);
|
||||
$length = MaxLength(\%UserAgents);
|
||||
print "\n**UserAgent Counters**\n";
|
||||
print "* Location: L=<HASH> -> see below L=<FILE> -> see raw log\n" if (scalar(keys %Locations) > 0);
|
||||
printf "%-" . $length . "s | %7s | %9s |\n", "UserAgent", "cnt", "ratio";
|
||||
print "-" x($length + 24) . "\n";
|
||||
foreach my $ua (sort keys %UserAgents) {
|
||||
printf "%-" . $length . "s | %7d | %7.3f%% |\n", $ua, $UserAgents{$ua}, (($UserAgents{$ua} * 100) / $sum);
|
||||
}
|
||||
print "-" x($length + 24) . "\n";
|
||||
printf "%-" . $length . "s | %7d | %7.3d%% |\n", "", $sum, 100;
|
||||
}
|
||||
|
||||
if (keys %Requests) {
|
||||
$sum = Sum(\%Requests);
|
||||
$length = MaxLength(\%Requests);
|
||||
print "\n**Request counters (D=<depth>)**\n";
|
||||
print "* Location: L=<HASH> -> see below L=<FILE> -> see raw log\n" if (scalar(keys %Locations) > 0);
|
||||
printf "%-" . $length . "s | %7s | %9s |\n", "Request", "cnt", "ratio";
|
||||
print "-" x($length + 24) . "\n";
|
||||
foreach my $req (sort keys %Requests) {
|
||||
printf "%-" . $length . "s | %7d | %7.3f%% |\n", $req, $Requests{$req}, (($Requests{$req} * 100) / $sum);
|
||||
}
|
||||
print "-" x($length + 24) . "\n";
|
||||
printf "%-18s | %7d | %7.3f%% |\n", "", $sum, 100;
|
||||
}
|
||||
|
||||
if (keys %Responses) {
|
||||
$sum = Sum(\%Responses);
|
||||
$length = MaxLength(\%Responses);
|
||||
print "\n**Response result counters ((D=<depth> R=<result>)**\n";
|
||||
print "* Flags: ST:sync-token SC:sync-collection GCT:getctag GET:getetag\n" if ($Detail >= 15);
|
||||
print "* Location: L=<HASH> -> see below L=<FILE> -> see raw log\n" if (scalar(keys %Locations) > 0);
|
||||
printf "%-" . $length . "s | %7s | %9s |\n", "Response", "cnt", "ratio";
|
||||
print "-" x($length + 24) . "\n";
|
||||
foreach my $req (sort keys %Responses) {
|
||||
printf "%-" . $length . "s | %7d | %7.3f%% |\n", $req, $Responses{$req}, (($Responses{$req} * 100) / $sum);
|
||||
}
|
||||
print "-" x($length + 24) . "\n";
|
||||
printf "%-" . $length . "s | %7d | %7.3f%% |\n", "", $sum, 100;
|
||||
}
|
||||
|
||||
if (keys %ResponseTimes) {
|
||||
print "\n**Response timings (counts, seconds) (D=<depth> R=<result>)**\n";
|
||||
printf "%-18s | %7s | %7s | %7s | %7s |\n", "Response", "cnt", "min", "max", "avg";
|
||||
print "-" x60 . "\n";
|
||||
$length = MaxLength(\%ResponseTimes);
|
||||
print "\n**Response timings (counts, seconds) (D=<depth> R=<result> F=<flags>)**\n";
|
||||
print "* Flags: ST:sync-token SC:sync-collection GCT:getctag GET:getetag\n" if ($Detail >= 15);
|
||||
print "* Location: L=<HASH> -> see below L=<FILE> -> see raw log\n" if (scalar(keys %Locations) > 0);
|
||||
printf "%-" . $length . "s | %7s | %7s | %7s | %7s |\n", "Response", "cnt", "min", "max", "avg";
|
||||
print "-" x($length + 42) . "\n";
|
||||
foreach my $req (sort keys %ResponseTimes) {
|
||||
printf "%-18s | %7d | %7.3f | %7.3f | %7.3f |\n", $req
|
||||
printf "%-" . $length . "s | %7d | %7.3f | %7.3f | %7.3f |\n", $req
|
||||
, $ResponseTimes{$req}->{'cnt'}
|
||||
, $ResponseTimes{$req}->{'min'}
|
||||
, $ResponseTimes{$req}->{'max'}
|
||||
, $ResponseTimes{$req}->{'sum'} / $ResponseTimes{$req}->{'cnt'};
|
||||
}
|
||||
print "-" x60 . "\n";
|
||||
print "-" x($length + 42) . "\n";
|
||||
}
|
||||
|
||||
if (keys %ResponseSizes) {
|
||||
for my $type (sort keys %ResponseSizes) {
|
||||
$length = MaxLength($ResponseSizes{$type});
|
||||
print "\n**Response sizes (counts, bytes: $type) (D=<depth> R=<result>)**\n";
|
||||
print "* Flags: ST:sync-token SC:sync-collection GCT:getctag GET:getetag\n" if ($Detail >= 15);
|
||||
print "* Location: L=<HASH> -> see below L=<FILE> -> see raw log\n" if (scalar(keys %Locations) > 0);
|
||||
printf "%-" . $length . "s | %7s | %9s | %9s | %9s |\n", "Response", "cnt", "min", "max", "avg";
|
||||
print "-" x($length + 48) . "\n";
|
||||
foreach my $req (sort keys %{$ResponseSizes{$type}}) {
|
||||
printf "%-" . $length . "s | %7d | %9d | %9d | %9d |\n", $req
|
||||
, $ResponseSizes{$type}->{$req}->{'cnt'}
|
||||
, $ResponseSizes{$type}->{$req}->{'min'}
|
||||
, $ResponseSizes{$type}->{$req}->{'max'}
|
||||
, $ResponseSizes{$type}->{$req}->{'sum'} / $ResponseSizes{$type}->{$req}->{'cnt'};
|
||||
}
|
||||
print "-" x($length + 48) . "\n";
|
||||
}
|
||||
}
|
||||
|
||||
if (keys %OtherEvents) {
|
||||
@@ -188,6 +383,28 @@ if (keys %OtherList) {
|
||||
}
|
||||
}
|
||||
|
||||
if (scalar(keys %LoginsHash) > 0) {
|
||||
print "\n**Map of login hashes (REMOVE THIS FOR PRIVACY REASONS before submit)**\n";
|
||||
$length = MaxLength(\%LoginsHash);
|
||||
printf "%-10s | %-" . $length . "s | \n", "Hash", "Login";
|
||||
print "-" x($length + 15) . "\n";
|
||||
foreach my $login (sort { $LoginsHash{$a} cmp $LoginsHash{$b} } keys %LoginsHash) {
|
||||
printf "%10s | %-" . $length . "s |\n", $LoginsHash{$login}, $login;
|
||||
}
|
||||
print "-" x($length + 15) . "\n";
|
||||
}
|
||||
|
||||
if (scalar(keys %Locations) > 0) {
|
||||
print "\n**Map of location hashes (REMOVE THIS FOR PRIVACY REASONS before submit)**\n";
|
||||
$length = MaxLength(\%Locations);
|
||||
printf "%-10s | %-" . $length . "s | \n", "Hash", "Location";
|
||||
print "-" x($length + 15) . "\n";
|
||||
foreach my $loc (sort { $Locations{$a} cmp $Locations{$b} } keys %Locations) {
|
||||
printf "%10s | %-" . $length . "s |\n", $Locations{$loc}, $loc;
|
||||
}
|
||||
print "-" x($length + 15) . "\n";
|
||||
}
|
||||
|
||||
exit(0);
|
||||
|
||||
# vim: shiftwidth=3 tabstop=3 syntax=perl et smartindent
|
||||
|
||||
@@ -8,7 +8,7 @@ rewrite ^/.well-known/caldav /radicale/ redirect;
|
||||
|
||||
## Base URI: /radicale/
|
||||
location /radicale/ {
|
||||
proxy_pass http://localhost:5232/;
|
||||
proxy_pass http://localhost:5232;
|
||||
proxy_set_header X-Script-Name /radicale;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
@@ -20,7 +20,7 @@ location /radicale/ {
|
||||
|
||||
## Base URI: /
|
||||
#location / {
|
||||
# proxy_pass http://localhost:5232/;
|
||||
# proxy_pass http://localhost:5232;
|
||||
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
# proxy_set_header X-Forwarded-Host $host;
|
||||
# proxy_set_header X-Forwarded-Port $server_port;
|
||||
|
||||
288
docs/DOCUMENTATION.te.md
Normal file
288
docs/DOCUMENTATION.te.md
Normal file
@@ -0,0 +1,288 @@
|
||||
> Last updated: 2025-10-20 by [@gowtham1412-p](https://github.com/gowtham1412-p)
|
||||
|
||||
> Based on commit: [4fdc78760914040d5f74ece8978013b8836a712e] of [DOCUMENTATION.md](https://github.com/Kozea/Radicale/blob/master/DOCUMENTATION.md)
|
||||
|
||||
\# డాక్యుమెంటేషన్
|
||||
|
||||
|
||||
|
||||
\## ప్రారంభించడం
|
||||
|
||||
|
||||
|
||||
\#### రాడికేల్ గురించి
|
||||
|
||||
|
||||
|
||||
రాడికేల్ అనేది ఒక చిన్న కానీ శక్తివంతమైన CalDAV (క్యాలెండర్లు, చేయవలసిన జాబితాలు) మరియు CardDAV
|
||||
|
||||
(పరిచయాలు) సర్వర్, ఇది:
|
||||
|
||||
|
||||
|
||||
\* CalDAV, CardDAV మరియు HTTP ద్వారా క్యాలెండర్లు మరియు పరిచయ జాబితాలను పంచుకుంటుంది.
|
||||
|
||||
\* ఈవెంట్లు, టోడోలు, జర్నల్ ఎంట్రీలు మరియు వ్యాపార కార్డులకు మద్దతు ఇస్తుంది.
|
||||
|
||||
\* బాక్స్ వెలుపల పనిచేస్తుంది, సంక్లిష్టమైన సెటప్ లేదా కాన్ఫిగరేషన్ అవసరం లేదు.
|
||||
|
||||
\* సౌకర్యవంతమైన ప్రామాణీకరణ ఎంపికలను అందిస్తుంది.
|
||||
|
||||
\* అధికారం ద్వారా యాక్సెస్ను పరిమితం చేయవచ్చు.
|
||||
|
||||
\* TLSతో కనెక్షన్లను సురక్షితం చేయవచ్చు.
|
||||
|
||||
\* చాలా మందితో పనిచేస్తుంది
|
||||
|
||||
\[CalDAV మరియు CardDAV క్లయింట్లు](#సపోర్టెడ్-క్లయింట్లు).
|
||||
|
||||
\* ఫైల్ సిస్టమ్లోని అన్ని డేటాను సాధారణ ఫోల్డర్ నిర్మాణంలో నిల్వ చేస్తుంది.
|
||||
|
||||
\* ప్లగిన్లతో పొడిగించవచ్చు.
|
||||
|
||||
\* GPLv3-లైసెన్స్ పొందిన ఉచిత సాఫ్ట్వేర్.
|
||||
|
||||
|
||||
|
||||
\#### ఇన్స్టాలేషన్
|
||||
|
||||
|
||||
|
||||
తనిఖీ చేయండి
|
||||
|
||||
|
||||
|
||||
\* \[ట్యుటోరియల్స్](#ట్యుటోరియల్స్)
|
||||
|
||||
\* \[డాక్యుమెంటేషన్](#డాక్యుమెంటేషన్-1)
|
||||
|
||||
\* \[GitHubలో వికీ](https://github.com/Kozea/Radicale/wiki)
|
||||
|
||||
\* \[GitHubలో చర్చలు](https://github.com/Kozea/Radicale/discussions)
|
||||
|
||||
\* \[GitHubలో తెరిచి ఉన్న మరియు ఇప్పటికే మూసివేయబడిన సమస్యలు](https://github.com/Kozea/Radicale/issues?q=is%3Aissue)
|
||||
|
||||
|
||||
|
||||
\#### కొత్తగా ఏముంది?
|
||||
|
||||
|
||||
|
||||
\[GitHubలో చేంజ్లాగ్](https://github.com/Kozea/Radicale/blob/master/CHANGELOG.md) చదవండి.
|
||||
|
||||
|
||||
|
||||
\## ట్యుటోరియల్స్
|
||||
|
||||
|
||||
|
||||
\### 5 నిమిషాల సులభమైన సెటప్
|
||||
|
||||
|
||||
|
||||
మీరు Radicaleని ప్రయత్నించాలనుకుంటున్నారా కానీ మీ క్యాలెండర్లో 5 నిమిషాలు మాత్రమే ఖాళీగా ఉందా?
|
||||
|
||||
|
||||
|
||||
ఇప్పుడే వెళ్లి Radicaleతో కొంచెం ఆడుదాం!
|
||||
|
||||
|
||||
|
||||
ఈ విభాగం నుండి సెట్టింగ్లతో కాన్ఫిగర్ చేయబడిన సర్వర్, localhost
|
||||
|
||||
కి మాత్రమే బైండ్ అవుతుంది (అంటే ఇది నెట్వర్క్ ద్వారా చేరుకోలేరు), మరియు మీరు ఏదైనా వినియోగదారు పేరు మరియు పాస్వర్డ్తో లాగిన్ అవ్వవచ్చు.
|
||||
|
||||
|
||||
|
||||
ప్రతిదీ పనిచేసినప్పుడు, మీరు స్థానిక \[client](#supported-clients)
|
||||
|
||||
ని పొందవచ్చు మరియు క్యాలెండర్లు మరియు చిరునామా పుస్తకాలను సృష్టించడం ప్రారంభించవచ్చు.
|
||||
|
||||
|
||||
|
||||
Radicale మీ అవసరాలకు సరిపోతుంటే, రిమోట్ క్లయింట్లు మరియు కావలసిన ప్రామాణీకరణ రకానికి మద్దతు ఇవ్వడానికి కొంత \[ప్రాథమిక కాన్ఫిగరేషన్](#basic-configuration)
|
||||
|
||||
కి సమయం కావచ్చు.
|
||||
|
||||
|
||||
|
||||
మీ ఆపరేటింగ్ సిస్టమ్ను బట్టి దిగువన ఉన్న అధ్యాయాలలో ఒకదాన్ని అనుసరించండి.
|
||||
|
||||
|
||||
|
||||
\#### Linux / \\\*BSD
|
||||
|
||||
|
||||
|
||||
సూచన: PyPI నుండి డౌన్లోడ్ చేయడానికి బదులుగా, మీ \[distribution](#linux-distribution-packages) అందించిన ప్యాకేజీల కోసం చూడండి.
|
||||
|
||||
|
||||
|
||||
అవి మీ పంపిణీలలో ఇంటిగ్రేట్ చేయబడిన స్టార్టప్ స్క్రిప్ట్లను కూడా కలిగి ఉంటాయి, ఇవి Radicaleని డెమోనైజ్ చేయడానికి అనుమతిస్తాయి.
|
||||
|
||||
|
||||
|
||||
ముందుగా, \*\*python\*\* 3.9 లేదా తరువాత మరియు \*\*pip\*\* ఇన్స్టాల్ చేయబడిందని నిర్ధారించుకోండి. చాలా డిస్ట్రిబ్యూషన్లలో ``python3-pip`` ప్యాకేజీని ఇన్స్టాల్ చేయడానికి సరిపోతుంది.
|
||||
|
||||
|
||||
|
||||
\##### సాధారణ వినియోగదారుగా
|
||||
|
||||
|
||||
|
||||
పరీక్ష కోసం మాత్రమే సిఫార్సు చేయబడింది - కన్సోల్ను తెరిచి ఇలా టైప్ చేయండి:
|
||||
|
||||
|
||||
|
||||
```bash
|
||||
|
||||
\# ప్రస్తుత వినియోగదారు కోసం మాత్రమే ఇన్స్టాల్ చేయడానికి కింది ఆదేశాన్ని అమలు చేయండి
|
||||
|
||||
python3 -m pip install --user --upgrade https://github.com/Kozea/Radicale/archive/master.tar.gz
|
||||
|
||||
```
|
||||
|
||||
|
||||
|
||||
\_install\_ పని చేయకపోతే మరియు బదులుగా `error: externally-managed-environment` ప్రదర్శించబడితే,
|
||||
|
||||
ముందుగానే వర్చువల్ వాతావరణాన్ని సృష్టించండి మరియు సక్రియం చేయండి.
|
||||
|
||||
|
||||
|
||||
```bash
|
||||
|
||||
python3 -m venv ~/venv
|
||||
|
||||
source ~/venv/bin/activate
|
||||
|
||||
```
|
||||
|
||||
|
||||
|
||||
మరియు దీనితో ఇన్స్టాల్ చేయడానికి ప్రయత్నించండి
|
||||
|
||||
|
||||
|
||||
```bash
|
||||
|
||||
python3 -m pip install --upgrade https://github.com/Kozea/Radicale/archive/master.tar.gz
|
||||
|
||||
```
|
||||
|
||||
|
||||
|
||||
సేవను మాన్యువల్గా ప్రారంభించండి, డేటా ప్రస్తుత వినియోగదారు కోసం మాత్రమే నిల్వ చేయబడుతుంది
|
||||
|
||||
|
||||
|
||||
```bash
|
||||
|
||||
\# ప్రారంభించు, డేటా ప్రస్తుత వినియోగదారు కోసం మాత్రమే నిల్వ చేయబడుతుంది
|
||||
|
||||
python3 -m radicale --storage-filesystem-folder=~/.var/lib/radicale/collections --auth-type none
|
||||
|
||||
```
|
||||
|
||||
|
||||
|
||||
\#### సిస్టమ్ వినియోగదారుగా (లేదా రూట్గా)
|
||||
|
||||
|
||||
|
||||
ప్రత్యామ్నాయంగా, మీరు సిస్టమ్ వినియోగదారుగా లేదా రూట్గా ఇన్స్టాల్ చేసి అమలు చేయవచ్చు (సిఫార్సు చేయబడలేదు):
|
||||
|
||||
|
||||
|
||||
```bash
|
||||
|
||||
\# కింది ఆదేశాన్ని రూట్ (సిఫార్సు చేయబడలేదు) లేదా రూట్ కాని వ్యవస్థ వినియోగదారుగా అమలు చేయండి
|
||||
|
||||
\# (డిపెండెన్సీలు లేనప్పుడు తరువాతి వాటికి --user అవసరం కావచ్చు సిస్టమ్-వైడ్ మరియు/లేదా వర్చువల్ ఎన్విరాన్మెంట్ అందుబాటులో ఉంది)
|
||||
|
||||
python3 -m pip install --upgrade https://github.com/Kozea/Radicale/archive/master.tar.gz
|
||||
|
||||
```
|
||||
|
||||
|
||||
|
||||
`/var/lib/radicale/collections` కింద సిస్టమ్ ఫోల్డర్లో నిల్వ చేయబడిన డేటాతో సేవను మాన్యువల్గా ప్రారంభించండి:
|
||||
|
||||
|
||||
|
||||
```bash
|
||||
|
||||
\# Start, డేటా సిస్టమ్ ఫోల్డర్లో నిల్వ చేయబడుతుంది (/var/lib/radicale/collections కు వ్రాయడానికి అనుమతులు అవసరం)
|
||||
|
||||
python3 -m radicale --storage-filesystem-folder=/var/lib/radicale/collections --auth-type none
|
||||
|
||||
```
|
||||
|
||||
|
||||
|
||||
\#### Windows
|
||||
|
||||
|
||||
|
||||
మొదటి దశ పైథాన్ను ఇన్స్టాల్ చేయడం.
|
||||
|
||||
\[python.org](https://python.org) కు వెళ్లి పైథాన్ 3 యొక్క తాజా వెర్షన్ను డౌన్లోడ్ చేసుకోండి.
|
||||
|
||||
తర్వాత ఇన్స్టాలర్ను అమలు చేయండి.
|
||||
|
||||
ఇన్స్టాలర్ యొక్క మొదటి విండోలో, "PATH కు పైథాన్ను జోడించు" బాక్స్ను తనిఖీ చేసి,
|
||||
|
||||
"ఇప్పుడే ఇన్స్టాల్ చేయి"పై క్లిక్ చేయండి. రెండు నిమిషాలు వేచి ఉండండి, పూర్తయింది!
|
||||
|
||||
|
||||
|
||||
కమాండ్ ప్రాంప్ట్ను ప్రారంభించి ఇలా టైప్ చేయండి:
|
||||
|
||||
|
||||
|
||||
```powershell
|
||||
|
||||
python -m pip install --upgrade https://github.com/Kozea/Radicale/archive/master.tar.gz
|
||||
|
||||
python -m radicale --storage-filesystem-folder=~/radicale/collections --auth-type none
|
||||
|
||||
```
|
||||
|
||||
|
||||
|
||||
\##### Common
|
||||
|
||||
|
||||
|
||||
విజయవంతం!!! మీ బ్రౌజర్లో <http://localhost:5232> తెరవండి!
|
||||
|
||||
ఉదాహరణ ఎంపిక `--auth-type none` ద్వారా ప్రామాణీకరణ అవసరం లేనందున మీరు ఏదైనా వినియోగదారు పేరు మరియు పాస్వర్డ్తో లాగిన్ అవ్వవచ్చు.
|
||||
|
||||
ఇది \*\*సురక్షితం\*\*, మరిన్ని వివరాల కోసం \[కాన్ఫిగరేషన్/ప్రామాణీకరణ](#auth) చూడండి.
|
||||
|
||||
|
||||
|
||||
భద్రతా కారణాల దృష్ట్యా డిఫాల్ట్ కాన్ఫిగరేషన్ సర్వర్ను `localhost` (IPv4: `127.0.0.1`, IPv6: `::1`) కు బంధిస్తుందని గమనించండి.
|
||||
|
||||
|
||||
|
||||
మరిన్ని వివరాల కోసం \[చిరునామాలు](#చిరునామాలు) మరియు \[కాన్ఫిగరేషన్/సర్వర్](#సర్వర్) చూడండి.
|
||||
|
||||
|
||||
|
||||
\### ప్రాథమిక కాన్ఫిగరేషన్
|
||||
|
||||
|
||||
|
||||
ఇన్స్టాలేషన్ సూచనలను
|
||||
|
||||
\[సరళమైన 5-నిమిషాల సెటప్](#సింపుల్-5-నిమిషాల-సెటప్) ట్యుటోరియల్లో చూడవచ్చు.
|
||||
|
||||
|
||||
|
||||
రాడికేల్ `/etc/radicale/config` మరియు
|
||||
|
||||
`~/.config/radicale/config` నుండి కాన్ఫిగరేషన్ ఫైల్లను లోడ్ చేయడానికి ప్రయత్నిస్తుంది.
|
||||
|
||||
Cu
|
||||
|
||||
@@ -3,7 +3,7 @@ name = "Radicale"
|
||||
# When the version is updated, a new section in the CHANGELOG.md file must be
|
||||
# added too.
|
||||
readme = "README.md"
|
||||
version = "3.5.5.dev"
|
||||
version = "3.6.1.dev"
|
||||
authors = [{name = "Guillaume Ayoub", email = "guillaume.ayoub@kozea.fr"}, {name = "Unrud", email = "unrud@outlook.com"}, {name = "Peter Bieringer", email = "pb@bieringer.de"}]
|
||||
license = {text = "GNU GPL v3"}
|
||||
description = "CalDAV and CardDAV Server"
|
||||
@@ -28,12 +28,14 @@ classifiers = [
|
||||
]
|
||||
urls = {Homepage = "https://radicale.org/"}
|
||||
requires-python = ">=3.9.0"
|
||||
# Hint: if bcyrpt < 5.0.0 is used, passlib(libpass) dependency can be downgraded/reverted by: sed -i 's|libpass[^"]*|passlib|' pyproject.toml
|
||||
dependencies = [
|
||||
"defusedxml",
|
||||
"passlib",
|
||||
"libpass>=1.9.3",
|
||||
"vobject>=0.9.6",
|
||||
"pika>=1.1.0",
|
||||
"requests",
|
||||
"packaging",
|
||||
]
|
||||
|
||||
|
||||
@@ -104,7 +106,7 @@ radicale = [
|
||||
|
||||
[tool.isort]
|
||||
known_standard_library = "_dummy_thread,_thread,abc,aifc,argparse,array,ast,asynchat,asyncio,asyncore,atexit,audioop,base64,bdb,binascii,binhex,bisect,builtins,bz2,cProfile,calendar,cgi,cgitb,chunk,cmath,cmd,code,codecs,codeop,collections,colorsys,compileall,concurrent,configparser,contextlib,contextvars,copy,copyreg,crypt,csv,ctypes,curses,dataclasses,datetime,dbm,decimal,difflib,dis,distutils,doctest,dummy_threading,email,encodings,ensurepip,enum,errno,faulthandler,fcntl,filecmp,fileinput,fnmatch,formatter,fpectl,fractions,ftplib,functools,gc,getopt,getpass,gettext,glob,grp,gzip,hashlib,heapq,hmac,html,http,imaplib,imghdr,imp,importlib,inspect,io,ipaddress,itertools,json,keyword,lib2to3,linecache,locale,logging,lzma,macpath,mailbox,mailcap,marshal,math,mimetypes,mmap,modulefinder,msilib,msvcrt,multiprocessing,netrc,nis,nntplib,ntpath,numbers,operator,optparse,os,ossaudiodev,parser,pathlib,pdb,pickle,pickletools,pipes,pkgutil,platform,plistlib,poplib,posix,posixpath,pprint,profile,pstats,pty,pwd,py_compile,pyclbr,pydoc,queue,quopri,random,re,readline,reprlib,resource,rlcompleter,runpy,sched,secrets,select,selectors,shelve,shlex,shutil,signal,site,smtpd,smtplib,sndhdr,socket,socketserver,spwd,sqlite3,sre,sre_compile,sre_constants,sre_parse,ssl,stat,statistics,string,stringprep,struct,subprocess,sunau,symbol,symtable,sys,sysconfig,syslog,tabnanny,tarfile,telnetlib,tempfile,termios,test,textwrap,threading,time,timeit,tkinter,token,tokenize,trace,traceback,tracemalloc,tty,turtle,turtledemo,types,typing,unicodedata,unittest,urllib,uu,uuid,venv,warnings,wave,weakref,webbrowser,winreg,winsound,wsgiref,xdrlib,xml,xmlrpc,zipapp,zipfile,zipimport,zlib"
|
||||
known_third_party = "defusedxml,passlib,pkg_resources,pytest,vobject"
|
||||
known_third_party = "defusedxml,libpass,pkg_resources,pytest,vobject"
|
||||
|
||||
[tool.mypy]
|
||||
ignore_missing_imports = true
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# This file is part of Radicale - CalDAV and CardDAV server
|
||||
# Copyright © 2011-2017 Guillaume Ayoub
|
||||
# Copyright © 2017-2022 Unrud <unrud@outlook.com>
|
||||
# Copyright © 2024-2024 Peter Bieringer <pb@bieringer.de>
|
||||
# Copyright © 2024-2025 Peter Bieringer <pb@bieringer.de>
|
||||
#
|
||||
# This library is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
@@ -33,7 +33,7 @@ import sys
|
||||
from types import FrameType
|
||||
from typing import List, Optional, cast
|
||||
|
||||
from radicale import VERSION, config, log, server, storage, types
|
||||
from radicale import VERSION, config, item, log, server, storage, types
|
||||
from radicale.log import logger
|
||||
|
||||
|
||||
@@ -65,6 +65,8 @@ def run() -> None:
|
||||
parser.add_argument("--version", action="version", version=VERSION)
|
||||
parser.add_argument("--verify-storage", action="store_true",
|
||||
help="check the storage for errors and exit")
|
||||
parser.add_argument("--verify-item", action="store", nargs=1,
|
||||
help="check the provided item file for errors and exit")
|
||||
parser.add_argument("-C", "--config",
|
||||
help="use specific configuration files", nargs="*")
|
||||
parser.add_argument("-D", "--debug", action="store_const", const="debug",
|
||||
@@ -194,6 +196,19 @@ def run() -> None:
|
||||
sys.exit(1)
|
||||
return
|
||||
|
||||
if args_ns.verify_item:
|
||||
encoding = configuration.get("encoding", "stock")
|
||||
logger.info("Item verification start using 'stock' encoding: %s", encoding)
|
||||
try:
|
||||
if not item.verify(args_ns.verify_item[0], encoding):
|
||||
logger.critical("Item verification failed")
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
logger.critical("An exception occurred during item "
|
||||
"verification: %s", e, exc_info=False)
|
||||
sys.exit(1)
|
||||
return
|
||||
|
||||
# Create a socket pair to notify the server of program shutdown
|
||||
shutdown_socket, shutdown_socket_out = socket.socketpair()
|
||||
|
||||
|
||||
@@ -27,15 +27,19 @@ the built-in server (see ``radicale.server`` module).
|
||||
"""
|
||||
|
||||
import base64
|
||||
import cProfile
|
||||
import datetime
|
||||
import io
|
||||
import logging
|
||||
import pprint
|
||||
import pstats
|
||||
import random
|
||||
import time
|
||||
import zlib
|
||||
from http import client
|
||||
from typing import Iterable, List, Mapping, Tuple, Union
|
||||
|
||||
from radicale import config, httputils, log, pathutils, types
|
||||
from radicale import config, httputils, log, pathutils, types, utils
|
||||
from radicale.app.base import ApplicationBase
|
||||
from radicale.app.delete import ApplicationPartDelete
|
||||
from radicale.app.get import ApplicationPartGet
|
||||
@@ -49,11 +53,14 @@ from radicale.app.propfind import ApplicationPartPropfind
|
||||
from radicale.app.proppatch import ApplicationPartProppatch
|
||||
from radicale.app.put import ApplicationPartPut
|
||||
from radicale.app.report import ApplicationPartReport
|
||||
from radicale.auth import AuthContext
|
||||
from radicale.log import logger
|
||||
|
||||
# Combination of types.WSGIStartResponse and WSGI application return value
|
||||
_IntermediateResponse = Tuple[str, List[Tuple[str, str]], Iterable[bytes]]
|
||||
|
||||
REQUEST_METHODS = ["DELETE", "GET", "HEAD", "MKCALENDAR", "MKCOL", "MOVE", "OPTIONS", "POST", "PROPFIND", "PROPPATCH", "PUT", "REPORT"]
|
||||
|
||||
|
||||
class Application(ApplicationPartDelete, ApplicationPartHead,
|
||||
ApplicationPartGet, ApplicationPartMkcalendar,
|
||||
@@ -67,11 +74,18 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
|
||||
_auth_delay: float
|
||||
_internal_server: bool
|
||||
_max_content_length: int
|
||||
_max_resource_size: int
|
||||
_auth_realm: str
|
||||
_auth_type: str
|
||||
_web_type: str
|
||||
_script_name: str
|
||||
_extra_headers: Mapping[str, str]
|
||||
_permit_delete_collection: bool
|
||||
_permit_overwrite_collection: bool
|
||||
_profiling_per_request: bool = False
|
||||
_profiling_per_request_method: bool = False
|
||||
profiler_per_request_method: dict[str, cProfile.Profile] = {}
|
||||
profiler_per_request_method_counter: dict[str, int] = {}
|
||||
profiler_per_request_method_starttime: datetime.datetime
|
||||
profiler_per_request_method_logtime: datetime.datetime
|
||||
|
||||
def __init__(self, configuration: config.Configuration) -> None:
|
||||
"""Initialize Application.
|
||||
@@ -83,10 +97,28 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
|
||||
"""
|
||||
super().__init__(configuration)
|
||||
self._mask_passwords = configuration.get("logging", "mask_passwords")
|
||||
self._max_content_length = configuration.get("server", "max_content_length")
|
||||
self._max_resource_size = configuration.get("server", "max_resource_size")
|
||||
logger.info("max_content_length set to: %d bytes (%sbytes)", self._max_content_length, utils.format_unit(self._max_content_length, binary=True))
|
||||
if (self._max_resource_size > (self._max_content_length * 0.8)):
|
||||
max_resource_size_limited = int(self._max_content_length * 0.8)
|
||||
logger.warning("max_resource_size set to: %d bytes (%sbytes) (capped from %d to 80%% of max_content_length)", max_resource_size_limited, utils.format_unit(max_resource_size_limited, binary=True), self._max_resource_size)
|
||||
self._max_resource_size = max_resource_size_limited
|
||||
else:
|
||||
logger.info("max_resource_size set to: %d bytes (%sbytes)", self._max_resource_size, utils.format_unit(self._max_resource_size, binary=True))
|
||||
self._bad_put_request_content = configuration.get("logging", "bad_put_request_content")
|
||||
logger.info("log bad put request content: %s", self._bad_put_request_content)
|
||||
self._request_header_on_debug = configuration.get("logging", "request_header_on_debug")
|
||||
self._request_content_on_debug = configuration.get("logging", "request_content_on_debug")
|
||||
self._response_header_on_debug = configuration.get("logging", "response_header_on_debug")
|
||||
self._response_content_on_debug = configuration.get("logging", "response_content_on_debug")
|
||||
logger.debug("log request header on debug: %s", self._request_header_on_debug)
|
||||
logger.debug("log request content on debug: %s", self._request_content_on_debug)
|
||||
logger.debug("log response header on debug: %s", self._response_header_on_debug)
|
||||
logger.debug("log response content on debug: %s", self._response_content_on_debug)
|
||||
self._auth_delay = configuration.get("auth", "delay")
|
||||
self._auth_type = configuration.get("auth", "type")
|
||||
self._web_type = configuration.get("web", "type")
|
||||
self._internal_server = configuration.get("server", "_internal_server")
|
||||
self._script_name = configuration.get("server", "script_name")
|
||||
if self._script_name:
|
||||
@@ -111,6 +143,59 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
|
||||
self._extra_headers = dict()
|
||||
for key in self.configuration.options("headers"):
|
||||
self._extra_headers[key] = configuration.get("headers", key)
|
||||
self._strict_preconditions = configuration.get("storage", "strict_preconditions")
|
||||
logger.info("strict preconditions check: %s", self._strict_preconditions)
|
||||
# Profiling options
|
||||
self._profiling = configuration.get("logging", "profiling")
|
||||
self._profiling_per_request_min_duration = configuration.get("logging", "profiling_per_request_min_duration")
|
||||
self._profiling_per_request_header = configuration.get("logging", "profiling_per_request_header")
|
||||
self._profiling_per_request_xml = configuration.get("logging", "profiling_per_request_xml")
|
||||
self._profiling_per_request_method_interval = configuration.get("logging", "profiling_per_request_method_interval")
|
||||
self._profiling_top_x_functions = configuration.get("logging", "profiling_top_x_functions")
|
||||
if self._profiling in config.PROFILING:
|
||||
logger.info("profiling: %r", self._profiling)
|
||||
if self._profiling == "per_request":
|
||||
self._profiling_per_request = True
|
||||
elif self._profiling == "per_request_method":
|
||||
self._profiling_per_request_method = True
|
||||
if self._profiling_per_request or self._profiling_per_request_method:
|
||||
logger.info("profiling top X functions: %d", self._profiling_top_x_functions)
|
||||
if self._profiling_per_request:
|
||||
logger.info("profiling per request minimum duration: %d (below are skipped)", self._profiling_per_request_min_duration)
|
||||
logger.info("profiling per request header: %s", self._profiling_per_request_header)
|
||||
logger.info("profiling per request xml : %s", self._profiling_per_request_xml)
|
||||
if self._profiling_per_request_method:
|
||||
logger.info("profiling per request method interval: %d seconds", self._profiling_per_request_method_interval)
|
||||
# Profiling per request method initialization
|
||||
if self._profiling_per_request_method:
|
||||
for method in REQUEST_METHODS:
|
||||
self.profiler_per_request_method[method] = cProfile.Profile()
|
||||
self.profiler_per_request_method_counter[method] = False
|
||||
self.profiler_per_request_method_starttime = datetime.datetime.now()
|
||||
self.profiler_per_request_method_logtime = self.profiler_per_request_method_starttime
|
||||
|
||||
def __del__(self) -> None:
|
||||
"""Shutdown application."""
|
||||
if self._profiling_per_request_method:
|
||||
# Profiling since startup
|
||||
self._profiler_per_request_method(True)
|
||||
|
||||
def _profiler_per_request_method(self, shutdown: bool = False) -> None:
|
||||
"""Display profiler data per method."""
|
||||
profiler_timedelta_start = (datetime.datetime.now() - self.profiler_per_request_method_starttime).total_seconds()
|
||||
for method in REQUEST_METHODS:
|
||||
if self.profiler_per_request_method_counter[method] > 0:
|
||||
s = io.StringIO()
|
||||
s.write("**Profiling statistics BEGIN**\n")
|
||||
stats = pstats.Stats(self.profiler_per_request_method[method], stream=s).sort_stats('cumulative')
|
||||
stats.print_stats(self._profiling_top_x_functions) # Print top X functions
|
||||
s.write("**Profiling statistics END**\n")
|
||||
logger.info("Profiling data per request method %s after %d seconds and %d requests:\n%s", method, profiler_timedelta_start, self.profiler_per_request_method_counter[method], utils.textwrap_str(s.getvalue(), -1))
|
||||
else:
|
||||
if shutdown:
|
||||
logger.info("Profiling data per request method %s after %d seconds: (no request seen so far)", method, profiler_timedelta_start)
|
||||
else:
|
||||
logger.debug("Profiling data per request method %s after %d seconds: (no request seen so far)", method, profiler_timedelta_start)
|
||||
|
||||
def _scrub_headers(self, environ: types.WSGIEnviron) -> types.WSGIEnviron:
|
||||
"""Mask passwords and cookies."""
|
||||
@@ -132,7 +217,7 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
|
||||
"%s", environ.get("REQUEST_METHOD", "unknown"),
|
||||
environ.get("PATH_INFO", ""), e, exc_info=True)
|
||||
# Make minimal response
|
||||
status, raw_headers, raw_answer = (
|
||||
status, raw_headers, raw_answer, xml_request = (
|
||||
httputils.INTERNAL_SERVER_ERROR)
|
||||
assert isinstance(raw_answer, str)
|
||||
answer = raw_answer.encode("ascii")
|
||||
@@ -151,20 +236,29 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
|
||||
request_method = environ["REQUEST_METHOD"].upper()
|
||||
unsafe_path = environ.get("PATH_INFO", "")
|
||||
https = environ.get("HTTPS", "")
|
||||
profiler = None
|
||||
profiler_active = False
|
||||
xml_request = None
|
||||
|
||||
context = AuthContext()
|
||||
|
||||
"""Manage a request."""
|
||||
def response(status: int, headers: types.WSGIResponseHeaders,
|
||||
answer: Union[None, str, bytes]) -> _IntermediateResponse:
|
||||
answer: Union[None, str, bytes],
|
||||
xml_request: Union[None, str] = None) -> _IntermediateResponse:
|
||||
"""Helper to create response from internal types.WSGIResponse"""
|
||||
headers = dict(headers)
|
||||
content_encoding = "plain"
|
||||
# Set content length
|
||||
answers = []
|
||||
if answer is not None:
|
||||
if isinstance(answer, str):
|
||||
if self._response_content_on_debug:
|
||||
logger.debug("Response content:\n%s", answer)
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("Response content (nonXML):\n%s", utils.textwrap_str(answer))
|
||||
else:
|
||||
logger.debug("Response content: suppressed by config/option [logging] response_content_on_debug")
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("Response content: suppressed by config/option [logging] response_content_on_debug")
|
||||
headers["Content-Type"] += "; charset=%s" % self._encoding
|
||||
answer = answer.encode(self._encoding)
|
||||
accept_encoding = [
|
||||
@@ -176,6 +270,7 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
|
||||
zcomp = zlib.compressobj(wbits=16 + zlib.MAX_WBITS)
|
||||
answer = zcomp.compress(answer) + zcomp.flush()
|
||||
headers["Content-Encoding"] = "gzip"
|
||||
content_encoding = "gzip"
|
||||
|
||||
headers["Content-Length"] = str(len(answer))
|
||||
answers.append(answer)
|
||||
@@ -183,13 +278,79 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
|
||||
# Add extra headers set in configuration
|
||||
headers.update(self._extra_headers)
|
||||
|
||||
if self._response_header_on_debug:
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("Response header:\n%s", utils.textwrap_str(pprint.pformat(headers)))
|
||||
else:
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("Response header: suppressed by config/option [logging] response_header_on_debug")
|
||||
|
||||
# Start response
|
||||
time_end = datetime.datetime.now()
|
||||
time_delta_seconds = (time_end - time_begin).total_seconds()
|
||||
status_text = "%d %s" % (
|
||||
status, client.responses.get(status, "Unknown"))
|
||||
logger.info("%s response status for %r%s in %.3f seconds: %s",
|
||||
request_method, unsafe_path, depthinfo,
|
||||
(time_end - time_begin).total_seconds(), status_text)
|
||||
flags = []
|
||||
if xml_request is not None:
|
||||
if "<sync-token />" in xml_request:
|
||||
flags.append("sync-token")
|
||||
if "<getetag />" in xml_request:
|
||||
flags.append("getetag")
|
||||
if "<CS:getctag />" in xml_request:
|
||||
flags.append("getctag")
|
||||
if "<sync-collection " in xml_request:
|
||||
flags.append("sync-collection")
|
||||
if flags:
|
||||
flags_text = " (" + " ".join(flags) + ")"
|
||||
else:
|
||||
flags_text = ""
|
||||
if answer is not None:
|
||||
logger.info("%s response status for %r%s in %.3f seconds %s %s bytes%s: %s",
|
||||
request_method, unsafe_path, depthinfo,
|
||||
(time_end - time_begin).total_seconds(), content_encoding, str(len(answer)),
|
||||
flags_text,
|
||||
status_text)
|
||||
else:
|
||||
logger.info("%s response status for %r%s in %.3f seconds: %s",
|
||||
request_method, unsafe_path, depthinfo,
|
||||
time_delta_seconds, status_text)
|
||||
|
||||
# Profiling end
|
||||
if self._profiling_per_request:
|
||||
if profiler_active is True:
|
||||
if profiler is not None:
|
||||
# Profiling per request
|
||||
if time_delta_seconds < self._profiling_per_request_min_duration:
|
||||
logger.debug("Profiling data per request %s for %r%s: (suppressed because duration below minimum %.3f < %.3f)", request_method, unsafe_path, depthinfo, time_delta_seconds, self._profiling_per_request_min_duration)
|
||||
else:
|
||||
s = io.StringIO()
|
||||
s.write("**Profiling statistics BEGIN**\n")
|
||||
stats = pstats.Stats(profiler, stream=s).sort_stats('cumulative')
|
||||
stats.print_stats(self._profiling_top_x_functions) # Print top X functions
|
||||
s.write("**Profiling statistics END**\n")
|
||||
if self._profiling_per_request_header:
|
||||
s.write("**Profiling request header BEGIN**\n")
|
||||
s.write(pprint.pformat(self._scrub_headers(environ)))
|
||||
s.write("\n**Profiling request header END**")
|
||||
if self._profiling_per_request_xml:
|
||||
if xml_request is not None:
|
||||
s.write("\n**Profiling request content (XML) BEGIN**\n")
|
||||
if xml_request is not None:
|
||||
s.write(xml_request)
|
||||
s.write("**Profiling request content (XML) END**")
|
||||
logger.info("Profiling data per request %s for %r%s:\n%s", request_method, unsafe_path, depthinfo, utils.textwrap_str(s.getvalue(), -1))
|
||||
else:
|
||||
logger.debug("Profiling data per request %s for %r%s: (suppressed because of no data)", request_method, unsafe_path, depthinfo)
|
||||
else:
|
||||
logger.info("Profiling data per request %s for %r%s: (not available because of concurrent running profiling request)", request_method, unsafe_path, depthinfo)
|
||||
elif self._profiling_per_request_method:
|
||||
self.profiler_per_request_method[request_method].disable()
|
||||
self.profiler_per_request_method_counter[request_method] += 1
|
||||
profiler_timedelta = (datetime.datetime.now() - self.profiler_per_request_method_logtime).total_seconds()
|
||||
if profiler_timedelta > self._profiling_per_request_method_interval:
|
||||
self._profiler_per_request_method()
|
||||
self.profiler_per_request_method_logtime = datetime.datetime.now()
|
||||
|
||||
# Return response content
|
||||
return status_text, list(headers.items()), answers
|
||||
|
||||
@@ -197,12 +358,16 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
|
||||
remote_host = "unknown"
|
||||
if environ.get("REMOTE_HOST"):
|
||||
remote_host = repr(environ["REMOTE_HOST"])
|
||||
elif environ.get("REMOTE_ADDR"):
|
||||
remote_host = environ["REMOTE_ADDR"]
|
||||
if environ.get("REMOTE_ADDR"):
|
||||
if remote_host == 'unknown':
|
||||
remote_host = environ["REMOTE_ADDR"]
|
||||
context.remote_addr = environ["REMOTE_ADDR"]
|
||||
if environ.get("HTTP_X_FORWARDED_FOR"):
|
||||
reverse_proxy = True
|
||||
remote_host = "%s (forwarded for %r)" % (
|
||||
remote_host, environ["HTTP_X_FORWARDED_FOR"])
|
||||
if environ.get("HTTP_X_REMOTE_ADDR"):
|
||||
context.x_remote_addr = environ["HTTP_X_REMOTE_ADDR"]
|
||||
if environ.get("HTTP_X_FORWARDED_HOST") or environ.get("HTTP_X_FORWARDED_PROTO") or environ.get("HTTP_X_FORWARDED_SERVER"):
|
||||
reverse_proxy = True
|
||||
remote_useragent = ""
|
||||
@@ -220,7 +385,7 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
|
||||
remote_host, remote_useragent, https_info)
|
||||
if self._request_header_on_debug:
|
||||
logger.debug("Request header:\n%s",
|
||||
pprint.pformat(self._scrub_headers(environ)))
|
||||
utils.textwrap_str(pprint.pformat(self._scrub_headers(environ))))
|
||||
else:
|
||||
logger.debug("Request header: suppressed by config/option [logging] request_header_on_debug")
|
||||
|
||||
@@ -257,7 +422,10 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
|
||||
logger.debug("Called by reverse proxy, remove base prefix %r from path: %r => %r", base_prefix, path, path_new)
|
||||
path = path_new
|
||||
else:
|
||||
logger.warning("Called by reverse proxy, cannot remove base prefix %r from path: %r as not matching", base_prefix, path)
|
||||
if self._auth_type in ['remote_user', 'http_remote_user', 'http_x_remote_user'] and self._web_type == 'internal':
|
||||
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:
|
||||
logger.debug("Called by reverse proxy, cannot remove base prefix %r from path: %r as not matching", base_prefix, path)
|
||||
|
||||
# Get function corresponding to method
|
||||
function = getattr(self, "do_%s" % request_method, None)
|
||||
@@ -288,7 +456,7 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
|
||||
self.configuration, environ, base64.b64decode(
|
||||
authorization.encode("ascii"))).split(":", 1)
|
||||
|
||||
(user, info) = self._auth.login(login, password) or ("", "") if login else ("", "")
|
||||
(user, info) = self._auth.login(login, password, context) or ("", "") if login else ("", "")
|
||||
if self.configuration.get("auth", "type") == "ldap":
|
||||
try:
|
||||
logger.debug("Groups received from LDAP: %r", ",".join(self._auth._ldap_groups))
|
||||
@@ -323,7 +491,7 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
|
||||
if "W" in self._rights.authorization(user, principal_path):
|
||||
with self._storage.acquire_lock("w", user):
|
||||
try:
|
||||
new_coll = self._storage.create_collection(principal_path)
|
||||
new_coll, _, _ = self._storage.create_collection(principal_path)
|
||||
if new_coll:
|
||||
jsn_coll = self.configuration.get("storage", "predefined_collections")
|
||||
for (name_coll, props) in jsn_coll.items():
|
||||
@@ -349,15 +517,42 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
|
||||
return response(*httputils.REQUEST_ENTITY_TOO_LARGE)
|
||||
|
||||
if not login or user:
|
||||
status, headers, answer = function(
|
||||
environ, base_prefix, path, user)
|
||||
if (status, headers, answer) == httputils.NOT_ALLOWED:
|
||||
# Profiling
|
||||
if self._profiling_per_request:
|
||||
profiler = cProfile.Profile()
|
||||
try:
|
||||
profiler.enable()
|
||||
except ValueError:
|
||||
profiler_active = False
|
||||
else:
|
||||
profiler_active = True
|
||||
elif self._profiling_per_request_method:
|
||||
try:
|
||||
self.profiler_per_request_method[request_method].enable()
|
||||
except ValueError:
|
||||
profiler_active = False
|
||||
else:
|
||||
profiler_active = True
|
||||
|
||||
status, headers, answer, xml_request = function(
|
||||
environ, base_prefix, path, user, remote_host, remote_useragent)
|
||||
|
||||
# Profiling
|
||||
if self._profiling_per_request:
|
||||
if profiler is not None:
|
||||
if profiler_active is True:
|
||||
profiler.disable()
|
||||
elif self._profiling_per_request_method:
|
||||
if profiler_active is True:
|
||||
self.profiler_per_request_method[request_method].disable()
|
||||
|
||||
if (status, headers, answer, xml_request) == httputils.NOT_ALLOWED:
|
||||
logger.info("Access to %r denied for %s", path,
|
||||
repr(user) if user else "anonymous user")
|
||||
else:
|
||||
status, headers, answer = httputils.NOT_ALLOWED
|
||||
status, headers, answer, xml_request = httputils.NOT_ALLOWED
|
||||
|
||||
if ((status, headers, answer) == httputils.NOT_ALLOWED and not user and
|
||||
if ((status, headers, answer, xml_request) == httputils.NOT_ALLOWED and not user and
|
||||
not external_login):
|
||||
# Unknown or unauthorized user
|
||||
logger.debug("Asking client for authentication")
|
||||
@@ -367,4 +562,4 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
|
||||
"WWW-Authenticate":
|
||||
"Basic realm=\"%s\"" % self._auth_realm})
|
||||
|
||||
return response(status, headers, answer)
|
||||
return response(status, headers, answer, xml_request)
|
||||
|
||||
@@ -23,7 +23,7 @@ import xml.etree.ElementTree as ET
|
||||
from typing import Optional
|
||||
|
||||
from radicale import (auth, config, hook, httputils, pathutils, rights,
|
||||
storage, types, web, xmlutils)
|
||||
storage, types, utils, web, xmlutils)
|
||||
from radicale.log import logger
|
||||
|
||||
# HACK: https://github.com/tiran/defusedxml/issues/54
|
||||
@@ -39,8 +39,10 @@ class ApplicationBase:
|
||||
_rights: rights.BaseRights
|
||||
_web: web.BaseWeb
|
||||
_encoding: str
|
||||
_max_resource_size: int
|
||||
_permit_delete_collection: bool
|
||||
_permit_overwrite_collection: bool
|
||||
_strict_preconditions: bool
|
||||
_hook: hook.BaseHook
|
||||
|
||||
def __init__(self, configuration: config.Configuration) -> None:
|
||||
@@ -70,7 +72,7 @@ class ApplicationBase:
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
if self._request_content_on_debug:
|
||||
logger.debug("Request content (XML):\n%s",
|
||||
xmlutils.pretty_xml(xml_content))
|
||||
utils.textwrap_str(xmlutils.pretty_xml(xml_content)))
|
||||
else:
|
||||
logger.debug("Request content (XML): suppressed by config/option [logging] request_content_on_debug")
|
||||
return xml_content
|
||||
@@ -79,7 +81,7 @@ class ApplicationBase:
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
if self._response_content_on_debug:
|
||||
logger.debug("Response content (XML):\n%s",
|
||||
xmlutils.pretty_xml(xml_content))
|
||||
utils.textwrap_str(xmlutils.pretty_xml(xml_content)))
|
||||
else:
|
||||
logger.debug("Response content (XML): suppressed by config/option [logging] response_content_on_debug")
|
||||
f = io.BytesIO()
|
||||
@@ -92,7 +94,7 @@ class ApplicationBase:
|
||||
"""Generate XML error response."""
|
||||
headers = {"Content-Type": "text/xml; charset=%s" % self._encoding}
|
||||
content = self._xml_response(xmlutils.webdav_error(human_tag))
|
||||
return status, headers, content
|
||||
return status, headers, content, None
|
||||
|
||||
|
||||
class Access:
|
||||
|
||||
@@ -24,7 +24,7 @@ from typing import Optional
|
||||
|
||||
from radicale import httputils, storage, types, xmlutils
|
||||
from radicale.app.base import Access, ApplicationBase
|
||||
from radicale.hook import DeleteHookNotificationItem
|
||||
from radicale.hook import HookNotificationItem, HookNotificationItemTypes
|
||||
from radicale.log import logger
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ def xml_delete(base_prefix: str, path: str, collection: storage.BaseCollection,
|
||||
class ApplicationPartDelete(ApplicationBase):
|
||||
|
||||
def do_DELETE(self, environ: types.WSGIEnviron, base_prefix: str,
|
||||
path: str, user: str) -> types.WSGIResponse:
|
||||
path: str, user: str, remote_host: str, remote_useragent: str) -> types.WSGIResponse:
|
||||
"""Manage DELETE request."""
|
||||
access = Access(self._rights, user, path)
|
||||
if not access.check("w"):
|
||||
@@ -82,10 +82,13 @@ class ApplicationPartDelete(ApplicationBase):
|
||||
return httputils.NOT_ALLOWED
|
||||
for i in item.get_all():
|
||||
hook_notification_item_list.append(
|
||||
DeleteHookNotificationItem(
|
||||
access.path,
|
||||
i.uid,
|
||||
old_content=item.serialize() # type: ignore
|
||||
HookNotificationItem(
|
||||
notification_item_type=HookNotificationItemTypes.DELETE,
|
||||
path=access.path,
|
||||
content=i.uid,
|
||||
uid=i.uid,
|
||||
old_content=i.serialize(), # type: ignore
|
||||
new_content=None
|
||||
)
|
||||
)
|
||||
xml_answer = xml_delete(base_prefix, path, item)
|
||||
@@ -93,10 +96,13 @@ class ApplicationPartDelete(ApplicationBase):
|
||||
assert item.collection is not None
|
||||
assert item.href is not None
|
||||
hook_notification_item_list.append(
|
||||
DeleteHookNotificationItem(
|
||||
access.path,
|
||||
item.uid,
|
||||
old_content=item.serialize() # type: ignore
|
||||
HookNotificationItem(
|
||||
notification_item_type=HookNotificationItemTypes.DELETE,
|
||||
path=access.path,
|
||||
content=item.uid,
|
||||
uid=item.uid,
|
||||
old_content=item.serialize(), # type: ignore
|
||||
new_content=None,
|
||||
)
|
||||
)
|
||||
xml_answer = xml_delete(
|
||||
@@ -104,4 +110,4 @@ class ApplicationPartDelete(ApplicationBase):
|
||||
for notification_item in hook_notification_item_list:
|
||||
self._hook.notify(notification_item)
|
||||
headers = {"Content-Type": "text/xml; charset=%s" % self._encoding}
|
||||
return client.OK, headers, self._xml_response(xml_answer)
|
||||
return client.OK, headers, self._xml_response(xml_answer), None
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
# Copyright © 2008 Nicolas Kandel
|
||||
# Copyright © 2008 Pascal Halter
|
||||
# Copyright © 2008-2017 Guillaume Ayoub
|
||||
# Copyright © 2017-2018 Unrud <unrud@outlook.com>
|
||||
# Copyright © 2017-2023 Unrud <unrud@outlook.com>
|
||||
# Copyright © 2025-2025 Peter Bieringer <pb@bieringer.de>
|
||||
#
|
||||
# This library is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
@@ -58,7 +59,7 @@ class ApplicationPartGet(ApplicationBase):
|
||||
return value
|
||||
|
||||
def do_GET(self, environ: types.WSGIEnviron, base_prefix: str, path: str,
|
||||
user: str) -> types.WSGIResponse:
|
||||
user: str, remote_host: str, remote_useragent: str) -> types.WSGIResponse:
|
||||
"""Manage GET request."""
|
||||
# Redirect to /.web if the root path is requested
|
||||
if not pathutils.strip_path(path):
|
||||
@@ -108,4 +109,4 @@ class ApplicationPartGet(ApplicationBase):
|
||||
if content_disposition:
|
||||
headers["Content-Disposition"] = content_disposition
|
||||
answer = item.serialize()
|
||||
return client.OK, headers, answer
|
||||
return client.OK, headers, answer, None
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
# Copyright © 2008 Nicolas Kandel
|
||||
# Copyright © 2008 Pascal Halter
|
||||
# Copyright © 2008-2017 Guillaume Ayoub
|
||||
# Copyright © 2017-2018 Unrud <unrud@outlook.com>
|
||||
# Copyright © 2017-2022 Unrud <unrud@outlook.com>
|
||||
# Copyright © 2025-2025 Peter Bieringer <pb@bieringer.de>
|
||||
#
|
||||
# This library is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
@@ -25,7 +26,7 @@ from radicale.app.get import ApplicationPartGet
|
||||
class ApplicationPartHead(ApplicationPartGet, ApplicationBase):
|
||||
|
||||
def do_HEAD(self, environ: types.WSGIEnviron, base_prefix: str, path: str,
|
||||
user: str) -> types.WSGIResponse:
|
||||
user: str, remote_host: str, remote_useragent: str) -> types.WSGIResponse:
|
||||
"""Manage HEAD request."""
|
||||
# Body is dropped in `Application.__call__` for HEAD requests
|
||||
return self.do_GET(environ, base_prefix, path, user)
|
||||
return self.do_GET(environ, base_prefix, path, user, remote_host, remote_useragent)
|
||||
|
||||
@@ -33,7 +33,7 @@ from radicale.log import logger
|
||||
class ApplicationPartMkcalendar(ApplicationBase):
|
||||
|
||||
def do_MKCALENDAR(self, environ: types.WSGIEnviron, base_prefix: str,
|
||||
path: str, user: str) -> types.WSGIResponse:
|
||||
path: str, user: str, remote_host: str, remote_useragent: str) -> types.WSGIResponse:
|
||||
"""Manage MKCALENDAR request."""
|
||||
if "w" not in self._rights.authorization(user, path):
|
||||
return httputils.NOT_ALLOWED
|
||||
@@ -89,4 +89,4 @@ class ApplicationPartMkcalendar(ApplicationBase):
|
||||
logger.warning(
|
||||
"Bad MKCALENDAR request on %r: %s", path, e, exc_info=True)
|
||||
return httputils.BAD_REQUEST
|
||||
return client.CREATED, {}, None
|
||||
return client.CREATED, {}, None, xmlutils.pretty_xml(xml_content)
|
||||
|
||||
@@ -33,7 +33,7 @@ from radicale.log import logger
|
||||
class ApplicationPartMkcol(ApplicationBase):
|
||||
|
||||
def do_MKCOL(self, environ: types.WSGIEnviron, base_prefix: str,
|
||||
path: str, user: str) -> types.WSGIResponse:
|
||||
path: str, user: str, remote_host: str, remote_useragent: str) -> types.WSGIResponse:
|
||||
"""Manage MKCOL request."""
|
||||
permissions = self._rights.authorization(user, path)
|
||||
if not rights.intersect(permissions, "Ww"):
|
||||
@@ -94,4 +94,4 @@ class ApplicationPartMkcol(ApplicationBase):
|
||||
"Bad MKCOL request on %r (type:%s): %s", path, collection_type, e, exc_info=True)
|
||||
return httputils.BAD_REQUEST
|
||||
logger.info("MKCOL request %r (type:%s): %s", path, collection_type, "successful")
|
||||
return client.CREATED, {}, None
|
||||
return client.CREATED, {}, None, xmlutils.pretty_xml(xml_content)
|
||||
|
||||
@@ -22,7 +22,7 @@ import errno
|
||||
import posixpath
|
||||
import re
|
||||
from http import client
|
||||
from urllib.parse import urlparse
|
||||
from urllib.parse import unquote, urlparse
|
||||
|
||||
from radicale import httputils, pathutils, storage, types
|
||||
from radicale.app.base import Access, ApplicationBase
|
||||
@@ -34,7 +34,7 @@ def get_server_netloc(environ: types.WSGIEnviron, force_port: bool = False):
|
||||
host = environ["HTTP_X_FORWARDED_HOST"]
|
||||
proto = environ.get("HTTP_X_FORWARDED_PROTO") or "http"
|
||||
port = "443" if proto == "https" else "80"
|
||||
port = environ["HTTP_X_FORWARDED_PORT"] or port
|
||||
port = environ.get("HTTP_X_FORWARDED_PORT") or port
|
||||
else:
|
||||
host = environ.get("HTTP_HOST") or environ["SERVER_NAME"]
|
||||
proto = environ["wsgi.url_scheme"]
|
||||
@@ -48,18 +48,25 @@ def get_server_netloc(environ: types.WSGIEnviron, force_port: bool = False):
|
||||
class ApplicationPartMove(ApplicationBase):
|
||||
|
||||
def do_MOVE(self, environ: types.WSGIEnviron, base_prefix: str,
|
||||
path: str, user: str) -> types.WSGIResponse:
|
||||
path: str, user: str, remote_host: str, remote_useragent: str) -> types.WSGIResponse:
|
||||
"""Manage MOVE request."""
|
||||
raw_dest = environ.get("HTTP_DESTINATION", "")
|
||||
to_url = urlparse(raw_dest)
|
||||
to_netloc_with_port = to_url.netloc
|
||||
if to_url.port is None:
|
||||
to_netloc_with_port += (":443" if to_url.scheme == "https"
|
||||
else ":80")
|
||||
if to_netloc_with_port != get_server_netloc(environ, force_port=True):
|
||||
logger.info("Unsupported destination address: %r", raw_dest)
|
||||
# Remote destination server, not supported
|
||||
return httputils.REMOTE_DESTINATION
|
||||
|
||||
# Decode URL-encoded characters (e.g. %40 -> @) before parsing
|
||||
raw_dest_decoded = unquote(raw_dest)
|
||||
to_url = urlparse(raw_dest_decoded)
|
||||
|
||||
# Only check netloc for absolute URLs
|
||||
if to_url.netloc:
|
||||
to_netloc_with_port = to_url.netloc
|
||||
if to_url.port is None:
|
||||
to_netloc_with_port += (":443" if to_url.scheme == "https"
|
||||
else ":80")
|
||||
if to_netloc_with_port != get_server_netloc(environ, force_port=True):
|
||||
logger.info("Unsupported destination address: %r", raw_dest)
|
||||
# Remote destination server, not supported
|
||||
return httputils.REMOTE_DESTINATION
|
||||
|
||||
access = Access(self._rights, user, path)
|
||||
if not access.check("w"):
|
||||
return httputils.NOT_ALLOWED
|
||||
@@ -127,4 +134,4 @@ class ApplicationPartMove(ApplicationBase):
|
||||
logger.warning(
|
||||
"Bad MOVE request on %r: %s", path, e, exc_info=True)
|
||||
return httputils.BAD_REQUEST
|
||||
return client.NO_CONTENT if to_item else client.CREATED, {}, None
|
||||
return client.NO_CONTENT if to_item else client.CREATED, {}, None, None
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
# Copyright © 2008 Nicolas Kandel
|
||||
# Copyright © 2008 Pascal Halter
|
||||
# Copyright © 2008-2017 Guillaume Ayoub
|
||||
# Copyright © 2017-2018 Unrud <unrud@outlook.com>
|
||||
# Copyright © 2017-2021 Unrud <unrud@outlook.com>
|
||||
# Copyright © 2025-2025 Peter Bieringer <pb@bieringer.de>
|
||||
#
|
||||
# This library is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
@@ -26,10 +27,10 @@ from radicale.app.base import ApplicationBase
|
||||
class ApplicationPartOptions(ApplicationBase):
|
||||
|
||||
def do_OPTIONS(self, environ: types.WSGIEnviron, base_prefix: str,
|
||||
path: str, user: str) -> types.WSGIResponse:
|
||||
path: str, user: str, remote_host: str, remote_useragent: str) -> types.WSGIResponse:
|
||||
"""Manage OPTIONS request."""
|
||||
headers = {
|
||||
"Allow": ", ".join(
|
||||
name[3:] for name in dir(self) if name.startswith("do_")),
|
||||
"DAV": httputils.DAV_HEADERS}
|
||||
return client.OK, headers, None
|
||||
return client.OK, headers, None, None
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
# Copyright © 2008 Nicolas Kandel
|
||||
# Copyright © 2008 Pascal Halter
|
||||
# Copyright © 2008-2017 Guillaume Ayoub
|
||||
# Copyright © 2017-2018 Unrud <unrud@outlook.com>
|
||||
# Copyright © 2020 Tom Hacohen <tom@stosb.com>
|
||||
# Copyright © 2017-2021 Unrud <unrud@outlook.com>
|
||||
# Copyright © 2020-2020 Tom Hacohen <tom@stosb.com>
|
||||
# Copyright © 2025-2025 Peter Bieringer <pb@bieringer.de>
|
||||
#
|
||||
# This library is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
@@ -25,7 +26,7 @@ from radicale.app.base import ApplicationBase
|
||||
class ApplicationPartPost(ApplicationBase):
|
||||
|
||||
def do_POST(self, environ: types.WSGIEnviron, base_prefix: str,
|
||||
path: str, user: str) -> types.WSGIResponse:
|
||||
path: str, user: str, remote_host: str, remote_useragent: str) -> types.WSGIResponse:
|
||||
"""Manage POST request."""
|
||||
if path == "/.web" or path.startswith("/.web/"):
|
||||
return self._web.post(environ, base_prefix, path, user)
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
# Copyright © 2008 Nicolas Kandel
|
||||
# Copyright © 2008 Pascal Halter
|
||||
# Copyright © 2008-2017 Guillaume Ayoub
|
||||
# Copyright © 2017-2018 Unrud <unrud@outlook.com>
|
||||
# Copyright © 2017-2021 Unrud <unrud@outlook.com>
|
||||
# Copyright © 2025-2025 Peter Bieringer <pb@bieringer.de>
|
||||
#
|
||||
# This library is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
@@ -25,7 +26,8 @@ import xml.etree.ElementTree as ET
|
||||
from http import client
|
||||
from typing import Dict, Iterable, Iterator, List, Optional, Sequence, Tuple
|
||||
|
||||
from radicale import httputils, pathutils, rights, storage, types, xmlutils
|
||||
from radicale import (httputils, pathutils, rights, storage, types, utils,
|
||||
xmlutils)
|
||||
from radicale.app.base import Access, ApplicationBase
|
||||
from radicale.log import logger
|
||||
|
||||
@@ -33,7 +35,7 @@ from radicale.log import logger
|
||||
def xml_propfind(base_prefix: str, path: str,
|
||||
xml_request: Optional[ET.Element],
|
||||
allowed_items: Iterable[Tuple[types.CollectionOrItem, str]],
|
||||
user: str, encoding: str) -> Optional[ET.Element]:
|
||||
user: str, encoding: str, max_resource_size: int) -> Optional[ET.Element]:
|
||||
"""Read and answer PROPFIND requests.
|
||||
|
||||
Read rfc4918-9.1 for info.
|
||||
@@ -70,14 +72,14 @@ def xml_propfind(base_prefix: str, path: str,
|
||||
write = permission == "w"
|
||||
multistatus.append(xml_propfind_response(
|
||||
base_prefix, path, item, props, user, encoding, write=write,
|
||||
allprop=allprop, propname=propname))
|
||||
allprop=allprop, propname=propname, max_resource_size=max_resource_size))
|
||||
|
||||
return multistatus
|
||||
|
||||
|
||||
def xml_propfind_response(
|
||||
base_prefix: str, path: str, item: types.CollectionOrItem,
|
||||
props: Sequence[str], user: str, encoding: str, write: bool = False,
|
||||
props: Sequence[str], user: str, encoding: str, max_resource_size: int, write: bool = False,
|
||||
propname: bool = False, allprop: bool = False) -> ET.Element:
|
||||
"""Build and return a PROPFIND response."""
|
||||
if propname and allprop or (props and (propname or allprop)):
|
||||
@@ -110,6 +112,9 @@ def xml_propfind_response(
|
||||
props.append(xmlutils.make_clark("D:supported-report-set"))
|
||||
props.append(xmlutils.make_clark("D:resourcetype"))
|
||||
props.append(xmlutils.make_clark("D:owner"))
|
||||
if not allprop:
|
||||
# RFC4791#5.2.5: SHOULD NOT be returned by a PROPFIND DAV:allprop request
|
||||
props.append(xmlutils.make_clark("C:max-resource-size"))
|
||||
|
||||
if is_collection and collection.is_principal:
|
||||
props.append(xmlutils.make_clark("C:calendar-user-address-set"))
|
||||
@@ -131,6 +136,10 @@ def xml_propfind_response(
|
||||
props.append(xmlutils.make_clark("CS:getctag"))
|
||||
props.append(
|
||||
xmlutils.make_clark("C:supported-calendar-component-set"))
|
||||
if collection.tag == "VADDRESSBOOK":
|
||||
props.append(xmlutils.make_clark("CS:getctag"))
|
||||
props.append(
|
||||
xmlutils.make_clark("CR:supported-address-data"))
|
||||
|
||||
meta = collection.get_meta()
|
||||
for tag in meta:
|
||||
@@ -184,6 +193,21 @@ def xml_propfind_response(
|
||||
element.append(comp)
|
||||
else:
|
||||
is404 = True
|
||||
elif tag == xmlutils.make_clark("CR:supported-address-data"):
|
||||
if is_collection and is_leaf and collection.tag == "VADDRESSBOOK":
|
||||
# Advertise supported vCard versions per RFC 6352 section 6.2.2
|
||||
# vCard 4.0 requires vobject >= 1.0.0
|
||||
versions: Sequence[str] = (("4.0", "3.0")
|
||||
if utils.vobject_supports_vcard4()
|
||||
else ("3.0",))
|
||||
for version in versions:
|
||||
address_data_type = ET.Element(
|
||||
xmlutils.make_clark("CR:address-data-type"))
|
||||
address_data_type.set("content-type", "text/vcard")
|
||||
address_data_type.set("version", version)
|
||||
element.append(address_data_type)
|
||||
else:
|
||||
is404 = True
|
||||
elif tag == xmlutils.make_clark("D:current-user-principal"):
|
||||
if user:
|
||||
child_element = ET.Element(xmlutils.make_clark("D:href"))
|
||||
@@ -238,6 +262,9 @@ def xml_propfind_response(
|
||||
child_element.text = xmlutils.make_href(
|
||||
base_prefix, "/%s/" % collection.owner)
|
||||
element.append(child_element)
|
||||
elif tag == xmlutils.make_clark("C:max-resource-size"):
|
||||
# RFC4791#5.2.5
|
||||
element.text = str(max_resource_size)
|
||||
elif is_collection:
|
||||
if tag == xmlutils.make_clark("D:getcontenttype"):
|
||||
if is_leaf:
|
||||
@@ -376,7 +403,7 @@ class ApplicationPartPropfind(ApplicationBase):
|
||||
yield item, permission
|
||||
|
||||
def do_PROPFIND(self, environ: types.WSGIEnviron, base_prefix: str,
|
||||
path: str, user: str) -> types.WSGIResponse:
|
||||
path: str, user: str, remote_host: str, remote_useragent: str) -> types.WSGIResponse:
|
||||
"""Manage PROPFIND request."""
|
||||
access = Access(self._rights, user, path)
|
||||
if not access.check("r"):
|
||||
@@ -406,7 +433,7 @@ class ApplicationPartPropfind(ApplicationBase):
|
||||
headers = {"DAV": httputils.DAV_HEADERS,
|
||||
"Content-Type": "text/xml; charset=%s" % self._encoding}
|
||||
xml_answer = xml_propfind(base_prefix, path, xml_content,
|
||||
allowed_items, user, self._encoding)
|
||||
allowed_items, user, self._encoding, max_resource_size=self._max_resource_size)
|
||||
if xml_answer is None:
|
||||
return httputils.NOT_ALLOWED
|
||||
return client.MULTI_STATUS, headers, self._xml_response(xml_answer)
|
||||
return client.MULTI_STATUS, headers, self._xml_response(xml_answer), xmlutils.pretty_xml(xml_content)
|
||||
|
||||
@@ -73,7 +73,7 @@ def xml_proppatch(base_prefix: str, path: str,
|
||||
class ApplicationPartProppatch(ApplicationBase):
|
||||
|
||||
def do_PROPPATCH(self, environ: types.WSGIEnviron, base_prefix: str,
|
||||
path: str, user: str) -> types.WSGIResponse:
|
||||
path: str, user: str, remote_host: str, remote_useragent: str) -> types.WSGIResponse:
|
||||
"""Manage PROPPATCH request."""
|
||||
access = Access(self._rights, user, path)
|
||||
if not access.check("w"):
|
||||
@@ -101,13 +101,17 @@ class ApplicationPartProppatch(ApplicationBase):
|
||||
xml_answer = xml_proppatch(base_prefix, path, xml_content,
|
||||
item)
|
||||
if xml_content is not None:
|
||||
content = DefusedET.tostring(
|
||||
xml_content,
|
||||
encoding=self._encoding
|
||||
).decode(encoding=self._encoding)
|
||||
hook_notification_item = HookNotificationItem(
|
||||
HookNotificationItemTypes.CPATCH,
|
||||
access.path,
|
||||
DefusedET.tostring(
|
||||
xml_content,
|
||||
encoding=self._encoding
|
||||
).decode(encoding=self._encoding)
|
||||
notification_item_type=HookNotificationItemTypes.CPATCH,
|
||||
path=access.path,
|
||||
content=content,
|
||||
uid=None,
|
||||
old_content=None,
|
||||
new_content=content
|
||||
)
|
||||
self._hook.notify(hook_notification_item)
|
||||
except ValueError as e:
|
||||
@@ -127,4 +131,4 @@ class ApplicationPartProppatch(ApplicationBase):
|
||||
logger.warning(
|
||||
"Bad PROPPATCH request on %r: %s", path, e, exc_info=True)
|
||||
return httputils.BAD_REQUEST
|
||||
return client.MULTI_STATUS, headers, self._xml_response(xml_answer)
|
||||
return client.MULTI_STATUS, headers, self._xml_response(xml_answer), xmlutils.pretty_xml(xml_content)
|
||||
|
||||
@@ -46,7 +46,7 @@ PRODID = u"-//Radicale//NONSGML Version " + utils.package_version("radicale") +
|
||||
|
||||
|
||||
def prepare(vobject_items: List[vobject.base.Component], path: str,
|
||||
content_type: str, permission: bool, parent_permission: bool,
|
||||
content_type: str, permission: bool, parent_permission: bool, max_resource_size: int,
|
||||
tag: Optional[str] = None,
|
||||
write_whole_collection: Optional[bool] = None) -> Tuple[
|
||||
Iterator[radicale_item.Item], # items
|
||||
@@ -93,24 +93,61 @@ def prepare(vobject_items: List[vobject.base.Component], path: str,
|
||||
logger.debug("Prepare item with UID '%s'", item.uid)
|
||||
try:
|
||||
item.prepare()
|
||||
except ValueError as e:
|
||||
except (RuntimeError, ValueError, AttributeError) as e:
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.warning("Problem during prepare item with UID '%s' (content below): %s\n%s", item.uid, e, item._text)
|
||||
if item._text is None:
|
||||
content = vobject_item
|
||||
else:
|
||||
content = item._text
|
||||
logger.warning("Problem during prepare item with UID '%s' (content below): %s\n%s", item.uid, e, utils.textwrap_str(content))
|
||||
else:
|
||||
logger.warning("Problem during prepare item with UID '%s' (content suppressed in this loglevel): %s", item.uid, e)
|
||||
raise
|
||||
size = len(item.serialize())
|
||||
if (size > max_resource_size):
|
||||
logger.warning("PUT request contains item with UID %r size %d > limit %d: %r", item.uid, size, max_resource_size, path)
|
||||
# Use OverflowError as flag for max_resource_size
|
||||
raise OverflowError
|
||||
else:
|
||||
logger.debug("PUT request contains item with UID %r size %d <= limit %d: %r", item.uid, size, max_resource_size, path)
|
||||
items.append(item)
|
||||
elif write_whole_collection and tag == "VADDRESSBOOK":
|
||||
for vobject_item in vobject_items:
|
||||
item = radicale_item.Item(collection_path=collection_path,
|
||||
vobject_item=vobject_item)
|
||||
item.prepare()
|
||||
logger.debug("Prepare item with UID '%s'", item.uid)
|
||||
try:
|
||||
item.prepare()
|
||||
except (RuntimeError, ValueError, AttributeError) as e:
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
if item._text is None:
|
||||
content = vobject_item
|
||||
else:
|
||||
content = item._text
|
||||
logger.warning("Problem during prepare item with UID '%s' (content below): %s\n%s", item.uid, e, utils.textwrap_str(content))
|
||||
else:
|
||||
logger.warning("Problem during prepare item with UID '%s' (content suppressed in this loglevel): %s", item.uid, e)
|
||||
raise
|
||||
size = len(item.serialize())
|
||||
if (size > max_resource_size):
|
||||
logger.warning("PUT request contains item with UID %r size %d > limit %d: %r", item.uid, size, max_resource_size, path)
|
||||
# Use OverflowError as flag for max_resource_size
|
||||
raise OverflowError
|
||||
else:
|
||||
logger.debug("PUT request contains item with UID %r size %d <= limit %d: %r", item.uid, size, max_resource_size, path)
|
||||
items.append(item)
|
||||
elif not write_whole_collection:
|
||||
vobject_item, = vobject_items
|
||||
item = radicale_item.Item(collection_path=collection_path,
|
||||
vobject_item=vobject_item)
|
||||
item.prepare()
|
||||
size = len(item.serialize())
|
||||
if (size > max_resource_size):
|
||||
logger.warning("PUT request contains item with UID %r size %d above limit %d: %r", item.uid, size, max_resource_size, path)
|
||||
# Use OverflowError as flag for max_resource_size
|
||||
raise OverflowError
|
||||
else:
|
||||
logger.debug("PUT request contains item with UID %r size %d below limit %d: %r", item.uid, size, max_resource_size, path)
|
||||
items.append(item)
|
||||
|
||||
if write_whole_collection:
|
||||
@@ -142,7 +179,7 @@ def prepare(vobject_items: List[vobject.base.Component], path: str,
|
||||
class ApplicationPartPut(ApplicationBase):
|
||||
|
||||
def do_PUT(self, environ: types.WSGIEnviron, base_prefix: str,
|
||||
path: str, user: str) -> types.WSGIResponse:
|
||||
path: str, user: str, remote_host: str, remote_useragent: str) -> types.WSGIResponse:
|
||||
"""Manage PUT request."""
|
||||
access = Access(self._rights, user, path)
|
||||
if not access.check("w"):
|
||||
@@ -164,7 +201,10 @@ class ApplicationPartPut(ApplicationBase):
|
||||
logger.warning(
|
||||
"Bad PUT request on %r (read_components): %s", path, e, exc_info=True)
|
||||
if self._log_bad_put_request_content:
|
||||
logger.warning("Bad PUT request content of %r:\n%s", path, content)
|
||||
logger.warning("Bad PUT request content of %r:\n%s", path, utils.textwrap_str(content))
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("Request content (sha256sum): %s", utils.sha256_str(content))
|
||||
logger.debug("Request content (hexdump/lines):\n%s", utils.hexdump_lines(content))
|
||||
else:
|
||||
logger.debug("Bad PUT request content: suppressed by config/option [logging] bad_put_request_content")
|
||||
return httputils.BAD_REQUEST
|
||||
@@ -172,7 +212,8 @@ class ApplicationPartPut(ApplicationBase):
|
||||
prepared_props, prepared_exc_info) = prepare(
|
||||
vobject_items, path, content_type,
|
||||
bool(rights.intersect(access.permissions, "Ww")),
|
||||
bool(rights.intersect(access.parent_permissions, "w")))
|
||||
bool(rights.intersect(access.parent_permissions, "w")),
|
||||
self._max_resource_size)
|
||||
|
||||
with self._storage.acquire_lock("w", user, path=path, request="PUT"):
|
||||
item = next(iter(self._storage.discover(path)), None)
|
||||
@@ -207,6 +248,9 @@ class ApplicationPartPut(ApplicationBase):
|
||||
return httputils.NOT_ALLOWED
|
||||
|
||||
etag = environ.get("HTTP_IF_MATCH", "")
|
||||
if item and not etag and self._strict_preconditions:
|
||||
logger.warning("Precondition failed for %r: existing item, no If-Match header, strict mode enabled", path)
|
||||
return httputils.PRECONDITION_FAILED
|
||||
if not item and etag:
|
||||
# Etag asked but no item found: item has been removed
|
||||
logger.warning("Precondition failed on PUT request for %r (HTTP_IF_MATCH: %s, item not existing)", path, etag)
|
||||
@@ -233,24 +277,46 @@ class ApplicationPartPut(ApplicationBase):
|
||||
vobject_items, path, content_type,
|
||||
bool(rights.intersect(access.permissions, "Ww")),
|
||||
bool(rights.intersect(access.parent_permissions, "w")),
|
||||
self._max_resource_size,
|
||||
tag, write_whole_collection)
|
||||
props = prepared_props
|
||||
if prepared_exc_info:
|
||||
logger.warning(
|
||||
"Bad PUT request on %r (prepare): %s", path, prepared_exc_info[1],
|
||||
exc_info=prepared_exc_info)
|
||||
return httputils.BAD_REQUEST
|
||||
# Use OverflowError as flag for max_resource_size
|
||||
if prepared_exc_info[0] == OverflowError:
|
||||
return httputils.PRECONDITION_FAILED
|
||||
else:
|
||||
logger.warning(
|
||||
"Bad PUT request on %r (prepare): %s", path, prepared_exc_info[1],
|
||||
exc_info=prepared_exc_info)
|
||||
return httputils.BAD_REQUEST
|
||||
|
||||
if write_whole_collection:
|
||||
try:
|
||||
etag = self._storage.create_collection(
|
||||
path, prepared_items, props).etag
|
||||
col, replaced_items, new_item_hrefs = self._storage.create_collection(
|
||||
href=path,
|
||||
items=prepared_items,
|
||||
props=props)
|
||||
for item in prepared_items:
|
||||
hook_notification_item = HookNotificationItem(
|
||||
HookNotificationItemTypes.UPSERT,
|
||||
access.path,
|
||||
item.serialize()
|
||||
)
|
||||
# Try to grab the previously-existing item by href
|
||||
existing_item = replaced_items.get(item.href, None) # type: ignore
|
||||
if existing_item:
|
||||
hook_notification_item = HookNotificationItem(
|
||||
notification_item_type=HookNotificationItemTypes.UPSERT,
|
||||
path=access.path,
|
||||
content=existing_item.serialize(),
|
||||
uid=None,
|
||||
old_content=existing_item.serialize(),
|
||||
new_content=item.serialize()
|
||||
)
|
||||
else: # We assume the item is new because it was not in the replaced_items
|
||||
hook_notification_item = HookNotificationItem(
|
||||
notification_item_type=HookNotificationItemTypes.UPSERT,
|
||||
path=access.path,
|
||||
content=item.serialize(),
|
||||
uid=None,
|
||||
old_content=None,
|
||||
new_content=item.serialize()
|
||||
)
|
||||
self._hook.notify(hook_notification_item)
|
||||
except ValueError as e:
|
||||
logger.warning(
|
||||
@@ -267,11 +333,15 @@ class ApplicationPartPut(ApplicationBase):
|
||||
|
||||
href = posixpath.basename(pathutils.strip_path(path))
|
||||
try:
|
||||
etag = parent_item.upload(href, prepared_item).etag
|
||||
uploaded_item, replaced_item = parent_item.upload(href, prepared_item)
|
||||
etag = uploaded_item.etag
|
||||
hook_notification_item = HookNotificationItem(
|
||||
HookNotificationItemTypes.UPSERT,
|
||||
access.path,
|
||||
prepared_item.serialize()
|
||||
notification_item_type=HookNotificationItemTypes.UPSERT,
|
||||
path=access.path,
|
||||
content=prepared_item.serialize(),
|
||||
uid=None,
|
||||
old_content=replaced_item.serialize() if replaced_item else None,
|
||||
new_content=prepared_item.serialize()
|
||||
)
|
||||
self._hook.notify(hook_notification_item)
|
||||
except ValueError as e:
|
||||
@@ -294,7 +364,7 @@ class ApplicationPartPut(ApplicationBase):
|
||||
if (item and item.uid == prepared_item.uid):
|
||||
logger.debug("PUT request updated existing item %r", path)
|
||||
headers = {"ETag": etag}
|
||||
return client.NO_CONTENT, headers, None
|
||||
return client.NO_CONTENT, headers, None, None
|
||||
|
||||
headers = {"ETag": etag}
|
||||
return client.CREATED, headers, None
|
||||
return client.CREATED, headers, None, None
|
||||
|
||||
@@ -149,13 +149,14 @@ def free_busy_report(base_prefix: str, path: str, xml_request: Optional[ET.Eleme
|
||||
def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element],
|
||||
collection: storage.BaseCollection, encoding: str,
|
||||
unlock_storage_fn: Callable[[], None],
|
||||
max_occurrence: int = 0,
|
||||
max_occurrence: int = 0, user: str = "", remote_addr: str = "", remote_useragent: str = ""
|
||||
) -> Tuple[int, ET.Element]:
|
||||
"""Read and answer REPORT requests that return XML.
|
||||
|
||||
Read rfc3253-3.6 for info.
|
||||
|
||||
"""
|
||||
logger.debug("TRACE/REPORT/xml_report: base_prefix=%r path=%r", base_prefix, path)
|
||||
multistatus = ET.Element(xmlutils.make_clark("D:multistatus"))
|
||||
if xml_request is None:
|
||||
return client.MULTI_STATUS, multistatus
|
||||
@@ -212,8 +213,8 @@ def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element],
|
||||
sync_token, names = collection.sync(old_sync_token)
|
||||
except ValueError as e:
|
||||
# Invalid sync token
|
||||
logger.warning("Client provided invalid sync token %r: %s",
|
||||
old_sync_token, e, exc_info=True)
|
||||
logger.warning("Client provided invalid sync token for path %r (user %r from %s%s): %s",
|
||||
path, user, remote_addr, remote_useragent, e, exc_info=True)
|
||||
# client.CONFLICT doesn't work with some clients (e.g. InfCloud)
|
||||
return (client.FORBIDDEN,
|
||||
xmlutils.webdav_error("D:valid-sync-token"))
|
||||
@@ -239,6 +240,7 @@ def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element],
|
||||
filter_copy = copy.deepcopy(filter_)
|
||||
|
||||
if expand is not None:
|
||||
logger.debug("TRACE/REPORT/xml_report: expand")
|
||||
for comp_filter in filter_copy.findall(".//" + xmlutils.make_clark("C:comp-filter")):
|
||||
if comp_filter.get("name", "").upper() == "VCALENDAR":
|
||||
continue
|
||||
@@ -275,21 +277,15 @@ def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element],
|
||||
|
||||
found_props = []
|
||||
not_found_props = []
|
||||
item_etag: str = ""
|
||||
|
||||
for prop in props:
|
||||
element = ET.Element(prop.tag)
|
||||
if prop.tag == xmlutils.make_clark("D:getetag"):
|
||||
if expand is not None:
|
||||
item_etag = item.etag
|
||||
else:
|
||||
element.text = item.etag
|
||||
found_props.append(element)
|
||||
elif prop.tag == xmlutils.make_clark("D:getcontenttype"):
|
||||
if prop.tag == xmlutils.make_clark("D:getcontenttype"):
|
||||
element.text = xmlutils.get_content_type(item, encoding)
|
||||
found_props.append(element)
|
||||
elif prop.tag in (
|
||||
xmlutils.make_clark("C:calendar-data"),
|
||||
xmlutils.make_clark("D:getetag"),
|
||||
xmlutils.make_clark("CR:address-data")):
|
||||
element.text = item.serialize()
|
||||
|
||||
@@ -326,11 +322,24 @@ def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element],
|
||||
continue
|
||||
|
||||
n_vevents += n_vev
|
||||
found_props.append(expanded_element)
|
||||
if prop.tag == xmlutils.make_clark("D:getetag"):
|
||||
if n_vev > 0:
|
||||
logger.debug("TRACE/REPORT/xml_report: getetag/expanded element")
|
||||
element.text = item.etag
|
||||
found_props.append(element)
|
||||
else:
|
||||
logger.debug("TRACE/REPORT/xml_report: getetag/no expanded element")
|
||||
else:
|
||||
logger.debug("TRACE/REPORT/xml_report: default")
|
||||
found_props.append(expanded_element)
|
||||
else:
|
||||
found_props.append(element)
|
||||
if hasattr(item.vobject_item, "vevent_list"):
|
||||
n_vevents += len(item.vobject_item.vevent_list)
|
||||
if prop.tag == xmlutils.make_clark("D:getetag"):
|
||||
element.text = item.etag
|
||||
found_props.append(element)
|
||||
else:
|
||||
found_props.append(element)
|
||||
if hasattr(item.vobject_item, "vevent_list"):
|
||||
n_vevents += len(item.vobject_item.vevent_list)
|
||||
# Avoid DoS with too many events
|
||||
if max_occurrence and n_vevents > max_occurrence:
|
||||
raise ValueError("REPORT occurrences limit of {} hit"
|
||||
@@ -345,7 +354,7 @@ def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element],
|
||||
if found_props or not_found_props:
|
||||
multistatus.append(xml_item_response(
|
||||
base_prefix, uri, found_props=found_props,
|
||||
not_found_props=not_found_props, found_item=True, item_etag=item_etag))
|
||||
not_found_props=not_found_props, found_item=True))
|
||||
|
||||
return client.MULTI_STATUS, multistatus
|
||||
|
||||
@@ -475,7 +484,7 @@ def _expand(
|
||||
|
||||
if not vevent:
|
||||
# Create new instance from recurrence
|
||||
vevent = copy.deepcopy(base_vevent)
|
||||
vevent = base_vevent.duplicate(base_vevent)
|
||||
|
||||
# For all day events, the system timezone may influence the
|
||||
# results, so use recurrence_dt
|
||||
@@ -681,7 +690,7 @@ def _find_overridden(
|
||||
def xml_item_response(base_prefix: str, href: str,
|
||||
found_props: Sequence[ET.Element] = (),
|
||||
not_found_props: Sequence[ET.Element] = (),
|
||||
found_item: bool = True, item_etag: str = "") -> ET.Element:
|
||||
found_item: bool = True) -> ET.Element:
|
||||
response = ET.Element(xmlutils.make_clark("D:response"))
|
||||
|
||||
href_element = ET.Element(xmlutils.make_clark("D:href"))
|
||||
@@ -695,10 +704,6 @@ def xml_item_response(base_prefix: str, href: str,
|
||||
status = ET.Element(xmlutils.make_clark("D:status"))
|
||||
status.text = xmlutils.make_response(code)
|
||||
prop_element = ET.Element(xmlutils.make_clark("D:prop"))
|
||||
if (item_etag != "") and (code == 200):
|
||||
prop_etag = ET.Element(xmlutils.make_clark("D:getetag"))
|
||||
prop_etag.text = item_etag
|
||||
prop_element.append(prop_etag)
|
||||
for prop in props:
|
||||
prop_element.append(prop)
|
||||
propstat.append(prop_element)
|
||||
@@ -752,6 +757,7 @@ def retrieve_items(
|
||||
else:
|
||||
yield item, False
|
||||
if collection_requested:
|
||||
logger.debug("TRACE/REPORT/retrieve_items: get_filtered")
|
||||
yield from collection.get_filtered(filters)
|
||||
|
||||
|
||||
@@ -787,7 +793,7 @@ def test_filter(collection_tag: str, item: radicale_item.Item,
|
||||
class ApplicationPartReport(ApplicationBase):
|
||||
|
||||
def do_REPORT(self, environ: types.WSGIEnviron, base_prefix: str,
|
||||
path: str, user: str) -> types.WSGIResponse:
|
||||
path: str, user: str, remote_host: str, remote_useragent: str) -> types.WSGIResponse:
|
||||
"""Manage REPORT request."""
|
||||
access = Access(self._rights, user, path)
|
||||
if not access.check("r"):
|
||||
@@ -826,15 +832,15 @@ class ApplicationPartReport(ApplicationBase):
|
||||
"Bad REPORT request on %r: %s", path, e, exc_info=True)
|
||||
return httputils.BAD_REQUEST
|
||||
headers = {"Content-Type": "text/calendar; charset=%s" % self._encoding}
|
||||
return status, headers, str(body)
|
||||
return status, headers, str(body), xmlutils.pretty_xml(xml_content)
|
||||
else:
|
||||
try:
|
||||
status, xml_answer = xml_report(
|
||||
base_prefix, path, xml_content, collection, self._encoding,
|
||||
lock_stack.close, max_occurrence)
|
||||
lock_stack.close, max_occurrence, user, remote_host, remote_useragent)
|
||||
except ValueError as e:
|
||||
logger.warning(
|
||||
"Bad REPORT request on %r: %s", path, e, exc_info=True)
|
||||
return httputils.BAD_REQUEST
|
||||
headers = {"Content-Type": "text/xml; charset=%s" % self._encoding}
|
||||
return status, headers, self._xml_response(xml_answer)
|
||||
return status, headers, self._xml_response(xml_answer), xmlutils.pretty_xml(xml_content)
|
||||
|
||||
@@ -23,7 +23,7 @@ Authentication module.
|
||||
|
||||
Authentication is based on usernames and passwords. If something more
|
||||
advanced is needed an external WSGI server or reverse proxy can be used
|
||||
(see ``remote_user`` or ``http_x_remote_user`` backend).
|
||||
(see ``remote_user``, ``http_remote_user`` or ``http_x_remote_user`` backend).
|
||||
|
||||
Take a look at the class ``BaseAuth`` if you want to implement your own.
|
||||
|
||||
@@ -40,6 +40,7 @@ from radicale import config, types, utils
|
||||
from radicale.log import logger
|
||||
|
||||
INTERNAL_TYPES: Sequence[str] = ("none", "remote_user", "http_x_remote_user",
|
||||
"http_remote_user",
|
||||
"denyall",
|
||||
"htpasswd",
|
||||
"ldap",
|
||||
@@ -59,11 +60,14 @@ CACHE_LOGIN_TYPES: Sequence[str] = (
|
||||
|
||||
INSECURE_IF_NO_LOOPBACK_TYPES: Sequence[str] = (
|
||||
"remote_user",
|
||||
"http_remote_user",
|
||||
"http_x_remote_user",
|
||||
)
|
||||
|
||||
AUTH_SOCKET_FAMILY: Sequence[str] = ("AF_UNIX", "AF_INET", "AF_INET6")
|
||||
|
||||
REMOTE_ADDR_SOURCE: Sequence[str] = ("REMOTE_ADDR", "X-Remote-Addr")
|
||||
|
||||
|
||||
def load(configuration: "config.Configuration") -> "BaseAuth":
|
||||
"""Load the authentication module chosen in configuration."""
|
||||
@@ -91,6 +95,15 @@ def load(configuration: "config.Configuration") -> "BaseAuth":
|
||||
configuration)
|
||||
|
||||
|
||||
class AuthContext:
|
||||
remote_addr: str
|
||||
x_remote_addr: str
|
||||
|
||||
def __init__(self):
|
||||
self.remote_addr = None
|
||||
self.x_remote_addr = None
|
||||
|
||||
|
||||
class BaseAuth:
|
||||
|
||||
_ldap_groups: Set[str] = set([])
|
||||
@@ -129,7 +142,7 @@ class BaseAuth:
|
||||
if self._lc_username is True and self._uc_username is True:
|
||||
raise RuntimeError("auth.lc_username and auth.uc_username cannot be enabled together")
|
||||
self._auth_delay = configuration.get("auth", "delay")
|
||||
logger.info("auth.delay: %f", self._auth_delay)
|
||||
logger.info("auth.delay: %f seconds", self._auth_delay)
|
||||
self._failed_auth_delay = 0
|
||||
self._lock = threading.Lock()
|
||||
# cache_successful_logins
|
||||
@@ -187,6 +200,21 @@ class BaseAuth:
|
||||
|
||||
raise NotImplementedError
|
||||
|
||||
def _login_ext(self, login: str, password: str, context: AuthContext) -> str:
|
||||
"""Check credentials and map login to internal user
|
||||
|
||||
``login`` the login name
|
||||
|
||||
``password`` the password
|
||||
|
||||
``context`` additional data for the login, e.g. IP address used
|
||||
|
||||
Returns the username or ``""`` for invalid credentials.
|
||||
"""
|
||||
|
||||
# override this method instead of _login() if you want the context
|
||||
return self._login(login, password)
|
||||
|
||||
def _sleep_for_constant_exec_time(self, time_ns_begin: int):
|
||||
"""Sleep some time to reach a constant execution time for failed logins
|
||||
|
||||
@@ -216,7 +244,7 @@ class BaseAuth:
|
||||
time.sleep(sleep)
|
||||
|
||||
@final
|
||||
def login(self, login: str, password: str) -> Tuple[str, str]:
|
||||
def login(self, login: str, password: str, context: AuthContext) -> Tuple[str, str]:
|
||||
time_ns_begin = time.time_ns()
|
||||
result_from_cache = False
|
||||
if self._lc_username:
|
||||
@@ -284,7 +312,7 @@ class BaseAuth:
|
||||
if result == "":
|
||||
# verify login+password via configured backend
|
||||
logger.debug("Login verification for user+password via backend: '%s'", login)
|
||||
result = self._login(login, password)
|
||||
result = self._login_ext(login, password, context)
|
||||
if result != "":
|
||||
logger.debug("Login successful for user+password via backend: '%s'", login)
|
||||
if digest == "":
|
||||
@@ -314,7 +342,7 @@ class BaseAuth:
|
||||
return (result, self._type)
|
||||
else:
|
||||
# self._cache_logins is False
|
||||
result = self._login(login, password)
|
||||
result = self._login_ext(login, password, context)
|
||||
if result == "":
|
||||
self._sleep_for_constant_exec_time(time_ns_begin)
|
||||
return (result, self._type)
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
import base64
|
||||
import itertools
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
from contextlib import closing
|
||||
|
||||
@@ -32,6 +33,9 @@ class Auth(auth.BaseAuth):
|
||||
self.timeout = 5
|
||||
self.request_id_gen = itertools.count(1)
|
||||
|
||||
remote_ip_source = configuration.get("auth", "remote_ip_source")
|
||||
self.use_x_remote_addr = remote_ip_source == 'X-Remote-Addr'
|
||||
|
||||
config_family = configuration.get("auth", "dovecot_connection_type")
|
||||
if config_family == "AF_UNIX":
|
||||
self.family = socket.AF_UNIX
|
||||
@@ -46,7 +50,7 @@ class Auth(auth.BaseAuth):
|
||||
else:
|
||||
self.family = socket.AF_INET6
|
||||
|
||||
def _login(self, login, password):
|
||||
def _login_ext(self, login, password, context):
|
||||
"""Validate credentials.
|
||||
|
||||
Check if the ``login``/``password`` pair is valid according to Dovecot.
|
||||
@@ -88,6 +92,7 @@ class Auth(auth.BaseAuth):
|
||||
# Hence, we try to read just once with a buffer big
|
||||
# enough to hold all of it.
|
||||
buf = sock.recv(1024)
|
||||
version_sent = False
|
||||
while b'\n' in buf and not done:
|
||||
line, buf = buf.split(b'\n', 1)
|
||||
parts = line.split(b'\t')
|
||||
@@ -110,6 +115,10 @@ class Auth(auth.BaseAuth):
|
||||
)
|
||||
return ""
|
||||
seen_part[0] += 1
|
||||
if int(version[1]) >= 3:
|
||||
sock.send(b'VERSION\t1\t1\n')
|
||||
buf += sock.recv(1024)
|
||||
version_sent = True
|
||||
elif first == b'MECH':
|
||||
supported_mechs.append(parts[0])
|
||||
seen_part[1] += 1
|
||||
@@ -140,7 +149,8 @@ class Auth(auth.BaseAuth):
|
||||
|
||||
# Handshake
|
||||
logger.debug("Sending auth handshake")
|
||||
sock.send(b'VERSION\t1\t1\n')
|
||||
if not version_sent:
|
||||
sock.send(b'VERSION\t1\t1\n')
|
||||
sock.send(b'CPID\t%u\n' % os.getpid())
|
||||
|
||||
request_id = next(self.request_id_gen)
|
||||
@@ -148,10 +158,19 @@ class Auth(auth.BaseAuth):
|
||||
"Authenticating with request id: '{}'"
|
||||
.format(request_id)
|
||||
)
|
||||
rip = b''
|
||||
if self.use_x_remote_addr and context.x_remote_addr:
|
||||
rip = context.x_remote_addr.encode('ascii')
|
||||
elif context.remote_addr:
|
||||
rip = context.remote_addr.encode('ascii')
|
||||
# squash all whitespace - shouldn't be there and auth protocol
|
||||
# is sensitive to whitespace (in particular \t and \n)
|
||||
if rip:
|
||||
rip = b'\trip=' + re.sub(br'\s', b'', rip)
|
||||
sock.send(
|
||||
b'AUTH\t%u\tPLAIN\tservice=radicale\tresp=%b\n' %
|
||||
b'AUTH\t%u\tPLAIN\tservice=radicale%s\tresp=%b\n' %
|
||||
(
|
||||
request_id, base64.b64encode(
|
||||
request_id, rip, base64.b64encode(
|
||||
b'\0%b\0%b' %
|
||||
(login.encode(), password.encode())
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# Copyright © 2008 Pascal Halter
|
||||
# Copyright © 2008-2017 Guillaume Ayoub
|
||||
# Copyright © 2017-2019 Unrud <unrud@outlook.com>
|
||||
# Copyright © 2024-2025 Peter Bieringer <pb@bieringer.de>
|
||||
# Copyright © 2024-2026 Peter Bieringer <pb@bieringer.de>
|
||||
#
|
||||
# This library is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
@@ -43,7 +43,7 @@ out-of-the-box:
|
||||
- SHA256 (htpasswd -2 ...)
|
||||
- SHA512 (htpasswd -5 ...)
|
||||
|
||||
When bcrypt is installed:
|
||||
When bcrypt is installed (bcrypt >= 5.0.0 requires passlib(libpass) >= 1.9.3):
|
||||
- BCRYPT (htpasswd -B ...) -- Requires htpasswd 2.4.x
|
||||
|
||||
When argon2 is installed:
|
||||
@@ -61,7 +61,7 @@ from typing import Any, Tuple
|
||||
|
||||
from passlib.hash import apr_md5_crypt, sha256_crypt, sha512_crypt
|
||||
|
||||
from radicale import auth, config, logger
|
||||
from radicale import auth, config, logger, utils
|
||||
|
||||
|
||||
class Auth(auth.BaseAuth):
|
||||
@@ -120,12 +120,22 @@ class Auth(auth.BaseAuth):
|
||||
"The htpasswd encryption method 'bcrypt' or 'autodetect' requires "
|
||||
"the bcrypt module (entries found: %d)." % self._htpasswd_bcrypt_use) from e
|
||||
else:
|
||||
self._has_bcrypt = True
|
||||
[bcrypt_usable, info] = utils.passlib_libpass_supports_bcrypt()
|
||||
if bcrypt_usable:
|
||||
self._has_bcrypt = True
|
||||
logger.info(info)
|
||||
else:
|
||||
logger.warning(info)
|
||||
if self._encryption == "autodetect":
|
||||
if self._htpasswd_bcrypt_use == 0:
|
||||
logger.info("auth htpasswd encryption is 'radicale.auth.htpasswd_encryption.%s' and bycrypt module found, but currently not required", self._encryption)
|
||||
logger.info("auth htpasswd encryption is 'radicale.auth.htpasswd_encryption.%s' and bcrypt module found, but currently not required", self._encryption)
|
||||
else:
|
||||
logger.info("auth htpasswd encryption is 'radicale.auth.htpasswd_encryption.%s' and bycrypt module found (bcrypt entries found: %d)", self._encryption, self._htpasswd_bcrypt_use)
|
||||
logger.info("auth htpasswd encryption is 'radicale.auth.htpasswd_encryption.%s' and bcrypt module found (bcrypt entries found: %d)", self._encryption, self._htpasswd_bcrypt_use)
|
||||
if not bcrypt_usable:
|
||||
raise RuntimeError("The htpasswd encryption 'autodetect' requires the bcrypt module but not usuable")
|
||||
else:
|
||||
if not bcrypt_usable:
|
||||
raise RuntimeError("The htpasswd encryption method 'bcrypt' requires the bcrypt module but not usuable")
|
||||
if self._encryption == "bcrypt":
|
||||
self._verify = functools.partial(self._bcrypt, bcrypt)
|
||||
else:
|
||||
@@ -181,37 +191,28 @@ class Auth(auth.BaseAuth):
|
||||
return ("ARGON2", argon2.verify(password, hash_value.strip()))
|
||||
|
||||
def _md5apr1(self, hash_value: str, password: str) -> tuple[str, bool]:
|
||||
if self._encryption == "autodetect" and len(hash_value) != 37:
|
||||
return self._plain_fallback("MD5-APR1", hash_value, password)
|
||||
else:
|
||||
return ("MD5-APR1", apr_md5_crypt.verify(password, hash_value.strip()))
|
||||
return ("MD5-APR1", apr_md5_crypt.verify(password, hash_value.strip()))
|
||||
|
||||
def _sha256(self, hash_value: str, password: str) -> tuple[str, bool]:
|
||||
if self._encryption == "autodetect" and len(hash_value) != 63:
|
||||
return self._plain_fallback("SHA-256", hash_value, password)
|
||||
else:
|
||||
return ("SHA-256", sha256_crypt.verify(password, hash_value.strip()))
|
||||
return ("SHA-256", sha256_crypt.verify(password, hash_value.strip()))
|
||||
|
||||
def _sha512(self, hash_value: str, password: str) -> tuple[str, bool]:
|
||||
if self._encryption == "autodetect" and len(hash_value) != 106:
|
||||
return self._plain_fallback("SHA-512", hash_value, password)
|
||||
else:
|
||||
return ("SHA-512", sha512_crypt.verify(password, hash_value.strip()))
|
||||
return ("SHA-512", sha512_crypt.verify(password, hash_value.strip()))
|
||||
|
||||
def _autodetect(self, hash_value: str, password: str) -> tuple[str, bool]:
|
||||
if hash_value.startswith("$apr1$", 0, 6):
|
||||
if re.match(r"^\$apr1\$[A-Za-z0-9/.]{8}\$[A-Za-z0-9/.]{22}", hash_value):
|
||||
# MD5-APR1
|
||||
return self._md5apr1(hash_value, password)
|
||||
elif re.match(r"^\$2(a|b|x|y)?\$", hash_value):
|
||||
elif re.match(r"^\$2(a|b|x|y)?\$[0-9]{2}\$[A-Za-z0-9/.]{53}", hash_value):
|
||||
# BCRYPT
|
||||
return self._verify_bcrypt(hash_value, password)
|
||||
elif re.match(r"^\$argon2(i|d|id)\$", hash_value):
|
||||
# ARGON2
|
||||
return self._verify_argon2(hash_value, password)
|
||||
elif hash_value.startswith("$5$", 0, 3):
|
||||
elif re.match(r"^\$5\$(rounds=[0-9]+\$)?[A-Za-z0-9/.]{16}\$[A-Za-z0-9/.]{42}", hash_value):
|
||||
# SHA-256
|
||||
return self._sha256(hash_value, password)
|
||||
elif hash_value.startswith("$6$", 0, 3):
|
||||
elif re.match(r"^\$6\$(rounds=[0-9]+\$)?[A-Za-z0-9/.]{16}\$[A-Za-z0-9/.]{85}", hash_value):
|
||||
# SHA-512
|
||||
return self._sha512(hash_value, password)
|
||||
else:
|
||||
|
||||
36
radicale/auth/http_remote_user.py
Normal file
36
radicale/auth/http_remote_user.py
Normal file
@@ -0,0 +1,36 @@
|
||||
# This file is part of Radicale - CalDAV and CardDAV server
|
||||
# Copyright © 2025-2025 Peter Bieringer <pb@bieringer.de>
|
||||
#
|
||||
# This library is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This library is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Radicale. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
"""
|
||||
Authentication backend that takes the username from the
|
||||
``HTTP_REMOTE_USER`` header.
|
||||
|
||||
It's intended for use with a reverse proxy. Be aware as this will be insecure
|
||||
if the reverse proxy is not configured properly.
|
||||
|
||||
"""
|
||||
|
||||
from typing import Tuple, Union
|
||||
|
||||
from radicale import types
|
||||
from radicale.auth import none
|
||||
|
||||
|
||||
class Auth(none.Auth):
|
||||
|
||||
def get_external_login(self, environ: types.WSGIEnviron) -> Union[
|
||||
Tuple[()], Tuple[str, str]]:
|
||||
return environ.get("HTTP_REMOTE_USER", ""), ""
|
||||
@@ -64,10 +64,18 @@ class Auth(auth.BaseAuth):
|
||||
if self._security == "starttls":
|
||||
connection.starttls(ssl.create_default_context())
|
||||
try:
|
||||
connection.authenticate(
|
||||
"PLAIN",
|
||||
lambda _: "{0}\x00{0}\x00{1}".format(login, password).encode(),
|
||||
)
|
||||
if "AUTH=PLAIN" in connection.capabilities:
|
||||
logger.debug("IMAP authentication PLAIN selected for user %r via %s:%d (security: %s)", login, self._host, self._port, self._security)
|
||||
connection.authenticate(
|
||||
"PLAIN",
|
||||
lambda _: "{0}\x00{0}\x00{1}".format(login, password).encode(),
|
||||
)
|
||||
elif "AUTH=LOGIN" in connection.capabilities:
|
||||
logger.debug("IMAP authentication LOGIN selected for user %r via %s:%d (security: %s)", login, self._host, self._port, self._security)
|
||||
connection.login(login, password)
|
||||
else:
|
||||
logger.error("IMAP server is neither supporting AUTH=PLAIN or AUTH=LOGIN: %s:%d (security: %s)", self._host, self._port, self._security)
|
||||
return ""
|
||||
except imaplib.IMAP4.error as e:
|
||||
logger.warning("IMAP authentication failed for user %r: %s", login, e, exc_info=False)
|
||||
return ""
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# This file is part of Radicale - CalDAV and CardDAV server
|
||||
# Copyright © 2022-2024 Peter Varkoly
|
||||
# Copyright © 2024-2024 Peter Bieringer <pb@bieringer.de>
|
||||
# Copyright © 2024-2025 Peter Marschall <peter@adpm.de>
|
||||
#
|
||||
# This library is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
@@ -16,20 +17,36 @@
|
||||
# along with Radicale. If not, see <http://www.gnu.org/licenses/>.
|
||||
"""
|
||||
Authentication backend that checks credentials with a LDAP server.
|
||||
Following parameters are needed in the configuration:
|
||||
ldap_uri The LDAP URL to the server like ldap://localhost
|
||||
ldap_base The baseDN of the LDAP server
|
||||
ldap_reader_dn The DN of a LDAP user with read access to get the user accounts
|
||||
ldap_secret The password of the ldap_reader_dn
|
||||
ldap_secret_file The path of the file containing the password of the ldap_reader_dn
|
||||
ldap_filter The search filter to find the user to authenticate by the username
|
||||
ldap_user_attribute The attribute to be used as username after authentication
|
||||
ldap_groups_attribute The attribute containing group memberships in the LDAP user entry
|
||||
Following parameters controls SSL connections:
|
||||
ldap_use_ssl If ssl encryption should be used (to be deprecated)
|
||||
ldap_security The encryption mode to be used: *none*|tls|starttls
|
||||
ldap_ssl_verify_mode The certificate verification mode. Works for tls and starttls. NONE, OPTIONAL, default is REQUIRED
|
||||
ldap_ssl_ca_file
|
||||
The following parameters are needed in the configuration:
|
||||
ldap_uri URI to the LDAP server
|
||||
ldap_base Base DN of the LDAP server
|
||||
ldap_reader_dn DN of an LDAP user with read access to get the user accounts
|
||||
ldap_secret Password of the 'ldap_reader_dn'
|
||||
Better: use 'ldap_secret_file'!
|
||||
ldap_secret_file Path of the file containing the password of the 'ldap_reader_dn'
|
||||
ldap_filter Search filter to find the user DN to authenticate
|
||||
The following parameters control TLS connections:
|
||||
ldap_use_ssl Use ssl on the ldap connection.
|
||||
Deprecated, use 'ldap_security' instead!
|
||||
ldap_security Encryption mode to be used,
|
||||
one of: *none* | tls | starttls
|
||||
ldap_ssl_verify_mode Certificate verification mode for tls and starttls;
|
||||
one of: *REQUIRED* | OPTIONAL | NONE
|
||||
ldap_ssl_ca_file Path to the CA file in PEM format to certify the server certificate
|
||||
The following parameters are optional:
|
||||
ldap_user_attribute Attribute to be used as username after authentication, e.g. cn;
|
||||
if not given, the name used to logon is used.
|
||||
ldap_groups_attribute Attribute in the user entry to read the user's group memberships from,
|
||||
e.g. memberof, groupMememberShip. This may even be a non-DN attribute!
|
||||
ldap_group_base Base DN to search for groups;
|
||||
only if it differs from 'ldap_base' and if 'ldap_group_members_attribute' is set
|
||||
ldap_group_filter Search filter to search for groups having the user DN found as member;
|
||||
only if 'ldap_group_members_attribute' is set
|
||||
ldap_group_members_attribute Attribute in the group entries to read the group's members from,
|
||||
e.g. member.
|
||||
The following parameters are for LDAP servers with oddities
|
||||
ldap_ignore_attribute_create_modify_timestamp
|
||||
Ignore modifyTimestamp and createTimestamp attributes. Needed for Authentik LDAP server
|
||||
|
||||
"""
|
||||
import ssl
|
||||
@@ -47,10 +64,12 @@ class Auth(auth.BaseAuth):
|
||||
_ldap_attributes: list[str] = []
|
||||
_ldap_user_attr: str
|
||||
_ldap_groups_attr: str
|
||||
_ldap_group_base: str
|
||||
_ldap_group_filter: str
|
||||
_ldap_group_members_attr: str
|
||||
_ldap_module_version: int = 3
|
||||
_ldap_use_ssl: bool = False
|
||||
_ldap_security: str = "none"
|
||||
_ldap_ssl_verify_mode: int = ssl.CERT_REQUIRED
|
||||
_ldap_ssl_verify_mode: str = "REQUIRED"
|
||||
_ldap_ssl_ca_file: str = ""
|
||||
|
||||
def __init__(self, configuration: config.Configuration) -> None:
|
||||
@@ -61,16 +80,13 @@ class Auth(auth.BaseAuth):
|
||||
except ImportError:
|
||||
try:
|
||||
import ldap
|
||||
import ldap.filter
|
||||
self._ldap_module_version = 2
|
||||
self.ldap = ldap
|
||||
except ImportError as e:
|
||||
raise RuntimeError("LDAP authentication requires the ldap3 module") from e
|
||||
raise RuntimeError("LDAP authentication requires the ldap3 or ldap module") from e
|
||||
|
||||
self._ldap_ignore_attribute_create_modify_timestamp = configuration.get("auth", "ldap_ignore_attribute_create_modify_timestamp")
|
||||
if self._ldap_ignore_attribute_create_modify_timestamp:
|
||||
self.ldap3.utils.config._ATTRIBUTES_EXCLUDED_FROM_CHECK.extend(['createTimestamp', 'modifyTimestamp'])
|
||||
logger.info("auth.ldap_ignore_attribute_create_modify_timestamp applied")
|
||||
|
||||
self._ldap_uri = configuration.get("auth", "ldap_uri")
|
||||
self._ldap_base = configuration.get("auth", "ldap_base")
|
||||
self._ldap_reader_dn = configuration.get("auth", "ldap_reader_dn")
|
||||
@@ -78,71 +94,116 @@ class Auth(auth.BaseAuth):
|
||||
self._ldap_filter = configuration.get("auth", "ldap_filter")
|
||||
self._ldap_user_attr = configuration.get("auth", "ldap_user_attribute")
|
||||
self._ldap_groups_attr = configuration.get("auth", "ldap_groups_attribute")
|
||||
self._ldap_group_base = configuration.get("auth", "ldap_group_base")
|
||||
self._ldap_group_filter = configuration.get("auth", "ldap_group_filter")
|
||||
self._ldap_group_members_attr = configuration.get("auth", "ldap_group_members_attribute")
|
||||
ldap_secret_file_path = configuration.get("auth", "ldap_secret_file")
|
||||
if ldap_secret_file_path:
|
||||
with open(ldap_secret_file_path, 'r') as file:
|
||||
self._ldap_secret = file.read().rstrip('\n')
|
||||
if self._ldap_module_version == 3:
|
||||
self._ldap_use_ssl = configuration.get("auth", "ldap_use_ssl")
|
||||
self._ldap_security = configuration.get("auth", "ldap_security")
|
||||
self._use_encryption = self._ldap_use_ssl or self._ldap_security in ("tls", "starttls")
|
||||
if self._ldap_use_ssl and self._ldap_security == "starttls":
|
||||
raise RuntimeError("Cannot set both 'ldap_use_ssl = True' and 'ldap_security' = 'starttls'")
|
||||
if self._ldap_use_ssl:
|
||||
logger.warning("Configuration uses soon to be deprecated 'ldap_use_ssl', use 'ldap_security' ('none', 'tls', 'starttls') instead.")
|
||||
if self._use_encryption:
|
||||
self._ldap_ssl_ca_file = configuration.get("auth", "ldap_ssl_ca_file")
|
||||
tmp = configuration.get("auth", "ldap_ssl_verify_mode")
|
||||
if tmp == "NONE":
|
||||
self._ldap_ssl_verify_mode = ssl.CERT_NONE
|
||||
elif tmp == "OPTIONAL":
|
||||
self._ldap_ssl_verify_mode = ssl.CERT_OPTIONAL
|
||||
self._ldap_security = configuration.get("auth", "ldap_security")
|
||||
if self._ldap_security not in ("none", "tls", "starttls"):
|
||||
raise RuntimeError("Illegal value for config setting ´ldap_security'")
|
||||
ldap_use_ssl = configuration.get("auth", "ldap_use_ssl")
|
||||
if ldap_use_ssl:
|
||||
logger.warning("Configuration uses deprecated 'ldap_use_ssl': use 'ldap_security' ('none', 'tls', 'starttls') instead.")
|
||||
if self._ldap_security == "starttls":
|
||||
raise RuntimeError("Deprecated config setting 'ldap_use_ssl = True' conflicts with 'ldap_security' = 'starttls'")
|
||||
elif self._ldap_security != "tls":
|
||||
logger.warning("Update configuration: set 'ldap_security = tls' instead of deprecated 'ldap_use_ssl = True'")
|
||||
self._ldap_security = "tls"
|
||||
self._ldap_ssl_ca_file = configuration.get("auth", "ldap_ssl_ca_file")
|
||||
self._ldap_ssl_verify_mode = configuration.get("auth", "ldap_ssl_verify_mode")
|
||||
if self._ldap_ssl_verify_mode not in ("NONE", "OPTIONAL", "REQUIRED"):
|
||||
raise RuntimeError("Illegal value for config setting ´ldap_ssl_verify_mode'")
|
||||
|
||||
logger.info("auth.ldap_uri : %r" % self._ldap_uri)
|
||||
logger.info("auth.ldap_base : %r" % self._ldap_base)
|
||||
logger.info("auth.ldap_reader_dn : %r" % self._ldap_reader_dn)
|
||||
logger.info("auth.ldap_filter : %r" % self._ldap_filter)
|
||||
if self._ldap_uri.lower().startswith("ldaps://") and self._ldap_security not in ("tls", "starttls"):
|
||||
logger.info("Inferring 'ldap_security' = tls from 'ldap_uri' starting with 'ldaps://'")
|
||||
self._ldap_security = "tls"
|
||||
if self._ldap_uri.lower().startswith("ldapi://") and self._ldap_ssl_verify_mode != "NONE":
|
||||
logger.info("Lowering 'ldap_'ldap_ssl_verify_mode' to NONE for 'ldap_uri' starting with 'ldapi://'")
|
||||
self._ldap_ssl_verify_mode = "NONE"
|
||||
|
||||
if self._ldap_ssl_ca_file == "" and self._ldap_ssl_verify_mode != "NONE" and self._ldap_security in ("tls", "starttls"):
|
||||
logger.warning("Certificate verification not possible: 'ldap_ssl_ca_file' not set")
|
||||
if self._ldap_ssl_ca_file and self._ldap_security not in ("tls", "starttls"):
|
||||
logger.warning("Config setting 'ldap_ssl_ca_file' useless without encrypted LDAP connection")
|
||||
|
||||
logger.info("auth.ldap_uri : %r" % self._ldap_uri)
|
||||
logger.info("auth.ldap_base : %r" % self._ldap_base)
|
||||
logger.info("auth.ldap_reader_dn : %r" % self._ldap_reader_dn)
|
||||
logger.info("auth.ldap_filter : %r" % self._ldap_filter)
|
||||
if self._ldap_user_attr:
|
||||
logger.info("auth.ldap_user_attribute : %r" % self._ldap_user_attr)
|
||||
logger.info("auth.ldap_user_attribute : %r" % self._ldap_user_attr)
|
||||
else:
|
||||
logger.info("auth.ldap_user_attribute : (not provided)")
|
||||
logger.info("auth.ldap_user_attribute : (not provided)")
|
||||
if self._ldap_groups_attr:
|
||||
logger.info("auth.ldap_groups_attribute: %r" % self._ldap_groups_attr)
|
||||
logger.info("auth.ldap_groups_attribute : %r" % self._ldap_groups_attr)
|
||||
else:
|
||||
logger.info("auth.ldap_groups_attribute: (not provided)")
|
||||
logger.info("auth.ldap_groups_attribute : (not provided)")
|
||||
if self._ldap_group_base:
|
||||
logger.info("auth.ldap_group_base : %r" % self._ldap_group_base)
|
||||
else:
|
||||
logger.info("auth.ldap_group_base : (not provided, using ldap_base)")
|
||||
self._ldap_group_base = self._ldap_base
|
||||
if self._ldap_group_filter:
|
||||
logger.info("auth.ldap_group_filter : %r" % self._ldap_group_filter)
|
||||
else:
|
||||
logger.info("auth.ldap_group_filter : (not provided)")
|
||||
if self._ldap_group_members_attr:
|
||||
logger.info("auth.ldap_group_members_attr: %r" % self._ldap_group_members_attr)
|
||||
else:
|
||||
logger.info("auth.ldap_group_members_attr: (not provided)")
|
||||
if ldap_secret_file_path:
|
||||
logger.info("auth.ldap_secret_file_path: %r" % ldap_secret_file_path)
|
||||
logger.info("auth.ldap_secret_file_path : %r" % ldap_secret_file_path)
|
||||
if self._ldap_secret:
|
||||
logger.info("auth.ldap_secret : (from file)")
|
||||
logger.info("auth.ldap_secret : (from file)")
|
||||
else:
|
||||
logger.info("auth.ldap_secret_file_path: (not provided)")
|
||||
logger.info("auth.ldap_secret_file_path : (not provided)")
|
||||
if self._ldap_secret:
|
||||
logger.info("auth.ldap_secret : (from config)")
|
||||
logger.info("auth.ldap_secret : (from config)")
|
||||
if self._ldap_reader_dn and not self._ldap_secret:
|
||||
logger.error("auth.ldap_secret : (not provided)")
|
||||
logger.error("auth.ldap_secret : (not provided)")
|
||||
raise RuntimeError("LDAP authentication requires ldap_secret for ldap_reader_dn")
|
||||
logger.info("auth.ldap_use_ssl : %s" % self._ldap_use_ssl)
|
||||
logger.info("auth.ldap_security : %s" % self._ldap_security)
|
||||
if self._use_encryption:
|
||||
logger.info("auth.ldap_ssl_verify_mode : %s" % self._ldap_ssl_verify_mode)
|
||||
if self._ldap_ssl_ca_file:
|
||||
logger.info("auth.ldap_ssl_ca_file : %r" % self._ldap_ssl_ca_file)
|
||||
else:
|
||||
logger.info("auth.ldap_ssl_ca_file : (not provided)")
|
||||
logger.info("auth.ldap_use_ssl : %s" % ldap_use_ssl)
|
||||
logger.info("auth.ldap_security : %s" % self._ldap_security)
|
||||
logger.info("auth.ldap_ssl_verify_mode : %s" % self._ldap_ssl_verify_mode)
|
||||
if self._ldap_ssl_ca_file:
|
||||
logger.info("auth.ldap_ssl_ca_file : %r" % self._ldap_ssl_ca_file)
|
||||
else:
|
||||
logger.info("auth.ldap_ssl_ca_file : (not provided)")
|
||||
if self._ldap_ignore_attribute_create_modify_timestamp:
|
||||
logger.info("auth.ldap_ignore_attribute_create_modify_timestamp applied (relevant for ldap3 only)")
|
||||
"""Extend attributes to to be returned in the user query"""
|
||||
if self._ldap_groups_attr:
|
||||
self._ldap_attributes.append(self._ldap_groups_attr)
|
||||
if self._ldap_user_attr:
|
||||
self._ldap_attributes.append(self._ldap_user_attr)
|
||||
logger.info("ldap_attributes : %r" % self._ldap_attributes)
|
||||
logger.info("ldap_attributes : %r" % self._ldap_attributes)
|
||||
|
||||
def _login2(self, login: str, password: str) -> str:
|
||||
try:
|
||||
"""Bind as reader dn"""
|
||||
logger.debug(f"_login2 {self._ldap_uri}, {self._ldap_reader_dn}")
|
||||
conn = self.ldap.initialize(self._ldap_uri)
|
||||
conn.protocol_version = 3
|
||||
conn.protocol_version = self.ldap.VERSION3
|
||||
conn.set_option(self.ldap.OPT_REFERRALS, 0)
|
||||
|
||||
if self._ldap_security in ("tls", "starttls"):
|
||||
"""certificate validation mode"""
|
||||
verifyMode = {"NONE": self.ldap.OPT_X_TLS_NEVER,
|
||||
"OPTIONAL": self.ldap.OPT_X_TLS_ALLOW,
|
||||
"REQUIRED": self.ldap.OPT_X_TLS_DEMAND}
|
||||
conn.set_option(self.ldap.OPT_X_TLS_REQUIRE_CERT, verifyMode[self._ldap_ssl_verify_mode])
|
||||
"""CA file to validate certificate against"""
|
||||
if self._ldap_ssl_ca_file:
|
||||
conn.set_option(self.ldap.OPT_X_TLS_CACERTFILE, self._ldap_ssl_ca_file)
|
||||
"""create TLS context- this must be the last TLS setting"""
|
||||
conn.set_option(self.ldap.OPT_X_TLS_NEWCTX, self.ldap.OPT_ON)
|
||||
|
||||
if self._ldap_security == "starttls":
|
||||
conn.start_tls_s()
|
||||
|
||||
conn.simple_bind_s(self._ldap_reader_dn, self._ldap_secret)
|
||||
"""Search for the dn of user to authenticate"""
|
||||
escaped_login = self.ldap.filter.escape_filter_chars(login)
|
||||
@@ -160,34 +221,56 @@ class Auth(auth.BaseAuth):
|
||||
user_entry = res[0]
|
||||
user_dn = user_entry[0]
|
||||
logger.debug(f"_login2 found LDAP user DN {user_dn}")
|
||||
"""Close LDAP connection"""
|
||||
conn.unbind()
|
||||
|
||||
"""Let's collect the groups of the user."""
|
||||
groupDNs = []
|
||||
if self._ldap_groups_attr:
|
||||
groupDNs = user_entry[1][self._ldap_groups_attr]
|
||||
|
||||
"""Search for all groups having the user_dn found as member."""
|
||||
if self._ldap_group_members_attr:
|
||||
groupDNs = []
|
||||
res = conn.search_s(
|
||||
self._ldap_group_base,
|
||||
self.ldap.SCOPE_SUBTREE,
|
||||
filterstr="(&{0}({1}={2}))".format(
|
||||
self._ldap_group_filter,
|
||||
self._ldap_group_members_attr,
|
||||
self.ldap.filter.escape_filter_chars(user_dn)),
|
||||
attrlist=['1.1']
|
||||
)
|
||||
"""Fill groupDNs with DNs of groups found"""
|
||||
if len(res) > 0:
|
||||
groupDNs = []
|
||||
for dn, entry in res:
|
||||
groupDNs.append(dn)
|
||||
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Invalid LDAP configuration:{e}")
|
||||
|
||||
try:
|
||||
"""Bind as user to authenticate"""
|
||||
conn = self.ldap.initialize(self._ldap_uri)
|
||||
conn.protocol_version = 3
|
||||
conn.set_option(self.ldap.OPT_REFERRALS, 0)
|
||||
conn.simple_bind_s(user_dn, password)
|
||||
tmp: list[str] = []
|
||||
if self._ldap_groups_attr:
|
||||
tmp = []
|
||||
for g in user_entry[1][self._ldap_groups_attr]:
|
||||
"""Get group g's RDN's attribute value"""
|
||||
try:
|
||||
rdns = self.ldap.dn.explode_dn(g, notypes=True)
|
||||
tmp.append(rdns[0])
|
||||
except Exception:
|
||||
tmp.append(g.decode('utf8'))
|
||||
self._ldap_groups = set(tmp)
|
||||
logger.debug("_login2 LDAP groups of user: %s", ",".join(self._ldap_groups))
|
||||
if self._ldap_user_attr:
|
||||
if user_entry[1][self._ldap_user_attr]:
|
||||
tmplogin = user_entry[1][self._ldap_user_attr][0]
|
||||
login = tmplogin.decode('utf-8')
|
||||
login = user_entry[1][self._ldap_user_attr][0]
|
||||
if isinstance(login, bytes):
|
||||
login = login.decode('utf-8')
|
||||
logger.debug(f"_login2 user set to: '{login}'")
|
||||
|
||||
"""Get RDNs of groups' DNs"""
|
||||
tmp = []
|
||||
for g in groupDNs:
|
||||
try:
|
||||
rdns = self.ldap.dn.explode_dn(g, notypes=True)
|
||||
tmp.append(rdns[0])
|
||||
except Exception:
|
||||
if isinstance(g, bytes):
|
||||
g = g.decode('utf-8')
|
||||
tmp.append(g)
|
||||
self._ldap_groups = set(tmp)
|
||||
logger.debug("_login2 LDAP groups of user: %s", ",".join(self._ldap_groups))
|
||||
|
||||
conn.unbind()
|
||||
logger.debug(f"_login2 {login} successfully authenticated")
|
||||
return login
|
||||
@@ -195,18 +278,21 @@ class Auth(auth.BaseAuth):
|
||||
return ""
|
||||
|
||||
def _login3(self, login: str, password: str) -> str:
|
||||
if self._ldap_ignore_attribute_create_modify_timestamp:
|
||||
self.ldap3.utils.config._ATTRIBUTES_EXCLUDED_FROM_CHECK.extend(['createTimestamp', 'modifyTimestamp'])
|
||||
|
||||
"""Connect the server"""
|
||||
try:
|
||||
logger.debug(f"_login3 {self._ldap_uri}, {self._ldap_reader_dn}")
|
||||
if self._use_encryption:
|
||||
if self._ldap_security in ("tls", "starttls"):
|
||||
logger.debug("_login3 using encryption (reader)")
|
||||
tls = self.ldap3.Tls(validate=self._ldap_ssl_verify_mode)
|
||||
verifyMode = {"NONE": ssl.CERT_NONE,
|
||||
"OPTIONAL": ssl.CERT_OPTIONAL,
|
||||
"REQUIRED": ssl.CERT_REQUIRED}
|
||||
tls = self.ldap3.Tls(validate=verifyMode[self._ldap_ssl_verify_mode])
|
||||
if self._ldap_ssl_ca_file != "":
|
||||
tls = self.ldap3.Tls(
|
||||
validate=self._ldap_ssl_verify_mode,
|
||||
ca_certs_file=self._ldap_ssl_ca_file
|
||||
)
|
||||
if self._ldap_use_ssl or self._ldap_security == "tls":
|
||||
tls = self.ldap3.Tls(validate=verifyMode[self._ldap_ssl_verify_mode], ca_certs_file=self._ldap_ssl_ca_file)
|
||||
if self._ldap_security == "tls":
|
||||
logger.debug("_login3 using ssl (reader)")
|
||||
server = self.ldap3.Server(self._ldap_uri, use_ssl=True, tls=tls)
|
||||
else:
|
||||
@@ -249,9 +335,42 @@ class Auth(auth.BaseAuth):
|
||||
return ""
|
||||
|
||||
user_entry = conn.response[0]
|
||||
conn.unbind()
|
||||
user_dn = user_entry['dn']
|
||||
logger.debug(f"_login3 found LDAP user DN {user_dn}")
|
||||
|
||||
"""Let's collect the groups of the user."""
|
||||
groupDNs = []
|
||||
if self._ldap_groups_attr:
|
||||
if user_entry['attributes'][self._ldap_groups_attr]:
|
||||
if isinstance(user_entry['attributes'][self._ldap_groups_attr], list):
|
||||
groupDNs = user_entry['attributes'][self._ldap_groups_attr]
|
||||
else:
|
||||
groupDNs.append(user_entry['attributes'][self._ldap_groups_attr])
|
||||
|
||||
"""Search for all groups having the user_dn found as member."""
|
||||
if self._ldap_group_members_attr:
|
||||
try:
|
||||
conn.search(
|
||||
search_base=self._ldap_group_base,
|
||||
search_filter="(&{0}({1}={2}))".format(
|
||||
self._ldap_group_filter,
|
||||
self._ldap_group_members_attr,
|
||||
self.ldap3.utils.conv.escape_filter_chars(user_dn)),
|
||||
search_scope=self.ldap3.SUBTREE,
|
||||
attributes=['1.1']
|
||||
)
|
||||
except Exception as e:
|
||||
"""LDAP search failed: consider it as non-fatal - only groups missing"""
|
||||
logger.debug(f"_ldap3: LDAP group search failed: {e}")
|
||||
else:
|
||||
"""Fill groupDNs with DNs of groups found"""
|
||||
groupDNs = []
|
||||
for group in conn.response:
|
||||
groupDNs.append(group['dn'])
|
||||
|
||||
"""Close LDAP connection"""
|
||||
conn.unbind()
|
||||
|
||||
try:
|
||||
"""Try to bind as the user itself"""
|
||||
try:
|
||||
@@ -264,18 +383,18 @@ class Auth(auth.BaseAuth):
|
||||
if not conn.bind(read_server_info=False):
|
||||
logger.debug(f"_login3 user '{login}' cannot be found")
|
||||
return ""
|
||||
tmp: list[str] = []
|
||||
if self._ldap_groups_attr:
|
||||
tmp = []
|
||||
for g in user_entry['attributes'][self._ldap_groups_attr]:
|
||||
"""Get group g's RDN's attribute value"""
|
||||
try:
|
||||
rdns = self.ldap3.utils.dn.parse_dn(g)
|
||||
tmp.append(rdns[0][1])
|
||||
except Exception:
|
||||
tmp.append(g)
|
||||
self._ldap_groups = set(tmp)
|
||||
logger.debug("_login3 LDAP groups of user: %s", ",".join(self._ldap_groups))
|
||||
|
||||
"""Get RDNs of groups' DNs"""
|
||||
tmp = []
|
||||
for g in groupDNs:
|
||||
try:
|
||||
rdns = self.ldap3.utils.dn.parse_dn(g)
|
||||
tmp.append(rdns[0][1])
|
||||
except Exception:
|
||||
tmp.append(g)
|
||||
self._ldap_groups = set(tmp)
|
||||
logger.debug("_login3 LDAP groups of user: %s", ",".join(self._ldap_groups))
|
||||
|
||||
if self._ldap_user_attr:
|
||||
if user_entry['attributes'][self._ldap_user_attr]:
|
||||
if isinstance(user_entry['attributes'][self._ldap_user_attr], list):
|
||||
|
||||
@@ -45,6 +45,8 @@ DEFAULT_CONFIG_PATH: str = os.pathsep.join([
|
||||
"?/etc/radicale/config",
|
||||
"?~/.config/radicale/config"])
|
||||
|
||||
PROFILING: Sequence[str] = ("per_request", "per_request_method", "none")
|
||||
|
||||
|
||||
def positive_int(value: Any) -> int:
|
||||
value = int(value)
|
||||
@@ -70,6 +72,12 @@ def logging_level(value: Any) -> str:
|
||||
return value
|
||||
|
||||
|
||||
def profiling(value: Any) -> str:
|
||||
if value not in PROFILING:
|
||||
raise ValueError("unsupported profiling: %r" % value)
|
||||
return value
|
||||
|
||||
|
||||
def filepath(value: Any) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
@@ -154,7 +162,11 @@ DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([
|
||||
"type": positive_int}),
|
||||
("max_content_length", {
|
||||
"value": "100000000",
|
||||
"help": "maximum size of request body in bytes",
|
||||
"help": "maximum size of request body in bytes (default: 100 Mbyte)",
|
||||
"type": positive_int}),
|
||||
("max_resource_size", {
|
||||
"value": "10000000",
|
||||
"help": "maximum size of resource (default: 10 Mbyte)",
|
||||
"type": positive_int}),
|
||||
("timeout", {
|
||||
"value": "30",
|
||||
@@ -253,6 +265,11 @@ DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([
|
||||
"value": "12345",
|
||||
"help": "dovecot auth port",
|
||||
"type": int}),
|
||||
("remote_ip_source", {
|
||||
"value": "REMOTE_ADDR",
|
||||
"help": "remote address source for passing it to auth method",
|
||||
"type": str,
|
||||
"internal": auth.REMOTE_ADDR_SOURCE}),
|
||||
("realm", {
|
||||
"value": "Radicale - Password Required",
|
||||
"help": "message displayed when a password is needed",
|
||||
@@ -261,58 +278,70 @@ DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([
|
||||
"value": "1",
|
||||
"help": "incorrect authentication delay",
|
||||
"type": positive_float}),
|
||||
("ldap_ignore_attribute_create_modify_timestamp", {
|
||||
"value": "false",
|
||||
"help": "Ignore modifyTimestamp and createTimestamp attributes. Need if Authentik LDAP server is used.",
|
||||
"type": bool}),
|
||||
("ldap_uri", {
|
||||
"value": "ldap://localhost",
|
||||
"help": "URI to the ldap server",
|
||||
"help": "URI to the LDAP server",
|
||||
"type": str}),
|
||||
("ldap_base", {
|
||||
"value": "",
|
||||
"help": "LDAP base DN of the ldap server",
|
||||
"help": "Base DN of the LDAP server",
|
||||
"type": str}),
|
||||
("ldap_reader_dn", {
|
||||
"value": "",
|
||||
"help": "the DN of a ldap user with read access to get the user accounts",
|
||||
"help": "DN of an LDAP user with read access to users anmd - if defined - groups",
|
||||
"type": str}),
|
||||
("ldap_secret", {
|
||||
"value": "",
|
||||
"help": "the password of the ldap_reader_dn",
|
||||
"help": "Password of ldap_reader_dn (better: use ldap_secret_file)",
|
||||
"type": str}),
|
||||
("ldap_secret_file", {
|
||||
"value": "",
|
||||
"help": "path of the file containing the password of the ldap_reader_dn",
|
||||
"help": "Path to the file containing the password of ldap_reader_dn",
|
||||
"type": str}),
|
||||
("ldap_filter", {
|
||||
"value": "(cn={0})",
|
||||
"help": "the search filter to find the user DN to authenticate by the username",
|
||||
"help": "Filter to search for the LDAP entry of the user to authenticate",
|
||||
"type": str}),
|
||||
("ldap_user_attribute", {
|
||||
"value": "",
|
||||
"help": "the attribute to be used as username after authentication",
|
||||
"type": str}),
|
||||
("ldap_groups_attribute", {
|
||||
"value": "",
|
||||
"help": "attribute to read the group memberships from",
|
||||
"help": "Attribute to be used as username after authentication",
|
||||
"type": str}),
|
||||
("ldap_use_ssl", {
|
||||
"value": "False",
|
||||
"help": "Use ssl on the ldap connection. Soon to be deprecated, use ldap_security instead",
|
||||
"help": "Use ssl on the LDAP connection. Deprecated, use ldap_security instead!",
|
||||
"type": bool}),
|
||||
("ldap_security", {
|
||||
"value": "none",
|
||||
"help": "the encryption mode to be used: *none*|tls|starttls",
|
||||
"help": "Encryption mode to be used: *none*|tls|starttls",
|
||||
"type": str}),
|
||||
("ldap_ssl_verify_mode", {
|
||||
"value": "REQUIRED",
|
||||
"help": "The certificate verification mode. Works for tls and starttls. NONE, OPTIONAL, default is REQUIRED",
|
||||
"help": "Certificate verification mode for tls and starttls. NONE, OPTIONAL, default is REQUIRED",
|
||||
"type": str}),
|
||||
("ldap_ssl_ca_file", {
|
||||
"value": "",
|
||||
"help": "The path to the CA file in pem format which is used to certificate the server certificate",
|
||||
"help": "Path to the CA file in PEM format which is used to certify the server certificate",
|
||||
"type": str}),
|
||||
("ldap_groups_attribute", {
|
||||
"value": "",
|
||||
"help": "Attribute in the user's LDAP entry to read the group memberships from",
|
||||
"type": str}),
|
||||
("ldap_group_members_attribute", {
|
||||
"value": "",
|
||||
"help": "Attribute in the group entries to read the group's members from",
|
||||
"type": str}),
|
||||
("ldap_group_base", {
|
||||
"value": "",
|
||||
"help": "Base DN to search for groups. Only if it differs from ldap_base and if ldap_group_members_attribute is set",
|
||||
"type": str}),
|
||||
("ldap_group_filter", {
|
||||
"value": "",
|
||||
"help": "Search filter to search for groups having the user as member. Only if ldap_group_members_attribute is set",
|
||||
"type": str}),
|
||||
("ldap_ignore_attribute_create_modify_timestamp", {
|
||||
"value": "false",
|
||||
"help": "Quirk for Authentik LDAP server: ignore modifyTimestamp and createTimestamp attributes.",
|
||||
"type": bool}),
|
||||
("imap_host", {
|
||||
"value": "localhost",
|
||||
"help": "IMAP server hostname: address|address:port|[address]:port|*localhost*",
|
||||
@@ -413,6 +442,10 @@ DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([
|
||||
"value": "",
|
||||
"help": "command that is run after changes to storage",
|
||||
"type": str}),
|
||||
("strict_preconditions", {
|
||||
"value": "False",
|
||||
"help": "strict preconditions check on PUT",
|
||||
"type": bool}),
|
||||
("_filesystem_fsync", {
|
||||
"value": "True",
|
||||
"help": "sync all changes to filesystem during requests",
|
||||
@@ -477,7 +510,7 @@ DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([
|
||||
"value": "False",
|
||||
"help": "Send one email to all attendees, versus one email per attendee",
|
||||
"type": bool}),
|
||||
("added_template", {
|
||||
("new_or_added_to_event_template", {
|
||||
"value": """Hello $attendee_name,
|
||||
|
||||
You have been added as an attendee to the following calendar event.
|
||||
@@ -487,20 +520,31 @@ You have been added as an attendee to the following calendar event.
|
||||
$event_location
|
||||
|
||||
This is an automated message. Please do not reply.""",
|
||||
"help": "Template for the email sent when an event is added or updated. Select placeholder words prefixed with $ will be replaced",
|
||||
"help": "Template for the email sent when an event is created or attendee is added. Select placeholder words prefixed with $ will be replaced",
|
||||
"type": str}),
|
||||
("removed_template", {
|
||||
("deleted_or_removed_from_event_template", {
|
||||
"value": """Hello $attendee_name,
|
||||
|
||||
You have been removed as an attendee from the following calendar event.
|
||||
The following event has been deleted.
|
||||
|
||||
$event_title
|
||||
$event_start_time - $event_end_time
|
||||
$event_location
|
||||
|
||||
This is an automated message. Please do not reply.""",
|
||||
"help": "Template for the email sent when an event is deleted. Select placeholder words prefixed with $ will be replaced",
|
||||
"help": "Template for the email sent when an event is deleted or attendee is removed. Select placeholder words prefixed with $ will be replaced",
|
||||
"type": str}),
|
||||
("updated_event_template", {
|
||||
"value": """Hello $attendee_name,
|
||||
The following event has been updated.
|
||||
$event_title
|
||||
$event_start_time - $event_end_time
|
||||
$event_location
|
||||
|
||||
This is an automated message. Please do not reply.""",
|
||||
"help": "Template for the email sent when an event is updated. Select placeholder words prefixed with $ will be replaced",
|
||||
"type": str
|
||||
})
|
||||
])),
|
||||
("web", OrderedDict([
|
||||
("type", {
|
||||
@@ -537,6 +581,10 @@ This is an automated message. Please do not reply.""",
|
||||
"value": "False",
|
||||
"help": "log request content on level=debug",
|
||||
"type": bool}),
|
||||
("response_header_on_debug", {
|
||||
"value": "False",
|
||||
"help": "log response header on level=debug",
|
||||
"type": bool}),
|
||||
("response_content_on_debug", {
|
||||
"value": "False",
|
||||
"help": "log response content on level=debug",
|
||||
@@ -549,6 +597,30 @@ This is an automated message. Please do not reply.""",
|
||||
"value": "False",
|
||||
"help": "log storage cache action on level=debug",
|
||||
"type": bool}),
|
||||
("profiling", {
|
||||
"value": "none",
|
||||
"help": "log profiling data level=info",
|
||||
"type": profiling}),
|
||||
("profiling_per_request_min_duration", {
|
||||
"value": "3",
|
||||
"help": "log profiling data per request minimum duration (seconds)",
|
||||
"type": positive_int}),
|
||||
("profiling_per_request_header", {
|
||||
"value": "False",
|
||||
"help": "Log profiling request body (if passing minimum duration)",
|
||||
"type": bool}),
|
||||
("profiling_per_request_xml", {
|
||||
"value": "False",
|
||||
"help": "Log profiling request XML (if passing minimum duration)",
|
||||
"type": bool}),
|
||||
("profiling_per_request_method_interval", {
|
||||
"value": "600",
|
||||
"help": "log profiling data per request method interval (seconds)",
|
||||
"type": positive_int}),
|
||||
("profiling_top_x_functions", {
|
||||
"value": "10",
|
||||
"help": "log profiling top X functions (limit)",
|
||||
"type": positive_int}),
|
||||
("mask_passwords", {
|
||||
"value": "True",
|
||||
"help": "mask passwords in logs",
|
||||
|
||||
@@ -55,21 +55,26 @@ def _cleanup(path):
|
||||
|
||||
class HookNotificationItem:
|
||||
|
||||
def __init__(self, notification_item_type, path, content):
|
||||
def __init__(self, notification_item_type, path, content=None, uid=None, new_content=None, old_content=None):
|
||||
self.type = notification_item_type.value
|
||||
self.point = _cleanup(path)
|
||||
self.content = content
|
||||
self._content_legacy = content
|
||||
self.uid = uid
|
||||
self.new_content = new_content
|
||||
self.old_content = old_content
|
||||
|
||||
@property
|
||||
def content(self): # For backward compatibility
|
||||
return self._content_legacy or self.uid or self.new_content or self.old_content
|
||||
|
||||
@property
|
||||
def replaces_existing_item(self) -> bool:
|
||||
"""Check if this notification item replaces/deletes an existing item."""
|
||||
return self.old_content is not None
|
||||
|
||||
def to_json(self):
|
||||
return json.dumps(
|
||||
self,
|
||||
default=lambda o: o.__dict__,
|
||||
{**self.__dict__, "content": self.content},
|
||||
sort_keys=True,
|
||||
indent=4
|
||||
)
|
||||
|
||||
|
||||
class DeleteHookNotificationItem(HookNotificationItem):
|
||||
def __init__(self, path, uid, old_content=None):
|
||||
super().__init__(notification_item_type=HookNotificationItemTypes.DELETE, path=path, content=uid)
|
||||
self.old_content = old_content
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
# along with Radicale. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import enum
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import smtplib
|
||||
import ssl
|
||||
@@ -29,8 +31,8 @@ from typing import Any, Dict, List, Optional, Sequence, Tuple
|
||||
|
||||
import vobject
|
||||
|
||||
from radicale.hook import (BaseHook, DeleteHookNotificationItem,
|
||||
HookNotificationItem, HookNotificationItemTypes)
|
||||
from radicale.hook import (BaseHook, HookNotificationItem,
|
||||
HookNotificationItemTypes)
|
||||
from radicale.log import logger
|
||||
|
||||
PLUGIN_CONFIG_SCHEMA = {
|
||||
@@ -63,7 +65,7 @@ PLUGIN_CONFIG_SCHEMA = {
|
||||
"value": "",
|
||||
"type": str
|
||||
},
|
||||
"added_template": {
|
||||
"new_or_added_to_event_template": {
|
||||
"value": """Hello $attendee_name,
|
||||
|
||||
You have been added as an attendee to the following calendar event.
|
||||
@@ -75,10 +77,22 @@ You have been added as an attendee to the following calendar event.
|
||||
This is an automated message. Please do not reply.""",
|
||||
"type": str
|
||||
},
|
||||
"removed_template": {
|
||||
"deleted_or_removed_from_event_template": {
|
||||
"value": """Hello $attendee_name,
|
||||
|
||||
You have been removed as an attendee from the following calendar event.
|
||||
The following event has been deleted.
|
||||
|
||||
$event_title
|
||||
$event_start_time - $event_end_time
|
||||
$event_location
|
||||
|
||||
This is an automated message. Please do not reply.""",
|
||||
"type": str
|
||||
},
|
||||
"updated_event_template": {
|
||||
"value": """Hello $attendee_name,
|
||||
|
||||
The following event has been updated.
|
||||
|
||||
$event_title
|
||||
$event_start_time - $event_end_time
|
||||
@@ -143,14 +157,22 @@ SMTP_SSL_VERIFY_MODES: Sequence[str] = (SMTP_SSL_VERIFY_MODE_ENUM.NONE.value,
|
||||
SMTP_SSL_VERIFY_MODE_ENUM.REQUIRED.value)
|
||||
|
||||
|
||||
def ics_contents_contains_invited_event(contents: str):
|
||||
def read_ics_event(contents: str) -> Optional['Event']:
|
||||
"""
|
||||
Check if the ICS contents contain an event (versus a VTODO or VJOURNAL).
|
||||
Read the vobject item from the provided string and create an Event.
|
||||
"""
|
||||
v_cal: vobject.base.Component = vobject.readOne(contents)
|
||||
cal: Calendar = Calendar(vobject_item=v_cal)
|
||||
return cal.event if cal.event else None
|
||||
|
||||
|
||||
def ics_contents_contains_event(contents: str):
|
||||
"""
|
||||
Check if the ICS contents contain an event (versus a VADDRESSBOOK, VTODO or VJOURNAL).
|
||||
:param contents: The contents of the ICS file.
|
||||
:return: True if the ICS file contains an event, False otherwise.
|
||||
"""
|
||||
cal = vobject.readOne(contents)
|
||||
return cal.vevent is not None
|
||||
return read_ics_event(contents) is not None
|
||||
|
||||
|
||||
def extract_email(value: str) -> Optional[str]:
|
||||
@@ -165,6 +187,64 @@ def extract_email(value: str) -> Optional[str]:
|
||||
return value if "@" in value else None
|
||||
|
||||
|
||||
def determine_added_removed_and_unaltered_attendees(original_event: 'Event',
|
||||
new_event: 'Event') -> (
|
||||
Tuple)[List['Attendee'], List['Attendee'], List['Attendee']]:
|
||||
"""
|
||||
Determine the added, removed and unaltered attendees between two events.
|
||||
"""
|
||||
original_event_attendees = {attendee.email: attendee for attendee in original_event.attendees}
|
||||
new_event_attendees = {attendee.email: attendee for attendee in new_event.attendees}
|
||||
# Added attendees are those who are in the new event but not in the original event
|
||||
added_attendees = [new_event_attendees[email] for email in new_event_attendees if
|
||||
email not in original_event_attendees]
|
||||
# Removed attendees are those who are in the original event but not in the new event
|
||||
removed_attendees = [original_event_attendees[email] for email in original_event_attendees if
|
||||
email not in new_event_attendees]
|
||||
# Unaltered attendees are those who are in both events
|
||||
unaltered_attendees = [original_event_attendees[email] for email in original_event_attendees if
|
||||
email in new_event_attendees]
|
||||
|
||||
return added_attendees, removed_attendees, unaltered_attendees
|
||||
|
||||
|
||||
def event_details_other_than_attendees_changed(original_event: 'Event',
|
||||
new_event: 'Event') -> bool:
|
||||
"""
|
||||
Check if any details other than attendees and IDs have changed between two events.
|
||||
"""
|
||||
|
||||
def hash_dict(d: Dict[str, Any]) -> str:
|
||||
"""
|
||||
Create a hash of the dictionary to compare contents.
|
||||
This will ignore None values and empty strings.
|
||||
"""
|
||||
return hashlib.sha1(json.dumps(d).encode("utf8")).hexdigest()
|
||||
|
||||
original_event_details = {
|
||||
"summary": original_event.summary,
|
||||
"description": original_event.description,
|
||||
"location": original_event.location,
|
||||
"datetime_start": original_event.datetime_start.time_string() if original_event.datetime_start else None,
|
||||
"datetime_end": original_event.datetime_end.time_string() if original_event.datetime_end else None,
|
||||
"duration": original_event.duration,
|
||||
"status": original_event.status,
|
||||
"organizer": original_event.organizer
|
||||
}
|
||||
new_event_details = {
|
||||
"summary": new_event.summary,
|
||||
"description": new_event.description,
|
||||
"location": new_event.location,
|
||||
"datetime_start": new_event.datetime_start.time_string() if new_event.datetime_start else None,
|
||||
"datetime_end": new_event.datetime_end.time_string() if new_event.datetime_end else None,
|
||||
"duration": new_event.duration,
|
||||
"status": new_event.status,
|
||||
"organizer": new_event.organizer
|
||||
}
|
||||
|
||||
return hash_dict(original_event_details) != hash_dict(new_event_details)
|
||||
|
||||
|
||||
class ContentLine:
|
||||
_key: str
|
||||
value: Any
|
||||
@@ -415,6 +495,11 @@ class Event(VComponent):
|
||||
"""Return the summary of the event."""
|
||||
return self._get_content_lines("SUMMARY")[0].value
|
||||
|
||||
@property
|
||||
def description(self) -> Optional[str]:
|
||||
"""Return the description of the event."""
|
||||
return self._get_content_lines("DESCRIPTION")[0].value
|
||||
|
||||
@property
|
||||
def location(self) -> Optional[str]:
|
||||
"""Return the location of the event."""
|
||||
@@ -611,8 +696,9 @@ class EmailConfig:
|
||||
from_email: str,
|
||||
send_mass_emails: bool,
|
||||
dryrun: bool,
|
||||
added_template: MessageTemplate,
|
||||
removed_template: MessageTemplate):
|
||||
new_or_added_to_event_template: MessageTemplate,
|
||||
deleted_or_removed_from_event_template: MessageTemplate,
|
||||
updated_event_template: MessageTemplate):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.security = SMTP_SECURITY_TYPE_ENUM.from_string(value=security)
|
||||
@@ -622,10 +708,9 @@ class EmailConfig:
|
||||
self.from_email = from_email
|
||||
self.send_mass_emails = send_mass_emails
|
||||
self.dryrun = dryrun
|
||||
self.added_template = added_template
|
||||
self.removed_template = removed_template
|
||||
self.updated_template = added_template # Reuse added template for updated events
|
||||
self.deleted_template = removed_template # Reuse removed template for deleted events
|
||||
self.new_or_added_to_event_template = new_or_added_to_event_template
|
||||
self.deleted_or_removed_from_event_template = deleted_or_removed_from_event_template
|
||||
self.updated_event_template = updated_event_template
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""
|
||||
@@ -639,26 +724,17 @@ class EmailConfig:
|
||||
|
||||
def send_added_email(self, attendees: List[Attendee], event: EmailEvent) -> bool:
|
||||
"""
|
||||
Send a notification for added attendees.
|
||||
Send a notification for created events (and/or adding attendees).
|
||||
:param attendees: The attendees to inform.
|
||||
:param event: The event the attendee is being added to.
|
||||
:param event: The event being created (or the event the attendee is being added to).
|
||||
:return: True if the email was sent successfully, False otherwise.
|
||||
"""
|
||||
ics_attachment = ICSEmailAttachment(file_content=event.ics_content, file_name=f"{event.file_name}")
|
||||
|
||||
return self._prepare_and_send_email(template=self.added_template, attendees=attendees, event=event,
|
||||
return self._prepare_and_send_email(template=self.new_or_added_to_event_template, attendees=attendees,
|
||||
event=event,
|
||||
ics_attachment=ics_attachment)
|
||||
|
||||
def send_removed_email(self, attendees: List[Attendee], event: EmailEvent) -> bool:
|
||||
"""
|
||||
Send a notification for removed attendees.
|
||||
:param attendees: The attendees to inform.
|
||||
:param event: The event the attendee is being removed from.
|
||||
:return: True if the email was sent successfully, False otherwise.
|
||||
"""
|
||||
return self._prepare_and_send_email(template=self.removed_template, attendees=attendees, event=event,
|
||||
ics_attachment=None)
|
||||
|
||||
def send_updated_email(self, attendees: List[Attendee], event: EmailEvent) -> bool:
|
||||
"""
|
||||
Send a notification for updated events.
|
||||
@@ -668,17 +744,18 @@ class EmailConfig:
|
||||
"""
|
||||
ics_attachment = ICSEmailAttachment(file_content=event.ics_content, file_name=f"{event.file_name}")
|
||||
|
||||
return self._prepare_and_send_email(template=self.updated_template, attendees=attendees, event=event,
|
||||
return self._prepare_and_send_email(template=self.updated_event_template, attendees=attendees, event=event,
|
||||
ics_attachment=ics_attachment)
|
||||
|
||||
def send_deleted_email(self, attendees: List[Attendee], event: EmailEvent) -> bool:
|
||||
"""
|
||||
Send a notification for deleted events.
|
||||
Send a notification for deleted events (and/or removing attendees).
|
||||
:param attendees: The attendees to inform.
|
||||
:param event: The event being deleted.
|
||||
:param event: The event being deleted (or the event the attendee is being removed from).
|
||||
:return: True if the email was sent successfully, False otherwise.
|
||||
"""
|
||||
return self._prepare_and_send_email(template=self.deleted_template, attendees=attendees, event=event,
|
||||
return self._prepare_and_send_email(template=self.deleted_or_removed_from_event_template, attendees=attendees,
|
||||
event=event,
|
||||
ics_attachment=None)
|
||||
|
||||
def _prepare_and_send_email(self, template: MessageTemplate, attendees: List[Attendee],
|
||||
@@ -825,7 +902,6 @@ def _read_event(vobject_data: str) -> EmailEvent:
|
||||
class Hook(BaseHook):
|
||||
def __init__(self, configuration):
|
||||
super().__init__(configuration)
|
||||
self.dryrun = self.configuration.get("hook", "dryrun")
|
||||
self.email_config = EmailConfig(
|
||||
host=self.configuration.get("hook", "smtp_server"),
|
||||
port=self.configuration.get("hook", "smtp_port"),
|
||||
@@ -836,14 +912,18 @@ class Hook(BaseHook):
|
||||
from_email=self.configuration.get("hook", "from_email"),
|
||||
send_mass_emails=self.configuration.get("hook", "mass_email"),
|
||||
dryrun=self.configuration.get("hook", "dryrun"),
|
||||
added_template=MessageTemplate(
|
||||
new_or_added_to_event_template=MessageTemplate(
|
||||
subject="You have been added to an event",
|
||||
body=self.configuration.get("hook", "added_template")
|
||||
body=self.configuration.get("hook", "new_or_added_to_event_template")
|
||||
),
|
||||
removed_template=MessageTemplate(
|
||||
subject="You have been removed from an event",
|
||||
body=self.configuration.get("hook", "removed_template")
|
||||
deleted_or_removed_from_event_template=MessageTemplate(
|
||||
subject="An event you were invited to has been deleted",
|
||||
body=self.configuration.get("hook", "deleted_or_removed_from_event_template")
|
||||
),
|
||||
updated_event_template=MessageTemplate(
|
||||
subject="An event you are invited to has been updated",
|
||||
body=self.configuration.get("hook", "updated_event_template")
|
||||
)
|
||||
)
|
||||
logger.info(
|
||||
"Email hook initialized with configuration: %s",
|
||||
@@ -866,7 +946,7 @@ class Hook(BaseHook):
|
||||
:type notification_item: HookNotificationItem
|
||||
:return: None
|
||||
"""
|
||||
if self.dryrun:
|
||||
if self.email_config.dryrun:
|
||||
logger.warning("Hook 'email': DRY-RUN received notification_item: %r", vars(notification_item))
|
||||
else:
|
||||
logger.debug("Received notification_item: %r", vars(notification_item))
|
||||
@@ -881,50 +961,122 @@ class Hook(BaseHook):
|
||||
return
|
||||
|
||||
elif notification_type == HookNotificationItemTypes.UPSERT:
|
||||
# Handle upsert notifications (POST request for new item and PUT for updating existing item)
|
||||
# Handle upsert notifications
|
||||
|
||||
# We don't have access to the original content for a PUT request, just the incoming data
|
||||
new_item_str: str = notification_item.new_content # type: ignore # A serialized vobject.base.Component
|
||||
previous_item_str: Optional[str] = notification_item.old_content
|
||||
|
||||
item_str: str = notification_item.content # type: ignore # A serialized vobject.base.Component
|
||||
|
||||
if not ics_contents_contains_invited_event(contents=item_str):
|
||||
# If the ICS file does not contain an event, we do not send any notifications.
|
||||
if not ics_contents_contains_event(contents=new_item_str):
|
||||
# If ICS file does not contain an event, do not send any notifications (regardless of previous content).
|
||||
logger.debug("No event found in the ICS file, skipping notification.")
|
||||
return
|
||||
|
||||
email_event: EmailEvent = _read_event(vobject_data=item_str) # type: ignore
|
||||
email_event: EmailEvent = _read_event(vobject_data=new_item_str) # type: ignore
|
||||
if not email_event:
|
||||
logger.error("Failed to read event from new content: %s", new_item_str)
|
||||
return
|
||||
email_event_event = email_event.event # type: ignore
|
||||
if not email_event_event:
|
||||
logger.error("Event could not be parsed from the new content: %s", new_item_str)
|
||||
return
|
||||
email_event_end_time = email_event_event.datetime_end # type: ignore
|
||||
# Skip notification if the event end time is more than 1 minute in the past.
|
||||
if email_event_end_time and email_event_end_time.time:
|
||||
event_end = email_event_end_time.time # type: ignore
|
||||
now = datetime.now(
|
||||
event_end.tzinfo) if event_end.tzinfo else datetime.now() # Handle timezone-aware datetime
|
||||
if event_end < (now - timedelta(minutes=1)):
|
||||
logger.warning("Event end time is in the past, skipping notification for event: %s",
|
||||
email_event_event.uid)
|
||||
return
|
||||
|
||||
email_success: bool = self.email_config.send_updated_email( # type: ignore
|
||||
attendees=email_event.event.attendees,
|
||||
event=email_event
|
||||
)
|
||||
if not email_success:
|
||||
logger.error("Failed to send some or all email notifications for event: %s", email_event.event.uid)
|
||||
if not previous_item_str:
|
||||
# Dealing with a completely new event, no previous content to compare against.
|
||||
# Email every attendee about the new event.
|
||||
logger.debug("New event detected, sending notifications to all attendees.")
|
||||
email_success: bool = self.email_config.send_added_email( # type: ignore
|
||||
attendees=email_event.event.attendees,
|
||||
event=email_event
|
||||
)
|
||||
if not email_success:
|
||||
logger.error("Failed to send some or all added email notifications for event: %s",
|
||||
email_event.event.uid)
|
||||
return
|
||||
|
||||
# Dealing with an update to an existing event, compare new and previous content.
|
||||
new_event: Event = read_ics_event(contents=new_item_str) # type: ignore
|
||||
previous_event: Optional[Event] = read_ics_event(contents=previous_item_str)
|
||||
if not previous_event:
|
||||
# If we cannot parse the previous event for some reason, simply treat it as a new event.
|
||||
logger.warning("Previous event content could not be parsed, treating as a new event.")
|
||||
email_success: bool = self.email_config.send_added_email( # type: ignore
|
||||
attendees=email_event.event.attendees,
|
||||
event=email_event
|
||||
)
|
||||
if not email_success:
|
||||
logger.error("Failed to send some or all added email notifications for event: %s",
|
||||
email_event.event.uid)
|
||||
return
|
||||
|
||||
# Determine added, removed, and unaltered attendees
|
||||
added_attendees, removed_attendees, unaltered_attendees = determine_added_removed_and_unaltered_attendees(
|
||||
original_event=previous_event, new_event=new_event)
|
||||
|
||||
# Notify added attendees as "event created"
|
||||
if added_attendees:
|
||||
email_success: bool = self.email_config.send_added_email( # type: ignore
|
||||
attendees=added_attendees,
|
||||
event=email_event
|
||||
)
|
||||
if not email_success:
|
||||
logger.error("Failed to send some or all added email notifications for event: %s",
|
||||
email_event.event.uid)
|
||||
|
||||
# Notify removed attendees as "event deleted"
|
||||
if removed_attendees:
|
||||
email_success: bool = self.email_config.send_deleted_email( # type: ignore
|
||||
attendees=removed_attendees,
|
||||
event=email_event
|
||||
)
|
||||
if not email_success:
|
||||
logger.error("Failed to send some or all removed email notifications for event: %s",
|
||||
email_event.event.uid)
|
||||
|
||||
# Notify unaltered attendees as "event updated" if details other than attendees have changed
|
||||
if unaltered_attendees and event_details_other_than_attendees_changed(original_event=previous_event,
|
||||
new_event=new_event):
|
||||
email_success: bool = self.email_config.send_updated_email( # type: ignore
|
||||
attendees=unaltered_attendees,
|
||||
event=email_event
|
||||
)
|
||||
if not email_success:
|
||||
logger.error("Failed to send some or all updated email notifications for event: %s",
|
||||
email_event.event.uid)
|
||||
|
||||
# Skip sending notifications to existing attendees if the only changes made to the event
|
||||
# were the addition/removal of other attendees.
|
||||
|
||||
return
|
||||
|
||||
elif notification_type == HookNotificationItemTypes.DELETE:
|
||||
# Handle delete notifications (DELETE requests)
|
||||
# Handle delete notifications
|
||||
|
||||
# Ensure it's a delete notification, as we need the old content
|
||||
if not isinstance(notification_item, DeleteHookNotificationItem):
|
||||
return
|
||||
deleted_item_str: str = notification_item.old_content # type: ignore # A serialized vobject.base.Component
|
||||
|
||||
item_str: str = notification_item.old_content # type: ignore # A serialized vobject.base.Component
|
||||
|
||||
if not ics_contents_contains_invited_event(contents=item_str):
|
||||
if not ics_contents_contains_event(contents=deleted_item_str):
|
||||
# If the ICS file does not contain an event, we do not send any notifications.
|
||||
logger.debug("No event found in the ICS file, skipping notification.")
|
||||
return
|
||||
|
||||
email_event: EmailEvent = _read_event(vobject_data=item_str) # type: ignore
|
||||
email_event: EmailEvent = _read_event(vobject_data=deleted_item_str) # type: ignore
|
||||
|
||||
email_success: bool = self.email_config.send_deleted_email( # type: ignore
|
||||
attendees=email_event.event.attendees,
|
||||
event=email_event
|
||||
)
|
||||
if not email_success:
|
||||
logger.error("Failed to send some or all email notifications for event: %s", email_event.event.uid)
|
||||
logger.error("Failed to send some or all deleted email notifications for event: %s",
|
||||
email_event.event.uid)
|
||||
|
||||
return
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ Helper functions for HTTP.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
import os
|
||||
import pathlib
|
||||
import sys
|
||||
@@ -31,7 +32,7 @@ import time
|
||||
from http import client
|
||||
from typing import List, Mapping, Union, cast
|
||||
|
||||
from radicale import config, pathutils, types
|
||||
from radicale import config, pathutils, types, utils
|
||||
from radicale.log import logger
|
||||
|
||||
if sys.version_info < (3, 9):
|
||||
@@ -49,42 +50,42 @@ else:
|
||||
|
||||
NOT_ALLOWED: types.WSGIResponse = (
|
||||
client.FORBIDDEN, (("Content-Type", "text/plain"),),
|
||||
"Access to the requested resource forbidden.")
|
||||
"Access to the requested resource forbidden.", None)
|
||||
FORBIDDEN: types.WSGIResponse = (
|
||||
client.FORBIDDEN, (("Content-Type", "text/plain"),),
|
||||
"Action on the requested resource refused.")
|
||||
"Action on the requested resource refused.", None)
|
||||
BAD_REQUEST: types.WSGIResponse = (
|
||||
client.BAD_REQUEST, (("Content-Type", "text/plain"),), "Bad Request")
|
||||
client.BAD_REQUEST, (("Content-Type", "text/plain"),), "Bad Request", None)
|
||||
NOT_FOUND: types.WSGIResponse = (
|
||||
client.NOT_FOUND, (("Content-Type", "text/plain"),),
|
||||
"The requested resource could not be found.")
|
||||
"The requested resource could not be found.", None)
|
||||
CONFLICT: types.WSGIResponse = (
|
||||
client.CONFLICT, (("Content-Type", "text/plain"),),
|
||||
"Conflict in the request.")
|
||||
"Conflict in the request.", None)
|
||||
METHOD_NOT_ALLOWED: types.WSGIResponse = (
|
||||
client.METHOD_NOT_ALLOWED, (("Content-Type", "text/plain"),),
|
||||
"The method is not allowed on the requested resource.")
|
||||
"The method is not allowed on the requested resource.", None)
|
||||
PRECONDITION_FAILED: types.WSGIResponse = (
|
||||
client.PRECONDITION_FAILED,
|
||||
(("Content-Type", "text/plain"),), "Precondition failed.")
|
||||
(("Content-Type", "text/plain"),), "Precondition failed.", None)
|
||||
REQUEST_TIMEOUT: types.WSGIResponse = (
|
||||
client.REQUEST_TIMEOUT, (("Content-Type", "text/plain"),),
|
||||
"Connection timed out.")
|
||||
"Connection timed out.", None)
|
||||
REQUEST_ENTITY_TOO_LARGE: types.WSGIResponse = (
|
||||
client.REQUEST_ENTITY_TOO_LARGE, (("Content-Type", "text/plain"),),
|
||||
"Request body too large.")
|
||||
"Request body too large.", None)
|
||||
REMOTE_DESTINATION: types.WSGIResponse = (
|
||||
client.BAD_GATEWAY, (("Content-Type", "text/plain"),),
|
||||
"Remote destination not supported.")
|
||||
"Remote destination not supported.", None)
|
||||
DIRECTORY_LISTING: types.WSGIResponse = (
|
||||
client.FORBIDDEN, (("Content-Type", "text/plain"),),
|
||||
"Directory listings are not supported.")
|
||||
"Directory listings are not supported.", None)
|
||||
INSUFFICIENT_STORAGE: types.WSGIResponse = (
|
||||
client.INSUFFICIENT_STORAGE, (("Content-Type", "text/plain"),),
|
||||
"Insufficient Storage. Please contact the administrator.")
|
||||
"Insufficient Storage. Please contact the administrator.", None)
|
||||
INTERNAL_SERVER_ERROR: types.WSGIResponse = (
|
||||
client.INTERNAL_SERVER_ERROR, (("Content-Type", "text/plain"),),
|
||||
"A server error occurred. Please contact the administrator.")
|
||||
"A server error occurred. Please contact the administrator.", None)
|
||||
|
||||
DAV_HEADERS: str = "1, 2, 3, calendar-access, addressbook, extended-mkcol"
|
||||
|
||||
@@ -150,16 +151,19 @@ def read_request_body(configuration: "config.Configuration",
|
||||
content = decode_request(configuration, environ,
|
||||
read_raw_request_body(configuration, environ))
|
||||
if configuration.get("logging", "request_content_on_debug"):
|
||||
logger.debug("Request content:\n%s", content)
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("Request content (sha256sum): %s", utils.sha256_str(content))
|
||||
logger.debug("Request content:\n%s", utils.textwrap_str(content))
|
||||
else:
|
||||
logger.debug("Request content: suppressed by config/option [logging] request_content_on_debug")
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug("Request content: suppressed by config/option [logging] request_content_on_debug")
|
||||
return content
|
||||
|
||||
|
||||
def redirect(location: str, status: int = client.FOUND) -> types.WSGIResponse:
|
||||
return (status,
|
||||
{"Location": location, "Content-Type": "text/plain"},
|
||||
"Redirected to %s" % location)
|
||||
"Redirected to %s" % location, None)
|
||||
|
||||
|
||||
def _serve_traversable(
|
||||
@@ -214,7 +218,7 @@ def _serve_traversable(
|
||||
# adjust on the fly default main.js of InfCloud installation
|
||||
logger.debug("Adjust on-the-fly default InfCloud main.js in served page: %r", path)
|
||||
answer = answer.replace(b"'InfCloud - the open source CalDAV/CardDAV web client'", b"'InfCloud - the open source CalDAV/CardDAV web client - served through Radicale CalDAV/CardDAV server'")
|
||||
return client.OK, headers, answer
|
||||
return client.OK, headers, answer, None
|
||||
|
||||
|
||||
def serve_resource(
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
# Copyright © 2008 Pascal Halter
|
||||
# Copyright © 2014 Jean-Marc Martins
|
||||
# Copyright © 2008-2017 Guillaume Ayoub
|
||||
# Copyright © 2017-2018 Unrud <unrud@outlook.com>
|
||||
# Copyright © 2017-2022 Unrud <unrud@outlook.com>
|
||||
# Copyright © 2024-2026 Peter Bieringer <pb@bieringer.de>
|
||||
#
|
||||
# This library is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
@@ -37,7 +38,7 @@ from typing import (Any, Callable, List, MutableMapping, Optional, Sequence,
|
||||
import vobject
|
||||
|
||||
from radicale import storage # noqa:F401
|
||||
from radicale import pathutils
|
||||
from radicale import pathutils, utils
|
||||
from radicale.item import filter as radicale_filter
|
||||
from radicale.log import logger
|
||||
|
||||
@@ -55,6 +56,8 @@ def read_components(s: str) -> List[vobject.base.Component]:
|
||||
# * 0x0A Line Feed
|
||||
# * 0x0D Carriage Return
|
||||
s = re.sub(r'[\x00-\x08\x0B\x0C\x0E-\x1F]', '', s)
|
||||
# Workaround delete all empty lines to avoid vobject parsing errors
|
||||
s = re.sub(r'(?m)^[ \t]*\r?\n', '', s)
|
||||
return list(vobject.readComponents(s, allowQP=True))
|
||||
|
||||
|
||||
@@ -335,6 +338,25 @@ def find_time_range(vobject_item: vobject.base.Component, tag: str
|
||||
return math.floor(start.timestamp()), math.ceil(end.timestamp())
|
||||
|
||||
|
||||
def verify(file: str, encoding: str):
|
||||
logger.info("Verifying item: %s", file)
|
||||
with open(file, "rb") as f:
|
||||
content_raw = f.read()
|
||||
content = content_raw.decode(encoding)
|
||||
logger.info("Verifying item: %s has sha256sum %r", file, utils.sha256_bytes(content_raw))
|
||||
try:
|
||||
vobject_items = read_components(content) # noqa: F841
|
||||
except Exception as e:
|
||||
logger.error("Verifying item: %s problem: %s", file, e)
|
||||
logger.warning("Item content:\n%s", utils.textwrap_str(content))
|
||||
logger.info("Item content (hexdump):\n%s", utils.hexdump_str(content))
|
||||
logger.info("Item content (hexdump/lines):\n%s", utils.hexdump_lines(content))
|
||||
return False
|
||||
else:
|
||||
logger.info("Verifying item: %s successful", file)
|
||||
return True
|
||||
|
||||
|
||||
class Item:
|
||||
"""Class for address book and calendar entries."""
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ else:
|
||||
TRIGGER = datetime | None
|
||||
|
||||
|
||||
def date_to_datetime(d: date) -> datetime:
|
||||
def date_to_datetime(d: date, tzinfo=vobject.icalendar.utc) -> datetime:
|
||||
"""Transform any date to a UTC datetime.
|
||||
|
||||
If ``d`` is a datetime without timezone, return as UTC datetime. If ``d``
|
||||
@@ -58,7 +58,7 @@ def date_to_datetime(d: date) -> datetime:
|
||||
d = datetime.combine(d, datetime.min.time())
|
||||
if not d.tzinfo:
|
||||
# NOTE: using vobject's UTC as it wasn't playing well with datetime's.
|
||||
d = d.replace(tzinfo=vobject.icalendar.utc)
|
||||
d = d.replace(tzinfo=tzinfo)
|
||||
return d
|
||||
|
||||
|
||||
@@ -354,7 +354,10 @@ def visit_time_ranges(vobject_item: vobject.base.Component, child_name: str,
|
||||
for child, is_recurrence, recurrences in get_children(
|
||||
vobject_item.vevent_list):
|
||||
# TODO: check if there's a timezone
|
||||
dtstart = child.dtstart.value
|
||||
try:
|
||||
dtstart = child.dtstart.value
|
||||
except AttributeError:
|
||||
raise AttributeError("missing DTSTART")
|
||||
|
||||
if child.rruleset:
|
||||
dtstarts, infinity = getrruleset(child, recurrences)
|
||||
@@ -366,6 +369,21 @@ def visit_time_ranges(vobject_item: vobject.base.Component, child_name: str,
|
||||
dtend = getattr(child, "dtend", None)
|
||||
if dtend is not None:
|
||||
dtend = dtend.value
|
||||
|
||||
# Ensure that both datetime.datetime objects have a timezone or
|
||||
# both do not have one before doing calculations. This is required
|
||||
# as the library does not support performing mathematical operations
|
||||
# on timezone-aware and timezone-naive objects. See #1847
|
||||
if hasattr(dtstart, 'tzinfo') and hasattr(dtend, 'tzinfo'):
|
||||
if dtstart.tzinfo is None and dtend.tzinfo is not None:
|
||||
dtstart_orig = dtstart
|
||||
dtstart = date_to_datetime(dtstart, dtend.astimezone().tzinfo)
|
||||
logger.debug("TRACE/ITEM/FILTER/get_children: overtake missing tzinfo on dtstart from dtend: '%s' -> '%s'", dtstart_orig, dtstart)
|
||||
elif dtstart.tzinfo is not None and dtend.tzinfo is None:
|
||||
dtend_orig = dtend
|
||||
dtend = date_to_datetime(dtend, dtstart.astimezone().tzinfo)
|
||||
logger.debug("TRACE/ITEM/FILTER/get_children: overtake missing tzinfo on dtend from dtstart: '%s' -> '%s'", dtend_orig, dtend)
|
||||
|
||||
original_duration = (dtend - dtstart).total_seconds()
|
||||
dtend = date_to_datetime(dtend)
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ import threading
|
||||
from tempfile import TemporaryDirectory
|
||||
from typing import Iterator, Type, Union
|
||||
|
||||
from radicale import storage, types
|
||||
from radicale import storage, types, utils
|
||||
|
||||
if sys.platform == "win32":
|
||||
import ctypes
|
||||
@@ -286,9 +286,10 @@ def path_to_filesystem(root: str, sane_path: str) -> str:
|
||||
safe_path = os.path.join(safe_path, part)
|
||||
# Check for conflicting files (e.g. case-insensitive file systems
|
||||
# or short names on Windows file systems)
|
||||
if (os.path.lexists(safe_path) and
|
||||
part not in (e.name for e in os.scandir(safe_path_parent))):
|
||||
raise CollidingPathError(part)
|
||||
if os.path.lexists(safe_path):
|
||||
with os.scandir(safe_path_parent) as entries:
|
||||
if part not in (e.name for e in entries):
|
||||
raise CollidingPathError(part)
|
||||
return safe_path
|
||||
|
||||
|
||||
@@ -320,13 +321,36 @@ def name_from_path(path: str, collection: "storage.BaseCollection") -> str:
|
||||
|
||||
def path_permissions(path):
|
||||
path = pathlib.Path(path)
|
||||
return [path.owner(), path.group(), path.stat().st_mode]
|
||||
|
||||
try:
|
||||
uid = utils.unknown_if_empty(path.stat().st_uid)
|
||||
except (KeyError, NotImplementedError):
|
||||
uid = "UNKNOWN"
|
||||
|
||||
try:
|
||||
gid = utils.unknown_if_empty(path.stat().st_gid)
|
||||
except (KeyError, NotImplementedError):
|
||||
gid = "UNKNOWN"
|
||||
|
||||
try:
|
||||
mode = utils.unknown_if_empty("%o" % path.stat().st_mode)
|
||||
except (KeyError, NotImplementedError):
|
||||
mode = "UNKNOWN"
|
||||
|
||||
try:
|
||||
owner = utils.unknown_if_empty(path.owner())
|
||||
except (KeyError, NotImplementedError):
|
||||
owner = "UNKNOWN"
|
||||
|
||||
try:
|
||||
group = utils.unknown_if_empty(path.group())
|
||||
except (KeyError, NotImplementedError):
|
||||
group = "UNKNOWN"
|
||||
|
||||
return [owner, uid, group, gid, mode]
|
||||
|
||||
|
||||
def path_permissions_as_string(path):
|
||||
try:
|
||||
pp = path_permissions(path)
|
||||
s = "path=%r owner=%s group=%s mode=%o" % (path, pp[0], pp[1], pp[2])
|
||||
except NotImplementedError:
|
||||
s = "path=%r owner=UNKNOWN(unsupported on this system)" % (path)
|
||||
pp = path_permissions(path)
|
||||
s = "path=%r owner=%s(%s) group=%s(%s) mode=%s" % (path, pp[0], pp[1], pp[2], pp[3], pp[4])
|
||||
return s
|
||||
|
||||
@@ -339,6 +339,7 @@ def serve(configuration: config.Configuration,
|
||||
# Fallback to busy waiting. (select(...) blocks SIGINT on Windows.)
|
||||
select_timeout = 1.0
|
||||
max_connections: int = configuration.get("server", "max_connections")
|
||||
logger.info("Maximum parallel connections: %d", max_connections)
|
||||
logger.info("Radicale server ready")
|
||||
logger.debug("TRACE: Radicale server ready ('logging/trace_on_debug' is active)")
|
||||
logger.debug("TRACE/SERVER: Radicale server ready ('logging/trace_on_debug' is active - either with 'SERVER' or empty filter)")
|
||||
|
||||
@@ -27,8 +27,8 @@ Take a look at the class ``BaseCollection`` if you want to implement your own.
|
||||
import json
|
||||
import xml.etree.ElementTree as ET
|
||||
from hashlib import sha256
|
||||
from typing import (Callable, ContextManager, Iterable, Iterator, Mapping,
|
||||
Optional, Sequence, Set, Tuple, Union, overload)
|
||||
from typing import (Callable, ContextManager, Dict, Iterable, Iterator, List,
|
||||
Mapping, Optional, Sequence, Set, Tuple, Union, overload)
|
||||
|
||||
import vobject
|
||||
|
||||
@@ -44,7 +44,8 @@ INTERNAL_TYPES: Sequence[str] = ("multifilesystem", "multifilesystem_nolock",)
|
||||
# NOTE: change only if cache structure is modified to avoid cache invalidation on update
|
||||
CACHE_VERSION_RADICALE = "3.3.1"
|
||||
|
||||
CACHE_VERSION: bytes = ("%s=%s;%s=%s;" % ("radicale", CACHE_VERSION_RADICALE, "vobject", utils.package_version("vobject"))).encode()
|
||||
CACHE_VERSION: bytes = (
|
||||
"%s=%s;%s=%s;" % ("radicale", CACHE_VERSION_RADICALE, "vobject", utils.package_version("vobject"))).encode()
|
||||
|
||||
|
||||
def load(configuration: "config.Configuration") -> "BaseStorage":
|
||||
@@ -112,17 +113,18 @@ class BaseCollection:
|
||||
invalid.
|
||||
|
||||
"""
|
||||
|
||||
def hrefs_iter() -> Iterator[str]:
|
||||
for item in self.get_all():
|
||||
assert item.href
|
||||
yield item.href
|
||||
|
||||
token = "http://radicale.org/ns/sync/%s" % self.etag.strip("\"")
|
||||
if old_token:
|
||||
raise ValueError("Sync token are not supported")
|
||||
return token, hrefs_iter()
|
||||
|
||||
def get_multi(self, hrefs: Iterable[str]
|
||||
) -> Iterable[Tuple[str, Optional["radicale_item.Item"]]]:
|
||||
def get_multi(self, hrefs: Iterable[str]) -> Iterable[Tuple[str, Optional["radicale_item.Item"]]]:
|
||||
"""Fetch multiple items.
|
||||
|
||||
It's not required to return the requested items in the correct order.
|
||||
@@ -175,8 +177,11 @@ class BaseCollection:
|
||||
return False
|
||||
|
||||
def upload(self, href: str, item: "radicale_item.Item") -> (
|
||||
"radicale_item.Item"):
|
||||
"""Upload a new or replace an existing item."""
|
||||
Tuple)["radicale_item.Item", Optional["radicale_item.Item"]]:
|
||||
"""Upload a new or replace an existing item.
|
||||
|
||||
Return the uploaded item and the old item if it was replaced.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def delete(self, href: Optional[str] = None) -> None:
|
||||
@@ -188,10 +193,12 @@ class BaseCollection:
|
||||
raise NotImplementedError
|
||||
|
||||
@overload
|
||||
def get_meta(self, key: None = None) -> Mapping[str, str]: ...
|
||||
def get_meta(self, key: None = None) -> Mapping[str, str]:
|
||||
...
|
||||
|
||||
@overload
|
||||
def get_meta(self, key: str) -> Optional[str]: ...
|
||||
def get_meta(self, key: str) -> Optional[str]:
|
||||
...
|
||||
|
||||
def get_meta(self, key: Optional[str] = None
|
||||
) -> Union[Mapping[str, str], Optional[str]]:
|
||||
@@ -293,8 +300,7 @@ class BaseStorage:
|
||||
|
||||
def discover(
|
||||
self, path: str, depth: str = "0",
|
||||
child_context_manager: Optional[
|
||||
Callable[[str, Optional[str]], ContextManager[None]]] = None,
|
||||
child_context_manager: Optional[Callable[[str, Optional[str]], ContextManager[None]]] = None,
|
||||
user_groups: Set[str] = set([])) -> Iterable["types.CollectionOrItem"]:
|
||||
"""Discover a list of collections under the given ``path``.
|
||||
|
||||
@@ -328,7 +334,8 @@ class BaseStorage:
|
||||
def create_collection(
|
||||
self, href: str,
|
||||
items: Optional[Iterable["radicale_item.Item"]] = None,
|
||||
props: Optional[Mapping[str, str]] = None) -> BaseCollection:
|
||||
props: Optional[Mapping[str, str]] = None) -> (
|
||||
Tuple)[BaseCollection, Dict[str, "radicale_item.Item"], List[str]]:
|
||||
"""Create a collection.
|
||||
|
||||
``href`` is the sanitized path.
|
||||
@@ -348,7 +355,7 @@ class BaseStorage:
|
||||
raise NotImplementedError
|
||||
|
||||
@types.contextmanager
|
||||
def acquire_lock(self, mode: str, user: str = "") -> Iterator[None]:
|
||||
def acquire_lock(self, mode: str, user: str = "", *args, **kwargs) -> Iterator[None]:
|
||||
"""Set a context manager to lock the whole storage.
|
||||
|
||||
``mode`` must either be "r" for shared access or "w" for exclusive
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
import os
|
||||
from tempfile import TemporaryDirectory
|
||||
from typing import Iterable, Optional, cast
|
||||
from typing import Dict, Iterable, List, Optional, Tuple, cast
|
||||
|
||||
import radicale.item as radicale_item
|
||||
from radicale import pathutils
|
||||
@@ -30,9 +30,37 @@ from radicale.storage.multifilesystem.base import StorageBase
|
||||
|
||||
class StoragePartCreateCollection(StorageBase):
|
||||
|
||||
def _discover_existing_items_pre_overwrite(self,
|
||||
tmp_collection: "multifilesystem.Collection",
|
||||
dst_path: str) -> Tuple[Dict[str, radicale_item.Item], List[str]]:
|
||||
"""Discover existing items in the collection before overwriting them."""
|
||||
existing_items = {}
|
||||
new_item_hrefs = []
|
||||
|
||||
existing_collection = self._collection_class(
|
||||
cast(multifilesystem.Storage, self),
|
||||
pathutils.unstrip_path(dst_path, True))
|
||||
existing_item_hrefs = set(existing_collection._list())
|
||||
tmp_collection_hrefs = set(tmp_collection._list())
|
||||
for item_href in tmp_collection_hrefs:
|
||||
if item_href not in existing_item_hrefs:
|
||||
# Item in temporary collection does not exist in the existing collection (is new)
|
||||
new_item_hrefs.append(item_href)
|
||||
continue
|
||||
# Item exists in both collections, grab the existing item for reference
|
||||
try:
|
||||
item = existing_collection._get(item_href, verify_href=False)
|
||||
if item is not None:
|
||||
existing_items[item_href] = item
|
||||
except Exception:
|
||||
# TODO: Log exception?
|
||||
continue
|
||||
|
||||
return existing_items, new_item_hrefs
|
||||
|
||||
def create_collection(self, href: str,
|
||||
items: Optional[Iterable[radicale_item.Item]] = None,
|
||||
props=None) -> "multifilesystem.Collection":
|
||||
props=None) -> Tuple["multifilesystem.Collection", Dict[str, radicale_item.Item], List[str]]:
|
||||
folder = self._get_collection_root_folder()
|
||||
|
||||
# Path should already be sanitized
|
||||
@@ -44,11 +72,14 @@ class StoragePartCreateCollection(StorageBase):
|
||||
self._makedirs_synced(filesystem_path)
|
||||
return self._collection_class(
|
||||
cast(multifilesystem.Storage, self),
|
||||
pathutils.unstrip_path(sane_path, True))
|
||||
pathutils.unstrip_path(sane_path, True)), {}, []
|
||||
|
||||
parent_dir = os.path.dirname(filesystem_path)
|
||||
self._makedirs_synced(parent_dir)
|
||||
|
||||
replaced_items: Dict[str, radicale_item.Item] = {}
|
||||
new_item_hrefs: List[str] = []
|
||||
|
||||
# Create a temporary directory with an unsafe name
|
||||
try:
|
||||
with TemporaryDirectory(prefix=".Radicale.tmp-", dir=parent_dir
|
||||
@@ -68,14 +99,20 @@ class StoragePartCreateCollection(StorageBase):
|
||||
col._upload_all_nonatomic(items, suffix=".vcf")
|
||||
|
||||
if os.path.lexists(filesystem_path):
|
||||
replaced_items, new_item_hrefs = self._discover_existing_items_pre_overwrite(
|
||||
tmp_collection=col,
|
||||
dst_path=sane_path)
|
||||
pathutils.rename_exchange(tmp_filesystem_path, filesystem_path)
|
||||
else:
|
||||
# If the destination path does not exist, obviously all items are new
|
||||
new_item_hrefs = list(col._list())
|
||||
os.rename(tmp_filesystem_path, filesystem_path)
|
||||
self._sync_directory(parent_dir)
|
||||
except Exception as e:
|
||||
raise ValueError("Failed to create collection %r as %r %s" %
|
||||
(href, filesystem_path, e)) from e
|
||||
|
||||
# TODO: Return new-old pairs and just-new items (new vs updated)
|
||||
return self._collection_class(
|
||||
cast(multifilesystem.Storage, self),
|
||||
pathutils.unstrip_path(sane_path, True))
|
||||
pathutils.unstrip_path(sane_path, True)), replaced_items, new_item_hrefs
|
||||
|
||||
@@ -68,8 +68,21 @@ class CollectionPartGet(CollectionPartCache, CollectionPartLock,
|
||||
else:
|
||||
path = os.path.join(self._filesystem_path, href)
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
raw_text = f.read()
|
||||
if self._storage._use_mtime_and_size_for_item_cache is True:
|
||||
# try to avoid "open"
|
||||
if not os.path.isfile(path):
|
||||
if not os.path.exists(path):
|
||||
raise FileNotFoundError(path)
|
||||
if os.path.isdir(path):
|
||||
raise IsADirectoryError(path)
|
||||
if not os.access(path, os.R_OK):
|
||||
raise PermissionError(path)
|
||||
else:
|
||||
with open(path, "rb") as f:
|
||||
# early read of the content
|
||||
if self._storage._debug_cache_actions is True:
|
||||
logger.debug("Item cache early read: %r", path)
|
||||
raw_text = f.read()
|
||||
except (FileNotFoundError, IsADirectoryError):
|
||||
return None
|
||||
except PermissionError:
|
||||
@@ -100,6 +113,12 @@ class CollectionPartGet(CollectionPartCache, CollectionPartLock,
|
||||
# Check if another process created the file in the meantime
|
||||
cache_content = self._load_item_cache(href, cache_hash)
|
||||
if cache_content is None:
|
||||
if self._storage._use_mtime_and_size_for_item_cache is True:
|
||||
# late read of the content
|
||||
if self._storage._debug_cache_actions is True:
|
||||
logger.debug("Item cache late read : %r", path)
|
||||
with open(path, "rb") as f:
|
||||
raw_text = f.read()
|
||||
try:
|
||||
vobject_items = radicale_item.read_components(
|
||||
raw_text.decode(self._encoding))
|
||||
|
||||
@@ -21,7 +21,7 @@ import errno
|
||||
import os
|
||||
import pickle
|
||||
import sys
|
||||
from typing import Iterable, Iterator, TextIO, cast
|
||||
from typing import Iterable, Iterator, Optional, TextIO, Tuple, cast
|
||||
|
||||
import radicale.item as radicale_item
|
||||
from radicale import pathutils
|
||||
@@ -36,10 +36,11 @@ class CollectionPartUpload(CollectionPartGet, CollectionPartCache,
|
||||
CollectionPartHistory, CollectionBase):
|
||||
|
||||
def upload(self, href: str, item: radicale_item.Item
|
||||
) -> radicale_item.Item:
|
||||
) -> Tuple[radicale_item.Item, Optional[radicale_item.Item]]:
|
||||
if not pathutils.is_safe_filesystem_path_component(href):
|
||||
raise pathutils.UnsafePathError(href)
|
||||
path = pathutils.path_to_filesystem(self._filesystem_path, href)
|
||||
old_item = self._get(href, verify_href=False)
|
||||
try:
|
||||
with self._atomic_write(path, newline="") as fo: # type: ignore
|
||||
f = cast(TextIO, fo)
|
||||
@@ -67,7 +68,7 @@ class CollectionPartUpload(CollectionPartGet, CollectionPartCache,
|
||||
uploaded_item = self._get(href, verify_href=False)
|
||||
if uploaded_item is None:
|
||||
raise RuntimeError("Storage modified externally")
|
||||
return uploaded_item
|
||||
return uploaded_item, old_item
|
||||
|
||||
def _upload_all_nonatomic(self, items: Iterable[radicale_item.Item],
|
||||
suffix: str = "") -> None:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# This file is part of Radicale - CalDAV and CardDAV server
|
||||
# Copyright © 2012-2017 Guillaume Ayoub
|
||||
# Copyright © 2017-2018 Unrud <unrud@outlook.com>
|
||||
# Copyright © 2017-2023 Unrud <unrud@outlook.com>
|
||||
# Copyright © 2024-2026 Peter Bieringer <pb@bieringer.de>
|
||||
#
|
||||
# This library is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
@@ -22,6 +23,8 @@ Tests for Radicale.
|
||||
|
||||
import base64
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
@@ -35,7 +38,7 @@ import defusedxml.ElementTree as DefusedET
|
||||
import vobject
|
||||
|
||||
import radicale
|
||||
from radicale import app, config, types, xmlutils
|
||||
from radicale import app, config, types, utils, xmlutils
|
||||
|
||||
RESPONSES = Dict[str, Union[int, Dict[str, Tuple[int, ET.Element]], vobject.base.Component]]
|
||||
|
||||
@@ -51,6 +54,11 @@ class BaseTest:
|
||||
application: app.Application
|
||||
|
||||
def setup_method(self) -> None:
|
||||
if os.environ.get("PYTHONPATH"):
|
||||
info = "with PYTHONPATH=%r " % os.environ.get("PYTHONPATH")
|
||||
else:
|
||||
info = ""
|
||||
logging.info("Testing Radicale %s(%s) as %s on %s", info, utils.packages_version(), utils.user_groups_as_string(), platform.platform())
|
||||
self.configuration = config.load()
|
||||
self.colpath = tempfile.mkdtemp()
|
||||
self.configure({
|
||||
@@ -75,6 +83,12 @@ class BaseTest:
|
||||
if login is not None and not isinstance(login, str):
|
||||
raise TypeError("login argument must be %r, not %r" %
|
||||
(str, type(login)))
|
||||
http_if_match = kwargs.pop("http_if_match", None)
|
||||
if http_if_match is not None and not isinstance(http_if_match, str):
|
||||
raise TypeError("http_if_match argument must be %r, not %r" %
|
||||
(str, type(http_if_match)))
|
||||
remote_useragent = kwargs.pop("remote_useragent", None)
|
||||
remote_host = kwargs.pop("remote_host", None)
|
||||
environ: Dict[str, Any] = {k.upper(): v for k, v in kwargs.items()}
|
||||
for k, v in environ.items():
|
||||
if not isinstance(v, str):
|
||||
@@ -84,6 +98,12 @@ class BaseTest:
|
||||
if login:
|
||||
environ["HTTP_AUTHORIZATION"] = "Basic " + base64.b64encode(
|
||||
login.encode(encoding)).decode()
|
||||
if http_if_match:
|
||||
environ["HTTP_IF_MATCH"] = http_if_match
|
||||
if remote_useragent:
|
||||
environ["HTTP_USER_AGENT"] = remote_useragent
|
||||
if remote_host:
|
||||
environ["REMOTE_ADDR"] = remote_host
|
||||
environ["REQUEST_METHOD"] = method.upper()
|
||||
environ["PATH_INFO"] = path
|
||||
if data is not None:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# This file is part of Radicale - CalDAV and CardDAV server
|
||||
# Copyright © 2017-2018 Unrud <unrud@outlook.com>
|
||||
# Copyright © 2017-2021 Unrud <unrud@outlook.com>
|
||||
# Copyright © 2025-2025 Peter Bieringer <pb@bieringer.de>
|
||||
#
|
||||
# This library is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
@@ -28,9 +29,9 @@ class Web(web.BaseWeb):
|
||||
|
||||
def get(self, environ: types.WSGIEnviron, base_prefix: str, path: str,
|
||||
user: str) -> types.WSGIResponse:
|
||||
return client.OK, {"Content-Type": "text/plain"}, "custom"
|
||||
return client.OK, {"Content-Type": "text/plain"}, "custom", None
|
||||
|
||||
def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str,
|
||||
user: str) -> types.WSGIResponse:
|
||||
content = httputils.read_request_body(self.configuration, environ)
|
||||
return client.OK, {"Content-Type": "text/plain"}, "echo:" + content
|
||||
return client.OK, {"Content-Type": "text/plain"}, "echo:" + content, None
|
||||
|
||||
16
radicale/tests/static/broken-vcards.vcf
Normal file
16
radicale/tests/static/broken-vcards.vcf
Normal file
@@ -0,0 +1,16 @@
|
||||
BEGIN:VCARD
|
||||
VERSION:3.0
|
||||
PRODID:-//Inverse inc.//SOGo Connector 1.0//EN
|
||||
UID:C68582D2-2E60-0001-C2C0-000000000000.vcf
|
||||
X-MOZILLA-HTML:FALSE
|
||||
EMAIL;TYPE=work:test-misses-N-or-FN@example.com
|
||||
X-RADICALE-NAME:C68582D2-2E60-0001-C2C0-000000000000.vcf
|
||||
END:VCARD
|
||||
BEGIN:VCARD
|
||||
VERSION:3.0
|
||||
PRODID:-//Inverse inc.//SOGo Connector 1.0//EN
|
||||
UID:C68582D2-2E60-0001-C2C0-000000000001.vcf
|
||||
X-MOZILLA-HTML:FALSE
|
||||
EMAIL;TYPE=work:test-misses-N-or-FN@example1.com
|
||||
X-RADICALE-NAME:C68582D2-2E60-0001-C2C0-000000000001.vcf
|
||||
END:VCARD
|
||||
16
radicale/tests/static/broken-vcards2-no_uid.vcf
Normal file
16
radicale/tests/static/broken-vcards2-no_uid.vcf
Normal file
@@ -0,0 +1,16 @@
|
||||
BEGIN:VCARD
|
||||
VERSION:3.0
|
||||
PRODID:-//Inverse inc.//SOGo Connector 1.0//EN
|
||||
UID:C68582D2-2E60-0001-C2C0-000000000000.vcf
|
||||
X-MOZILLA-HTML:FALSE
|
||||
EMAIL;TYPE=work:test-misses-N-or-FN@example.com
|
||||
FN:Test Example
|
||||
X-RADICALE-NAME:C68582D2-2E60-0001-C2C0-000000000000.vcf
|
||||
END:VCARD
|
||||
BEGIN:VCARD
|
||||
VERSION:3.0
|
||||
PRODID:-//Inverse inc.//SOGo Connector 1.0//EN
|
||||
X-MOZILLA-HTML:FALSE
|
||||
EMAIL;TYPE=work:test-misses-N-or-FN@example1.com
|
||||
X-RADICALE-NAME:C68582D2-2E60-0001-C2C0-000000000001.vcf
|
||||
END:VCARD
|
||||
17
radicale/tests/static/broken-vcards2.vcf
Normal file
17
radicale/tests/static/broken-vcards2.vcf
Normal file
@@ -0,0 +1,17 @@
|
||||
BEGIN:VCARD
|
||||
VERSION:3.0
|
||||
PRODID:-//Inverse inc.//SOGo Connector 1.0//EN
|
||||
UID:C68582D2-2E60-0001-C2C0-000000000000.vcf
|
||||
X-MOZILLA-HTML:FALSE
|
||||
EMAIL;TYPE=work:test-misses-N-or-FN@example.com
|
||||
FN:Test Example
|
||||
X-RADICALE-NAME:C68582D2-2E60-0001-C2C0-000000000000.vcf
|
||||
END:VCARD
|
||||
BEGIN:VCARD
|
||||
VERSION:3.0
|
||||
PRODID:-//Inverse inc.//SOGo Connector 1.0//EN
|
||||
UID:C68582D2-2E60-0001-C2C0-000000000001.vcf
|
||||
X-MOZILLA-HTML:FALSE
|
||||
EMAIL;TYPE=work:test-misses-N-or-FN@example1.com
|
||||
X-RADICALE-NAME:C68582D2-2E60-0001-C2C0-000000000001.vcf
|
||||
END:VCARD
|
||||
25
radicale/tests/static/broken-vevents.ics
Normal file
25
radicale/tests/static/broken-vevents.ics
Normal file
@@ -0,0 +1,25 @@
|
||||
BEGIN:VCALENDAR
|
||||
PRODID:-//Radicale//NONSGML Radicale Server//EN
|
||||
VERSION:2.0
|
||||
BEGIN:VEVENT
|
||||
CREATED:20160725T060147Z
|
||||
LAST-MODIFIED:20160727T193435Z
|
||||
DTSTAMP:20160727T193435Z
|
||||
UID:040000008200E00074C5B7101A82E00800000000
|
||||
SUMMARY:Good ICS
|
||||
STATUS:CONFIRMED
|
||||
X-MOZ-LASTACK:20160727T193435Z
|
||||
DTSTART;TZID=Europe/Budapest:20160727T170000
|
||||
DTEND;TZID=Europe/Budapest:20160727T223000
|
||||
CLASS:PUBLIC
|
||||
X-LIC-ERROR:No value for LOCATION property. Removing entire property:
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
CREATED:20160725T060147Z
|
||||
LAST-MODIFIED:20160727T193435Z
|
||||
DTSTAMP:20160727T193435Z
|
||||
UID:040000008200E00074C5B7101A82E00800000001
|
||||
CLASS:PUBLIC
|
||||
X-LIC-ERROR:No value for LOCATION property. Removing entire property:
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
7
radicale/tests/static/contact1_v4.vcf
Normal file
7
radicale/tests/static/contact1_v4.vcf
Normal file
@@ -0,0 +1,7 @@
|
||||
BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
UID:contact1
|
||||
N:Contact;;;;
|
||||
FN:Contact
|
||||
NICKNAME:test
|
||||
END:VCARD
|
||||
12
radicale/tests/static/contact_multiple_v4.vcf
Normal file
12
radicale/tests/static/contact_multiple_v4.vcf
Normal file
@@ -0,0 +1,12 @@
|
||||
BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
UID:contact1
|
||||
N:Contact1;;;;
|
||||
FN:Contact1
|
||||
END:VCARD
|
||||
BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
UID:contact2
|
||||
N:Contact2;;;;
|
||||
FN:Contact2
|
||||
END:VCARD
|
||||
8
radicale/tests/static/contact_photo_with_data_uri_v4.vcf
Normal file
8
radicale/tests/static/contact_photo_with_data_uri_v4.vcf
Normal file
@@ -0,0 +1,8 @@
|
||||
BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
UID:contact
|
||||
N:Contact;;;;
|
||||
FN:Contact
|
||||
NICKNAME:test
|
||||
PHOTO:data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAAD0lEQVQIHQEEAPv/AP///wX+Av4DfRnGAAAAAElFTkSuQmCC
|
||||
END:VCARD
|
||||
30
radicale/tests/static/event_issue1812_getetag.ics
Normal file
30
radicale/tests/static/event_issue1812_getetag.ics
Normal file
@@ -0,0 +1,30 @@
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//algoo.fr//NONSGML Open Calendar v0.9//EN
|
||||
BEGIN:VTIMEZONE
|
||||
TZID:Europe/Paris
|
||||
LAST-MODIFIED:20250523T094234Z
|
||||
BEGIN:STANDARD
|
||||
DTSTART:19701025T030000Z
|
||||
RRULE:BYDAY=-1SU;BYMONTH=10;FREQ=YEARLY
|
||||
TZNAME:CET
|
||||
TZOFFSETFROM:+0200
|
||||
TZOFFSETTO:+0100
|
||||
END:STANDARD
|
||||
BEGIN:DAYLIGHT
|
||||
DTSTART:19700329T020000Z
|
||||
RRULE:BYDAY=-1SU;BYMONTH=3;FREQ=YEARLY
|
||||
TZNAME:CEST
|
||||
TZOFFSETFROM:+0100
|
||||
TZOFFSETTO:+0200
|
||||
END:DAYLIGHT
|
||||
END:VTIMEZONE
|
||||
BEGIN:VEVENT
|
||||
UID:070a3478-4411-4364-844d-26f3542fc364
|
||||
DTSTART;TZID=Europe/Paris;VALUE=DATE:20250716
|
||||
DTEND;TZID=Europe/Paris;VALUE=DATE:20250717
|
||||
DTSTAMP;VALUE=DATE-TIME:20250723T080354Z
|
||||
SEQUENCE:1
|
||||
SUMMARY:Filtered event
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
14
radicale/tests/static/event_issue1847_1.ics
Normal file
14
radicale/tests/static/event_issue1847_1.ics
Normal file
@@ -0,0 +1,14 @@
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//algoo.fr//NONSGML Open Calendar v0.9//EN
|
||||
BEGIN:VEVENT
|
||||
CREATED:20250814T153429Z
|
||||
LAST-MODIFIED:20250814T153503Z
|
||||
DTSTAMP:20250814T153503Z
|
||||
UID:f91964cb-53ca-4942-8811-c38f076f4328
|
||||
SUMMARY:error
|
||||
DTSTART:20250814T180000
|
||||
DTEND;TZID=Europe/Brussels:20250814T190000
|
||||
TRANSP:OPAQUE
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
14
radicale/tests/static/event_issue1847_2.ics
Normal file
14
radicale/tests/static/event_issue1847_2.ics
Normal file
@@ -0,0 +1,14 @@
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//algoo.fr//NONSGML Open Calendar v0.9//EN
|
||||
BEGIN:VEVENT
|
||||
CREATED:20250814T153429Z
|
||||
LAST-MODIFIED:20250814T153503Z
|
||||
DTSTAMP:20250814T153503Z
|
||||
UID:f91964cb-53ca-4942-8811-c38f076f4328
|
||||
SUMMARY:error
|
||||
DTSTART;TZID=Europe/Brussels:20250814T180000
|
||||
DTEND:20250814T190000
|
||||
TRANSP:OPAQUE
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
29
radicale/tests/static/event_issue1880_1.ics
Normal file
29
radicale/tests/static/event_issue1880_1.ics
Normal file
@@ -0,0 +1,29 @@
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//algoo.fr//NONSGML Open Calendar v0.9//EN
|
||||
BEGIN:VTIMEZONE
|
||||
TZID:Europe/Paris
|
||||
LAST-MODIFIED:20250523T094234Z
|
||||
BEGIN:STANDARD
|
||||
DTSTART:19701025T030000Z
|
||||
RRULE:BYDAY=-1SU;BYMONTH=10;FREQ=YEARLY
|
||||
TZNAME:CET
|
||||
TZOFFSETFROM:+0200
|
||||
TZOFFSETTO:+0100
|
||||
END:STANDARD
|
||||
BEGIN:DAYLIGHT
|
||||
DTSTART:19700329T020000Z
|
||||
RRULE:BYDAY=-1SU;BYMONTH=3;FREQ=YEARLY
|
||||
TZNAME:CEST
|
||||
TZOFFSETFROM:+0100
|
||||
TZOFFSETTO:+0200
|
||||
END:DAYLIGHT
|
||||
END:VTIMEZONE
|
||||
BEGIN:VEVENT
|
||||
UID:f5b69821-addc-4010-9ab8-891df1c33c01
|
||||
DTSTART;TZID=Europe/Paris;VALUE=DATE-TIME:20250925T093000
|
||||
DTEND;TZID=Europe/Paris;VALUE=DATE-TIME:20250925T140000
|
||||
DTSTAMP:20250923T114003Z
|
||||
SUMMARY:event from opencalendar
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
396
radicale/tests/static/event_issue1880_2.ics
Normal file
396
radicale/tests/static/event_issue1880_2.ics
Normal file
@@ -0,0 +1,396 @@
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
|
||||
BEGIN:VTIMEZONE
|
||||
TZID:Europe/Paris
|
||||
BEGIN:STANDARD
|
||||
DTSTART:19110311T000000
|
||||
RDATE:19110311T000000
|
||||
TZNAME:Europe/Paris(STD)
|
||||
TZOFFSETFROM:+000921
|
||||
TZOFFSETTO:+000000
|
||||
END:STANDARD
|
||||
BEGIN:STANDARD
|
||||
DTSTART:19161002T000000
|
||||
RDATE:19161002T000000
|
||||
TZNAME:Europe/Paris(STD)
|
||||
TZOFFSETFROM:+010000
|
||||
TZOFFSETTO:+000000
|
||||
END:STANDARD
|
||||
BEGIN:STANDARD
|
||||
DTSTART:19171008T000000
|
||||
RDATE:19171008T000000
|
||||
TZNAME:Europe/Paris(STD)
|
||||
TZOFFSETFROM:+010000
|
||||
TZOFFSETTO:+000000
|
||||
END:STANDARD
|
||||
BEGIN:STANDARD
|
||||
DTSTART:19181007T000000
|
||||
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=1MO;UNTIL=19191006T000000
|
||||
TZNAME:Europe/Paris(STD)
|
||||
TZOFFSETFROM:+010000
|
||||
TZOFFSETTO:+000000
|
||||
END:STANDARD
|
||||
BEGIN:STANDARD
|
||||
DTSTART:19201024T000000
|
||||
RDATE:19201024T000000
|
||||
TZNAME:Europe/Paris(STD)
|
||||
TZOFFSETFROM:+010000
|
||||
TZOFFSETTO:+000000
|
||||
END:STANDARD
|
||||
BEGIN:STANDARD
|
||||
DTSTART:19211026T000000
|
||||
RDATE:19211026T000000
|
||||
TZNAME:Europe/Paris(STD)
|
||||
TZOFFSETFROM:+010000
|
||||
TZOFFSETTO:+000000
|
||||
END:STANDARD
|
||||
BEGIN:STANDARD
|
||||
DTSTART:19221008T000000
|
||||
RDATE:19221008T000000
|
||||
TZNAME:Europe/Paris(STD)
|
||||
TZOFFSETFROM:+010000
|
||||
TZOFFSETTO:+000000
|
||||
END:STANDARD
|
||||
BEGIN:STANDARD
|
||||
DTSTART:19231007T000000
|
||||
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=1SU;UNTIL=19321002T000000
|
||||
TZNAME:Europe/Paris(STD)
|
||||
TZOFFSETFROM:+010000
|
||||
TZOFFSETTO:+000000
|
||||
END:STANDARD
|
||||
BEGIN:STANDARD
|
||||
DTSTART:19331008T000000
|
||||
RDATE:19331008T000000
|
||||
TZNAME:Europe/Paris(STD)
|
||||
TZOFFSETFROM:+010000
|
||||
TZOFFSETTO:+000000
|
||||
END:STANDARD
|
||||
BEGIN:STANDARD
|
||||
DTSTART:19341007T000000
|
||||
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=1SU;UNTIL=19381002T000000
|
||||
TZNAME:Europe/Paris(STD)
|
||||
TZOFFSETFROM:+010000
|
||||
TZOFFSETTO:+000000
|
||||
END:STANDARD
|
||||
BEGIN:STANDARD
|
||||
DTSTART:19391119T000000
|
||||
RDATE:19391119T000000
|
||||
TZNAME:Europe/Paris(STD)
|
||||
TZOFFSETFROM:+010000
|
||||
TZOFFSETTO:+000000
|
||||
END:STANDARD
|
||||
BEGIN:STANDARD
|
||||
DTSTART:19421102T030000
|
||||
RDATE:19421102T030000
|
||||
TZNAME:Europe/Paris(STD)
|
||||
TZOFFSETFROM:+020000
|
||||
TZOFFSETTO:+010000
|
||||
END:STANDARD
|
||||
BEGIN:STANDARD
|
||||
DTSTART:19431004T030000
|
||||
RDATE:19431004T030000
|
||||
TZNAME:Europe/Paris(STD)
|
||||
TZOFFSETFROM:+020000
|
||||
TZOFFSETTO:+010000
|
||||
END:STANDARD
|
||||
BEGIN:STANDARD
|
||||
DTSTART:19450916T030000
|
||||
RDATE:19450916T030000
|
||||
TZNAME:Europe/Paris(STD)
|
||||
TZOFFSETFROM:+020000
|
||||
TZOFFSETTO:+010000
|
||||
END:STANDARD
|
||||
BEGIN:STANDARD
|
||||
DTSTART:19760926T010000
|
||||
RDATE:19760926T010000
|
||||
TZNAME:Europe/Paris(STD)
|
||||
TZOFFSETFROM:+020000
|
||||
TZOFFSETTO:+010000
|
||||
END:STANDARD
|
||||
BEGIN:STANDARD
|
||||
DTSTART:19770925T030000
|
||||
RDATE:19770925T030000
|
||||
TZNAME:Europe/Paris(STD)
|
||||
TZOFFSETFROM:+020000
|
||||
TZOFFSETTO:+010000
|
||||
END:STANDARD
|
||||
BEGIN:STANDARD
|
||||
DTSTART:19781001T030000
|
||||
RDATE:19781001T030000
|
||||
TZNAME:Europe/Paris(STD)
|
||||
TZOFFSETFROM:+020000
|
||||
TZOFFSETTO:+010000
|
||||
END:STANDARD
|
||||
BEGIN:STANDARD
|
||||
DTSTART:19790930T030000
|
||||
RRULE:FREQ=YEARLY;BYMONTH=9;BYDAY=-1SU;UNTIL=19950924T030000
|
||||
TZNAME:Europe/Paris(STD)
|
||||
TZOFFSETFROM:+020000
|
||||
TZOFFSETTO:+010000
|
||||
END:STANDARD
|
||||
BEGIN:STANDARD
|
||||
DTSTART:19961027T030000
|
||||
RDATE:19961027T030000
|
||||
TZNAME:Europe/Paris(STD)
|
||||
TZOFFSETFROM:+020000
|
||||
TZOFFSETTO:+010000
|
||||
END:STANDARD
|
||||
BEGIN:STANDARD
|
||||
DTSTART:19971026T030000
|
||||
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
|
||||
TZNAME:(STD)
|
||||
TZOFFSETFROM:+020000
|
||||
TZOFFSETTO:+010000
|
||||
END:STANDARD
|
||||
BEGIN:DAYLIGHT
|
||||
DTSTART:19160614T230000
|
||||
RDATE:19160614T230000
|
||||
TZNAME:Europe/Paris(DST)
|
||||
TZOFFSETFROM:+000000
|
||||
TZOFFSETTO:+010000
|
||||
END:DAYLIGHT
|
||||
BEGIN:DAYLIGHT
|
||||
DTSTART:19170324T230000
|
||||
RDATE:19170324T230000
|
||||
TZNAME:Europe/Paris(DST)
|
||||
TZOFFSETFROM:+000000
|
||||
TZOFFSETTO:+010000
|
||||
END:DAYLIGHT
|
||||
BEGIN:DAYLIGHT
|
||||
DTSTART:19180309T230000
|
||||
RDATE:19180309T230000
|
||||
TZNAME:Europe/Paris(DST)
|
||||
TZOFFSETFROM:+000000
|
||||
TZOFFSETTO:+010000
|
||||
END:DAYLIGHT
|
||||
BEGIN:DAYLIGHT
|
||||
DTSTART:19190301T230000
|
||||
RDATE:19190301T230000
|
||||
TZNAME:Europe/Paris(DST)
|
||||
TZOFFSETFROM:+000000
|
||||
TZOFFSETTO:+010000
|
||||
END:DAYLIGHT
|
||||
BEGIN:DAYLIGHT
|
||||
DTSTART:19200214T230000
|
||||
RDATE:19200214T230000
|
||||
TZNAME:Europe/Paris(DST)
|
||||
TZOFFSETFROM:+000000
|
||||
TZOFFSETTO:+010000
|
||||
END:DAYLIGHT
|
||||
BEGIN:DAYLIGHT
|
||||
DTSTART:19210314T230000
|
||||
RDATE:19210314T230000
|
||||
TZNAME:Europe/Paris(DST)
|
||||
TZOFFSETFROM:+000000
|
||||
TZOFFSETTO:+010000
|
||||
END:DAYLIGHT
|
||||
BEGIN:DAYLIGHT
|
||||
DTSTART:19220325T230000
|
||||
RDATE:19220325T230000
|
||||
TZNAME:Europe/Paris(DST)
|
||||
TZOFFSETFROM:+000000
|
||||
TZOFFSETTO:+010000
|
||||
END:DAYLIGHT
|
||||
BEGIN:DAYLIGHT
|
||||
DTSTART:19230526T230000
|
||||
RDATE:19230526T230000
|
||||
TZNAME:Europe/Paris(DST)
|
||||
TZOFFSETFROM:+000000
|
||||
TZOFFSETTO:+010000
|
||||
END:DAYLIGHT
|
||||
BEGIN:DAYLIGHT
|
||||
DTSTART:19240329T230000
|
||||
RDATE:19240329T230000
|
||||
TZNAME:Europe/Paris(DST)
|
||||
TZOFFSETFROM:+000000
|
||||
TZOFFSETTO:+010000
|
||||
END:DAYLIGHT
|
||||
BEGIN:DAYLIGHT
|
||||
DTSTART:19250404T230000
|
||||
RDATE:19250404T230000
|
||||
TZNAME:Europe/Paris(DST)
|
||||
TZOFFSETFROM:+000000
|
||||
TZOFFSETTO:+010000
|
||||
END:DAYLIGHT
|
||||
BEGIN:DAYLIGHT
|
||||
DTSTART:19260417T230000
|
||||
RDATE:19260417T230000
|
||||
TZNAME:Europe/Paris(DST)
|
||||
TZOFFSETFROM:+000000
|
||||
TZOFFSETTO:+010000
|
||||
END:DAYLIGHT
|
||||
BEGIN:DAYLIGHT
|
||||
DTSTART:19270409T230000
|
||||
RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=2SA;UNTIL=19280414T230000
|
||||
TZNAME:Europe/Paris(DST)
|
||||
TZOFFSETFROM:+000000
|
||||
TZOFFSETTO:+010000
|
||||
END:DAYLIGHT
|
||||
BEGIN:DAYLIGHT
|
||||
DTSTART:19290420T230000
|
||||
RDATE:19290420T230000
|
||||
TZNAME:Europe/Paris(DST)
|
||||
TZOFFSETFROM:+000000
|
||||
TZOFFSETTO:+010000
|
||||
END:DAYLIGHT
|
||||
BEGIN:DAYLIGHT
|
||||
DTSTART:19300412T230000
|
||||
RDATE:19300412T230000
|
||||
TZNAME:Europe/Paris(DST)
|
||||
TZOFFSETFROM:+000000
|
||||
TZOFFSETTO:+010000
|
||||
END:DAYLIGHT
|
||||
BEGIN:DAYLIGHT
|
||||
DTSTART:19310418T230000
|
||||
RDATE:19310418T230000
|
||||
TZNAME:Europe/Paris(DST)
|
||||
TZOFFSETFROM:+000000
|
||||
TZOFFSETTO:+010000
|
||||
END:DAYLIGHT
|
||||
BEGIN:DAYLIGHT
|
||||
DTSTART:19320402T230000
|
||||
RDATE:19320402T230000
|
||||
TZNAME:Europe/Paris(DST)
|
||||
TZOFFSETFROM:+000000
|
||||
TZOFFSETTO:+010000
|
||||
END:DAYLIGHT
|
||||
BEGIN:DAYLIGHT
|
||||
DTSTART:19330325T230000
|
||||
RDATE:19330325T230000
|
||||
TZNAME:Europe/Paris(DST)
|
||||
TZOFFSETFROM:+000000
|
||||
TZOFFSETTO:+010000
|
||||
END:DAYLIGHT
|
||||
BEGIN:DAYLIGHT
|
||||
DTSTART:19340407T230000
|
||||
RDATE:19340407T230000
|
||||
TZNAME:Europe/Paris(DST)
|
||||
TZOFFSETFROM:+000000
|
||||
TZOFFSETTO:+010000
|
||||
END:DAYLIGHT
|
||||
BEGIN:DAYLIGHT
|
||||
DTSTART:19350330T230000
|
||||
RDATE:19350330T230000
|
||||
TZNAME:Europe/Paris(DST)
|
||||
TZOFFSETFROM:+000000
|
||||
TZOFFSETTO:+010000
|
||||
END:DAYLIGHT
|
||||
BEGIN:DAYLIGHT
|
||||
DTSTART:19360418T230000
|
||||
RDATE:19360418T230000
|
||||
TZNAME:Europe/Paris(DST)
|
||||
TZOFFSETFROM:+000000
|
||||
TZOFFSETTO:+010000
|
||||
END:DAYLIGHT
|
||||
BEGIN:DAYLIGHT
|
||||
DTSTART:19370403T230000
|
||||
RDATE:19370403T230000
|
||||
TZNAME:Europe/Paris(DST)
|
||||
TZOFFSETFROM:+000000
|
||||
TZOFFSETTO:+010000
|
||||
END:DAYLIGHT
|
||||
BEGIN:DAYLIGHT
|
||||
DTSTART:19380326T230000
|
||||
RDATE:19380326T230000
|
||||
TZNAME:Europe/Paris(DST)
|
||||
TZOFFSETFROM:+000000
|
||||
TZOFFSETTO:+010000
|
||||
END:DAYLIGHT
|
||||
BEGIN:DAYLIGHT
|
||||
DTSTART:19390415T230000
|
||||
RDATE:19390415T230000
|
||||
TZNAME:Europe/Paris(DST)
|
||||
TZOFFSETFROM:+000000
|
||||
TZOFFSETTO:+010000
|
||||
END:DAYLIGHT
|
||||
BEGIN:DAYLIGHT
|
||||
DTSTART:19400225T020000
|
||||
RDATE:19400225T020000
|
||||
TZNAME:Europe/Paris(DST)
|
||||
TZOFFSETFROM:+000000
|
||||
TZOFFSETTO:+010000
|
||||
END:DAYLIGHT
|
||||
BEGIN:DAYLIGHT
|
||||
DTSTART:19400614T230000
|
||||
RDATE:19400614T230000
|
||||
TZNAME:Europe/Paris(DST)
|
||||
TZOFFSETFROM:+010000
|
||||
TZOFFSETTO:+020000
|
||||
END:DAYLIGHT
|
||||
BEGIN:DAYLIGHT
|
||||
DTSTART:19430329T020000
|
||||
RDATE:19430329T020000
|
||||
TZNAME:Europe/Paris(DST)
|
||||
TZOFFSETFROM:+010000
|
||||
TZOFFSETTO:+020000
|
||||
END:DAYLIGHT
|
||||
BEGIN:DAYLIGHT
|
||||
DTSTART:19440403T020000
|
||||
RDATE:19440403T020000
|
||||
TZNAME:Europe/Paris(DST)
|
||||
TZOFFSETFROM:+010000
|
||||
TZOFFSETTO:+020000
|
||||
END:DAYLIGHT
|
||||
BEGIN:DAYLIGHT
|
||||
DTSTART:19440825T000000
|
||||
RDATE:19440825T000000
|
||||
TZNAME:Europe/Paris(DST)
|
||||
TZOFFSETFROM:+020000
|
||||
TZOFFSETTO:+020000
|
||||
END:DAYLIGHT
|
||||
BEGIN:DAYLIGHT
|
||||
DTSTART:19441008T010000
|
||||
RDATE:19441008T010000
|
||||
TZNAME:Europe/Paris(DST)
|
||||
TZOFFSETFROM:+020000
|
||||
TZOFFSETTO:+010000
|
||||
END:DAYLIGHT
|
||||
BEGIN:DAYLIGHT
|
||||
DTSTART:19450402T020000
|
||||
RDATE:19450402T020000
|
||||
TZNAME:Europe/Paris(DST)
|
||||
TZOFFSETFROM:+010000
|
||||
TZOFFSETTO:+020000
|
||||
END:DAYLIGHT
|
||||
BEGIN:DAYLIGHT
|
||||
DTSTART:19760328T010000
|
||||
RDATE:19760328T010000
|
||||
TZNAME:Europe/Paris(DST)
|
||||
TZOFFSETFROM:+010000
|
||||
TZOFFSETTO:+020000
|
||||
END:DAYLIGHT
|
||||
BEGIN:DAYLIGHT
|
||||
DTSTART:19770403T020000
|
||||
RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=1SU;UNTIL=19800406T020000
|
||||
TZNAME:Europe/Paris(DST)
|
||||
TZOFFSETFROM:+010000
|
||||
TZOFFSETTO:+020000
|
||||
END:DAYLIGHT
|
||||
BEGIN:DAYLIGHT
|
||||
DTSTART:19810329T020000
|
||||
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU;UNTIL=19960331T020000
|
||||
TZNAME:Europe/Paris(DST)
|
||||
TZOFFSETFROM:+010000
|
||||
TZOFFSETTO:+020000
|
||||
END:DAYLIGHT
|
||||
BEGIN:DAYLIGHT
|
||||
DTSTART:19970330T020000
|
||||
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
|
||||
TZNAME:(DST)
|
||||
TZOFFSETFROM:+010000
|
||||
TZOFFSETTO:+020000
|
||||
END:DAYLIGHT
|
||||
X-TZINFO:Europe/Paris[2024a]
|
||||
END:VTIMEZONE
|
||||
BEGIN:VEVENT
|
||||
UID:50c08af4-295c-4bea-9ea4-7402b8e82143
|
||||
DTSTART;TZID=Europe/Paris:20250924T133000
|
||||
DTEND;TZID=Europe/Paris:20250924T143000
|
||||
CREATED:20250923T113902Z
|
||||
DTSTAMP:20250923T113912Z
|
||||
LAST-MODIFIED:20250923T113912Z
|
||||
SUMMARY:event from thunderbird
|
||||
TRANSP:OPAQUE
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
38
radicale/tests/static/event_issue1970_ok.ics
Normal file
38
radicale/tests/static/event_issue1970_ok.ics
Normal file
@@ -0,0 +1,38 @@
|
||||
BEGIN:VCALENDAR
|
||||
PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
|
||||
VERSION:2.0
|
||||
BEGIN:VTIMEZONE
|
||||
TZID:Europe/Paris
|
||||
X-LIC-LOCATION:Europe/Paris
|
||||
BEGIN:DAYLIGHT
|
||||
TZOFFSETFROM:+0100
|
||||
TZOFFSETTO:+0200
|
||||
TZNAME:CEST
|
||||
DTSTART:19700329T020000
|
||||
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3
|
||||
END:DAYLIGHT
|
||||
BEGIN:STANDARD
|
||||
TZOFFSETFROM:+0200
|
||||
TZOFFSETTO:+0100
|
||||
TZNAME:CET
|
||||
DTSTART:19701025T030000
|
||||
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10
|
||||
END:STANDARD
|
||||
END:VTIMEZONE
|
||||
BEGIN:VEVENT
|
||||
CREATED:20130902T150157Z
|
||||
LAST-MODIFIED:20130902T150158Z
|
||||
DTSTAMP:20130902T150158Z
|
||||
UID:event1
|
||||
SUMMARY:Event
|
||||
CATEGORIES:some_category1,another_category2
|
||||
DESCRIPTION:Line1
|
||||
Line2
|
||||
Line3
|
||||
ORGANIZER:mailto:unclesam@example.com
|
||||
ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=TENTATIVE;CN=Jane Doe:MAILTO:janedoe@example.com
|
||||
ATTENDEE;ROLE=REQ-PARTICIPANT;DELEGATED-FROM="MAILTO:bob@host.com";PARTSTAT=ACCEPTED;CN=John Doe:MAILTO:johndoe@example.com
|
||||
DTSTART;TZID=Europe/Paris:20130901T180000
|
||||
DTEND;TZID=Europe/Paris:20130901T190000
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
39
radicale/tests/static/event_issue1970_problem.ics
Normal file
39
radicale/tests/static/event_issue1970_problem.ics
Normal file
@@ -0,0 +1,39 @@
|
||||
BEGIN:VCALENDAR
|
||||
PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
|
||||
VERSION:2.0
|
||||
BEGIN:VTIMEZONE
|
||||
TZID:Europe/Paris
|
||||
X-LIC-LOCATION:Europe/Paris
|
||||
BEGIN:DAYLIGHT
|
||||
TZOFFSETFROM:+0100
|
||||
TZOFFSETTO:+0200
|
||||
TZNAME:CEST
|
||||
DTSTART:19700329T020000
|
||||
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3
|
||||
END:DAYLIGHT
|
||||
BEGIN:STANDARD
|
||||
TZOFFSETFROM:+0200
|
||||
TZOFFSETTO:+0100
|
||||
TZNAME:CET
|
||||
DTSTART:19701025T030000
|
||||
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10
|
||||
END:STANDARD
|
||||
END:VTIMEZONE
|
||||
BEGIN:VEVENT
|
||||
CREATED:20130902T150157Z
|
||||
LAST-MODIFIED:20130902T150158Z
|
||||
DTSTAMP:20130902T150158Z
|
||||
UID:event1
|
||||
SUMMARY:Event having description with empty line
|
||||
CATEGORIES:some_category1,another_category2
|
||||
DESCRIPTION:Line1
|
||||
Line2
|
||||
|
||||
Line4
|
||||
ORGANIZER:mailto:unclesam@example.com
|
||||
ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=TENTATIVE;CN=Jane Doe:MAILTO:janedoe@example.com
|
||||
ATTENDEE;ROLE=REQ-PARTICIPANT;DELEGATED-FROM="MAILTO:bob@host.com";PARTSTAT=ACCEPTED;CN=John Doe:MAILTO:johndoe@example.com
|
||||
DTSTART;TZID=Europe/Paris:20130901T180000
|
||||
DTEND;TZID=Europe/Paris:20130901T190000
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
40
radicale/tests/static/event_multiple3.ics
Normal file
40
radicale/tests/static/event_multiple3.ics
Normal file
@@ -0,0 +1,40 @@
|
||||
BEGIN:VCALENDAR
|
||||
PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
|
||||
VERSION:2.0
|
||||
BEGIN:VTIMEZONE
|
||||
TZID:Europe/Paris
|
||||
X-LIC-LOCATION:Europe/Paris
|
||||
BEGIN:DAYLIGHT
|
||||
TZOFFSETFROM:+0100
|
||||
TZOFFSETTO:+0200
|
||||
TZNAME:CEST
|
||||
DTSTART:19700329T020000
|
||||
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3
|
||||
END:DAYLIGHT
|
||||
BEGIN:STANDARD
|
||||
TZOFFSETFROM:+0200
|
||||
TZOFFSETTO:+0100
|
||||
TZNAME:CET
|
||||
DTSTART:19701025T030000
|
||||
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10
|
||||
END:STANDARD
|
||||
END:VTIMEZONE
|
||||
BEGIN:VEVENT
|
||||
UID:event
|
||||
SUMMARY:Event
|
||||
DTSTART;TZID=Europe/Paris:20130901T190000
|
||||
DTEND;TZID=Europe/Paris:20130901T200000
|
||||
END:VEVENT
|
||||
BEGIN:VTODO
|
||||
UID:todo
|
||||
DTSTART;TZID=Europe/Paris:20130901T220000
|
||||
DURATION:PT1H
|
||||
SUMMARY:Todo
|
||||
END:VTODO
|
||||
BEGIN:VEVENT
|
||||
UID:event2
|
||||
SUMMARY:Event-with-longer-description
|
||||
DTSTART;TZID=Europe/Paris:20130901T190000
|
||||
DTEND;TZID=Europe/Paris:20130901T200000
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
@@ -2,7 +2,7 @@
|
||||
# Copyright © 2012-2016 Jean-Marc Martins
|
||||
# Copyright © 2012-2017 Guillaume Ayoub
|
||||
# Copyright © 2017-2022 Unrud <unrud@outlook.com>
|
||||
# Copyright © 2024-2025 Peter Bieringer <pb@bieringer.de>
|
||||
# Copyright © 2024-2026 Peter Bieringer <pb@bieringer.de>
|
||||
#
|
||||
# This library is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
@@ -30,7 +30,7 @@ from typing import Iterable, Tuple, Union
|
||||
|
||||
import pytest
|
||||
|
||||
from radicale import xmlutils
|
||||
from radicale import utils, xmlutils
|
||||
from radicale.tests import BaseTest
|
||||
|
||||
|
||||
@@ -114,45 +114,60 @@ class TestBaseAuthRequests(BaseTest):
|
||||
def test_htpasswd_sha256_autodetect(self) -> None:
|
||||
self._test_htpasswd("autodetect", "tmp:$5$i4Ni4TQq6L5FKss5$ilpTjkmnxkwZeV35GB9cYSsDXTALBn6KtWRJAzNlCL/")
|
||||
|
||||
def test_htpasswd_sha256_autodetect_with_rounds(self) -> None:
|
||||
self._test_htpasswd("autodetect", "tmp:$5$rounds=2500$9QD/kpJlV71PCXWy$/AbUzxa6kjDWHJ8BLU1hyQUBN/8wsGEf.rNjuKDHA24")
|
||||
|
||||
def test_htpasswd_sha512(self) -> None:
|
||||
self._test_htpasswd("sha512", "tmp:$6$3Qhl8r6FLagYdHYa$UCH9yXCed4A.J9FQsFPYAOXImzZUMfvLa0lwcWOxWYLOF5sE/lF99auQ4jKvHY2vijxmefl7G6kMqZ8JPdhIJ/")
|
||||
|
||||
def test_htpasswd_sha512_autodetect(self) -> None:
|
||||
self._test_htpasswd("autodetect", "tmp:$6$3Qhl8r6FLagYdHYa$UCH9yXCed4A.J9FQsFPYAOXImzZUMfvLa0lwcWOxWYLOF5sE/lF99auQ4jKvHY2vijxmefl7G6kMqZ8JPdhIJ/")
|
||||
|
||||
def test_htpasswd_sha512_autodetect_with_rounds(self) -> None:
|
||||
self._test_htpasswd("autodetect", "tmp:$6$rounds=2500$A1H/cZUl3CBnsplz$bSKYCDQ/YGR..YhxaZcM1eKmAi/jlnpbENKU8a.9kE95JBIpyUss3.cUyss0xQnhjD4PReN4sAzmdziWmoCsg/")
|
||||
|
||||
@pytest.mark.skipif(has_bcrypt == 0, reason="No bcrypt module installed")
|
||||
@pytest.mark.skipif(not utils.passlib_libpass_supports_bcrypt()[0], reason="bcrypt module incompatible with passlib(libpass) module")
|
||||
def test_htpasswd_bcrypt_2a(self) -> None:
|
||||
self._test_htpasswd("bcrypt", "tmp:$2a$10$Mj4A9vMecAp/K7.0fMKoVOk1SjgR.RBhl06a52nvzXhxlT3HB7Reu")
|
||||
|
||||
@pytest.mark.skipif(has_bcrypt == 0, reason="No bcrypt module installed")
|
||||
@pytest.mark.skipif(has_bcrypt == 0, reason="No bcrypt module installed or incompatibe")
|
||||
@pytest.mark.skipif(not utils.passlib_libpass_supports_bcrypt()[0], reason="bcrypt module incompatible with passlib(libpass) module")
|
||||
def test_htpasswd_bcrypt_2a_autodetect(self) -> None:
|
||||
self._test_htpasswd("autodetect", "tmp:$2a$10$Mj4A9vMecAp/K7.0fMKoVOk1SjgR.RBhl06a52nvzXhxlT3HB7Reu")
|
||||
|
||||
@pytest.mark.skipif(has_bcrypt == 0, reason="No bcrypt module installed")
|
||||
@pytest.mark.skipif(not utils.passlib_libpass_supports_bcrypt()[0], reason="bcrypt module incompatible with passlib(libpass) module")
|
||||
def test_htpasswd_bcrypt_2b(self) -> None:
|
||||
self._test_htpasswd("bcrypt", "tmp:$2b$12$7a4z/fdmXlBIfkz0smvzW.1Nds8wpgC/bo2DVOb4OSQKWCDL1A1wu")
|
||||
|
||||
@pytest.mark.skipif(has_bcrypt == 0, reason="No bcrypt module installed")
|
||||
@pytest.mark.skipif(not utils.passlib_libpass_supports_bcrypt()[0], reason="bcrypt module incompatible with passlib(libpass) module")
|
||||
def test_htpasswd_bcrypt_2b_autodetect(self) -> None:
|
||||
self._test_htpasswd("autodetect", "tmp:$2b$12$7a4z/fdmXlBIfkz0smvzW.1Nds8wpgC/bo2DVOb4OSQKWCDL1A1wu")
|
||||
|
||||
@pytest.mark.skipif(has_bcrypt == 0, reason="No bcrypt module installed")
|
||||
@pytest.mark.skipif(not utils.passlib_libpass_supports_bcrypt()[0], reason="bcrypt module incompatible with passlib(libpass) module")
|
||||
def test_htpasswd_bcrypt_2y(self) -> None:
|
||||
self._test_htpasswd("bcrypt", "tmp:$2y$05$oD7hbiQFQlvCM7zoalo/T.MssV3VNTRI3w5KDnj8NTUKJNWfVpvRq")
|
||||
|
||||
@pytest.mark.skipif(has_bcrypt == 0, reason="No bcrypt module installed")
|
||||
@pytest.mark.skipif(not utils.passlib_libpass_supports_bcrypt()[0], reason="bcrypt module incompatible with passlib(libpass) module")
|
||||
def test_htpasswd_bcrypt_2y_autodetect(self) -> None:
|
||||
self._test_htpasswd("autodetect", "tmp:$2y$05$oD7hbiQFQlvCM7zoalo/T.MssV3VNTRI3w5KDnj8NTUKJNWfVpvRq")
|
||||
|
||||
@pytest.mark.skipif(has_bcrypt == 0, reason="No bcrypt module installed")
|
||||
@pytest.mark.skipif(not utils.passlib_libpass_supports_bcrypt()[0], reason="bcrypt module incompatible with passlib(libpass) module")
|
||||
def test_htpasswd_bcrypt_C10(self) -> None:
|
||||
self._test_htpasswd("bcrypt", "tmp:$2y$10$bZsWq06ECzxqi7RmulQvC.T1YHUnLW2E3jn.MU2pvVTGn1dfORt2a")
|
||||
|
||||
@pytest.mark.skipif(has_bcrypt == 0, reason="No bcrypt module installed")
|
||||
@pytest.mark.skipif(not utils.passlib_libpass_supports_bcrypt()[0], reason="bcrypt module incompatible with passlib(libpass) module")
|
||||
def test_htpasswd_bcrypt_C10_autodetect(self) -> None:
|
||||
self._test_htpasswd("bcrypt", "tmp:$2y$10$bZsWq06ECzxqi7RmulQvC.T1YHUnLW2E3jn.MU2pvVTGn1dfORt2a")
|
||||
|
||||
@pytest.mark.skipif(has_bcrypt == 0, reason="No bcrypt module installed")
|
||||
@pytest.mark.skipif(not utils.passlib_libpass_supports_bcrypt()[0], reason="bcrypt module incompatible with passlib(libpass) module")
|
||||
def test_htpasswd_bcrypt_unicode(self) -> None:
|
||||
self._test_htpasswd("bcrypt", "😀:$2y$10$Oyz5aHV4MD9eQJbk6GPemOs4T6edK6U9Sqlzr.W1mMVCS8wJUftnW", "unicode")
|
||||
|
||||
@@ -263,6 +278,23 @@ class TestBaseAuthRequests(BaseTest):
|
||||
href_element = prop.find(xmlutils.make_clark("D:href"))
|
||||
assert href_element is not None and href_element.text == "/test/"
|
||||
|
||||
def test_http_remote_user(self) -> None:
|
||||
self.configure({"auth": {"type": "http_remote_user"}})
|
||||
_, responses = self.propfind("/", """\
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<propfind xmlns="DAV:">
|
||||
<prop>
|
||||
<current-user-principal />
|
||||
</prop>
|
||||
</propfind>""", HTTP_REMOTE_USER="test")
|
||||
assert responses is not None
|
||||
response = responses["/"]
|
||||
assert not isinstance(response, int)
|
||||
status, prop = response["D:current-user-principal"]
|
||||
assert status == 200
|
||||
href_element = prop.find(xmlutils.make_clark("D:href"))
|
||||
assert href_element is not None and href_element.text == "/test/"
|
||||
|
||||
def test_http_x_remote_user(self) -> None:
|
||||
self.configure({"auth": {"type": "http_x_remote_user"}})
|
||||
_, responses = self.propfind("/", """\
|
||||
@@ -282,13 +314,23 @@ class TestBaseAuthRequests(BaseTest):
|
||||
|
||||
@pytest.mark.skipif(sys.platform == 'win32', reason="Not supported on Windows")
|
||||
def _test_dovecot(
|
||||
self, user, password, expected_status,
|
||||
response=b'FAIL\n1\n', mech=[b'PLAIN'], broken=None):
|
||||
self, user, password, expected_status, expected_rip=None,
|
||||
response=b'FAIL\t1', mech=[b'PLAIN'], broken=None,
|
||||
extra_config=None, extra_env=None):
|
||||
import socket
|
||||
from unittest.mock import DEFAULT, patch
|
||||
|
||||
self.configure({"auth": {"type": "dovecot",
|
||||
"dovecot_socket": "./dovecot.sock"}})
|
||||
if extra_env is None:
|
||||
extra_env = {}
|
||||
if extra_config is None:
|
||||
extra_config = {}
|
||||
|
||||
config = {"auth": {"type": "dovecot",
|
||||
"dovecot_socket": "./dovecot.sock"}}
|
||||
for toplvl, entries in extra_config.items():
|
||||
for key, val in entries.items():
|
||||
config[toplvl][key] = val
|
||||
self.configure(config)
|
||||
|
||||
if broken is None:
|
||||
broken = []
|
||||
@@ -311,10 +353,18 @@ class TestBaseAuthRequests(BaseTest):
|
||||
if "done" not in broken:
|
||||
handshake += b'DONE\n'
|
||||
|
||||
sent_rip = None
|
||||
|
||||
def record_sent_data(s, data, flags=None):
|
||||
nonlocal sent_rip
|
||||
if b'\trip=' in data:
|
||||
sent_rip = data.split(b'\trip=')[1].split(b'\t')[0]
|
||||
return len(data)
|
||||
|
||||
with patch.multiple(
|
||||
'socket.socket',
|
||||
connect=DEFAULT,
|
||||
send=DEFAULT,
|
||||
send=record_sent_data,
|
||||
recv=DEFAULT
|
||||
) as mock_socket:
|
||||
if "socket" in broken:
|
||||
@@ -325,7 +375,9 @@ class TestBaseAuthRequests(BaseTest):
|
||||
status, _, answer = self.request(
|
||||
"PROPFIND", "/",
|
||||
HTTP_AUTHORIZATION="Basic %s" % base64.b64encode(
|
||||
("%s:%s" % (user, password)).encode()).decode())
|
||||
("%s:%s" % (user, password)).encode()).decode(),
|
||||
**extra_env)
|
||||
assert sent_rip == expected_rip
|
||||
assert status == expected_status
|
||||
|
||||
@pytest.mark.skipif(sys.platform == 'win32', reason="Not supported on Windows")
|
||||
@@ -392,6 +444,36 @@ class TestBaseAuthRequests(BaseTest):
|
||||
def test_dovecot_auth_id_mismatch(self):
|
||||
self._test_dovecot("user", "password", 401, response=b'OK\t2')
|
||||
|
||||
@pytest.mark.skipif(sys.platform == 'win32', reason="Not supported on Windows")
|
||||
def test_dovecot_remote_addr(self):
|
||||
self._test_dovecot("user", "password", 401, expected_rip=b'172.17.16.15',
|
||||
extra_env={
|
||||
'REMOTE_ADDR': '172.17.16.15',
|
||||
'HTTP_X_REMOTE_ADDR': '127.0.0.1',
|
||||
})
|
||||
|
||||
@pytest.mark.skipif(sys.platform == 'win32', reason="Not supported on Windows")
|
||||
def test_dovecot_x_remote_addr(self):
|
||||
self._test_dovecot("user", "password", 401, expected_rip=b'172.17.16.15',
|
||||
extra_env={
|
||||
'REMOTE_ADDR': '127.0.0.1',
|
||||
'HTTP_X_REMOTE_ADDR': '172.17.16.15',
|
||||
},
|
||||
extra_config={
|
||||
'auth': {"remote_ip_source": "X-Remote-Addr"},
|
||||
})
|
||||
|
||||
@pytest.mark.skipif(sys.platform == 'win32', reason="Not supported on Windows")
|
||||
def test_dovecot_x_remote_addr_whitespace(self):
|
||||
self._test_dovecot("user", "password", 401, expected_rip=b'172.17.16.15rip=127.0.0.1',
|
||||
extra_env={
|
||||
'REMOTE_ADDR': '127.0.0.1',
|
||||
'HTTP_X_REMOTE_ADDR': '172.17.16.15\trip=127.0.0.1',
|
||||
},
|
||||
extra_config={
|
||||
'auth': {"remote_ip_source": "X-Remote-Addr"},
|
||||
})
|
||||
|
||||
def test_custom(self) -> None:
|
||||
"""Custom authentication."""
|
||||
self.configure({"auth": {"type": "radicale.tests.custom.auth"}})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# This file is part of Radicale - CalDAV and CardDAV server
|
||||
# Copyright © 2012-2017 Guillaume Ayoub
|
||||
# Copyright © 2017-2022 Unrud <unrud@outlook.com>
|
||||
# Copyright © 2024-2025 Peter Bieringer <pb@bieringer.de>
|
||||
# Copyright © 2024-2026 Peter Bieringer <pb@bieringer.de>
|
||||
#
|
||||
# This library is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
@@ -24,12 +24,14 @@ Radicale tests with simple requests.
|
||||
import logging
|
||||
import os
|
||||
import posixpath
|
||||
import urllib
|
||||
from typing import Any, Callable, ClassVar, Iterable, List, Optional, Tuple
|
||||
|
||||
import defusedxml.ElementTree as DefusedET
|
||||
import pytest
|
||||
import vobject
|
||||
|
||||
from radicale import storage, xmlutils
|
||||
from radicale import storage, utils, xmlutils
|
||||
from radicale.tests import RESPONSES, BaseTest
|
||||
from radicale.tests.helpers import get_file_content
|
||||
|
||||
@@ -142,6 +144,64 @@ permissions: RrWw""")
|
||||
assert "Event" in answer
|
||||
assert "UID:event" in answer
|
||||
|
||||
def test_add_event_with_desc_ok(self) -> None:
|
||||
"""Add an event."""
|
||||
self.mkcalendar("/calendar.ics/")
|
||||
event = get_file_content("event_issue1970_ok.ics")
|
||||
path = "/calendar.ics/event_issue1970_ok.ics"
|
||||
self.put(path, event)
|
||||
_, headers, answer = self.request("GET", path, check=200)
|
||||
assert "ETag" in headers
|
||||
assert headers["Content-Type"] == "text/calendar; charset=utf-8"
|
||||
assert "DESCRIPTION" in answer
|
||||
assert "VEVENT" in answer
|
||||
assert "Event" in answer
|
||||
assert "UID:event" in answer
|
||||
|
||||
def test_add_event_with_desc_problem(self) -> None:
|
||||
"""Add an event."""
|
||||
self.mkcalendar("/calendar.ics/")
|
||||
event = get_file_content("event_issue1970_problem.ics")
|
||||
path = "/calendar.ics/event_issue1970_problem.ics"
|
||||
self.put(path, event)
|
||||
_, headers, answer = self.request("GET", path, check=200)
|
||||
assert "ETag" in headers
|
||||
assert headers["Content-Type"] == "text/calendar; charset=utf-8"
|
||||
assert "DESCRIPTION" in answer
|
||||
assert "VEVENT" in answer
|
||||
assert "Event" in answer
|
||||
assert "UID:event" in answer
|
||||
|
||||
def test_add_event_exceed_size(self) -> None:
|
||||
"""Add an event which is exceeding max-resource-size."""
|
||||
self.configure({"server": {"max_resource_size": 20}})
|
||||
self.mkcalendar("/calendar.ics/")
|
||||
event = get_file_content("event1.ics")
|
||||
path = "/calendar.ics/event1.ics"
|
||||
self.put(path, event, check=412)
|
||||
|
||||
def test_add_events_exceed_size(self) -> None:
|
||||
"""Add multipe events where last is exceeding max-resource-size."""
|
||||
self.configure({"server": {"max_resource_size": 603}})
|
||||
self.mkcalendar("/calendar.ics/")
|
||||
event = get_file_content("event_multiple3.ics")
|
||||
path = "/calendar.ics/"
|
||||
self.put(path, event, check=412)
|
||||
|
||||
def test_add_event_broken(self) -> None:
|
||||
"""Add a broken event."""
|
||||
self.mkcalendar("/calendar.ics/")
|
||||
event = get_file_content("broken-vevent.ics")
|
||||
path = "/calendar.ics/broken-vevent.ics"
|
||||
self.put(path, event, check=400)
|
||||
|
||||
def test_add_events_broken2(self) -> None:
|
||||
"""Add a broken event (2nd one is broken)."""
|
||||
self.mkcalendar("/calendar.ics/")
|
||||
event = get_file_content("broken-vevents.ics")
|
||||
path = "/calendar.ics/"
|
||||
self.put(path, event, check=400)
|
||||
|
||||
def test_add_event_without_uid(self) -> None:
|
||||
"""Add an event without UID."""
|
||||
self.mkcalendar("/calendar.ics/")
|
||||
@@ -201,6 +261,34 @@ permissions: RrWw""")
|
||||
_, answer = self.get(path)
|
||||
assert "UID:contact1" in answer
|
||||
|
||||
def test_add_contact_broken(self) -> None:
|
||||
"""Add a broken contact."""
|
||||
self.create_addressbook("/contacts.vcf/")
|
||||
contact = get_file_content("broken-vcard.vcf")
|
||||
path = "/contacts.vcf/broken-vcards.vcf"
|
||||
self.put(path, contact, check=400)
|
||||
|
||||
def test_add_contacts_broken(self) -> None:
|
||||
"""Add broken contacts."""
|
||||
self.create_addressbook("/contacts.vcf/")
|
||||
contact = get_file_content("broken-vcards.vcf")
|
||||
path = "/contacts.vcf/"
|
||||
self.put(path, contact, check=400)
|
||||
|
||||
def test_add_contacts_broken2(self) -> None:
|
||||
"""Add broken contacts (only 2nd one is broken)."""
|
||||
self.create_addressbook("/contacts.vcf/")
|
||||
contact = get_file_content("broken-vcards2.vcf")
|
||||
path = "/contacts.vcf/"
|
||||
self.put(path, contact, check=400)
|
||||
|
||||
def test_add_contacts_broken2_no_uid(self) -> None:
|
||||
"""Add broken contacts (only 2nd one is broken and has no UID)."""
|
||||
self.create_addressbook("/contacts.vcf/")
|
||||
contact = get_file_content("broken-vcards2-no_uid.vcf")
|
||||
path = "/contacts.vcf/"
|
||||
self.put(path, contact, check=400)
|
||||
|
||||
def test_add_contact_photo_with_data_uri(self) -> None:
|
||||
"""Test workaround for broken PHOTO data from InfCloud"""
|
||||
self.create_addressbook("/contacts.vcf/")
|
||||
@@ -216,6 +304,48 @@ permissions: RrWw""")
|
||||
path = "/contacts.vcf/contact.vcf"
|
||||
self.put(path, contact, check=400)
|
||||
|
||||
def test_add_contact_v3(self) -> None:
|
||||
"""Add a vCard 3.0 contact."""
|
||||
self.create_addressbook("/contacts.vcf/")
|
||||
contact = get_file_content("contact1.vcf")
|
||||
path = "/contacts.vcf/contact.vcf"
|
||||
self.put(path, contact)
|
||||
_, headers, answer = self.request("GET", path, check=200)
|
||||
assert "ETag" in headers
|
||||
assert headers["Content-Type"] == "text/vcard; charset=utf-8"
|
||||
assert "VCARD" in answer
|
||||
assert "UID:contact1" in answer
|
||||
assert "VERSION:3.0" in answer
|
||||
|
||||
@pytest.mark.skipif(not utils.vobject_supports_vcard4(),
|
||||
reason="vobject < 1.0.0 does not support vCard 4.0")
|
||||
def test_add_contact_v4(self) -> None:
|
||||
"""Add a vCard 4.0 contact (requires vobject >= 1.0.0)."""
|
||||
self.create_addressbook("/contacts.vcf/")
|
||||
contact = get_file_content("contact1_v4.vcf")
|
||||
path = "/contacts.vcf/contact.vcf"
|
||||
self.put(path, contact)
|
||||
_, headers, answer = self.request("GET", path, check=200)
|
||||
assert "ETag" in headers
|
||||
assert headers["Content-Type"] == "text/vcard; charset=utf-8"
|
||||
assert "VCARD" in answer
|
||||
assert "UID:contact1" in answer
|
||||
assert "VERSION:4.0" in answer
|
||||
|
||||
def test_add_contact_photo_with_data_uri_v3(self) -> None:
|
||||
"""Test vCard 3.0 PHOTO format"""
|
||||
self.create_addressbook("/contacts.vcf/")
|
||||
contact = get_file_content("contact_photo_with_data_uri.vcf")
|
||||
self.put("/contacts.vcf/contact.vcf", contact)
|
||||
|
||||
@pytest.mark.skipif(not utils.vobject_supports_vcard4(),
|
||||
reason="vobject < 1.0.0 does not support vCard 4.0")
|
||||
def test_add_contact_photo_with_data_uri_v4(self) -> None:
|
||||
"""Test vCard 4.0 PHOTO data URI format (requires vobject >= 1.0.0)"""
|
||||
self.create_addressbook("/contacts.vcf/")
|
||||
contact = get_file_content("contact_photo_with_data_uri_v4.vcf")
|
||||
self.put("/contacts.vcf/contact.vcf", contact)
|
||||
|
||||
def test_update_event(self) -> None:
|
||||
"""Update an event."""
|
||||
self.mkcalendar("/calendar.ics/")
|
||||
@@ -229,6 +359,56 @@ permissions: RrWw""")
|
||||
_, answer = self.get(path)
|
||||
assert "DTSTAMP:20130902T150159Z" in answer
|
||||
|
||||
def test_update_event_no_etag_strict_preconditions_true(self) -> None:
|
||||
"""Update an event without serving etag having strict_preconditions enabled (Precondition Failed)."""
|
||||
self.configure({"storage": {"strict_preconditions": True}})
|
||||
self.mkcalendar("/calendar.ics/")
|
||||
event = get_file_content("event1.ics")
|
||||
event_modified = get_file_content("event1_modified.ics")
|
||||
path = "/calendar.ics/event1.ics"
|
||||
self.put(path, event, check=201)
|
||||
self.put(path, event_modified, check=412)
|
||||
|
||||
def test_update_event_with_etag_strict_preconditions_true(self) -> None:
|
||||
"""Update an event with serving equal etag having strict_preconditions enabled (OK)."""
|
||||
self.configure({"storage": {"strict_preconditions": True}})
|
||||
self.configure({"logging": {"response_content_on_debug": True}})
|
||||
self.mkcalendar("/calendar.ics/")
|
||||
event = get_file_content("event1.ics")
|
||||
event_modified = get_file_content("event1_modified.ics")
|
||||
path = "/calendar.ics/event1.ics"
|
||||
self.put(path, event, check=201)
|
||||
# get etag
|
||||
_, responses = self.report("/calendar.ics/", """\
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<C:calendar-query xmlns:C="urn:ietf:params:xml:ns:caldav">
|
||||
<D:prop xmlns:D="DAV:">
|
||||
<D:getetag/>
|
||||
</D:prop>
|
||||
</C:calendar-query>""")
|
||||
assert len(responses) == 1
|
||||
response = responses["/calendar.ics/event1.ics"]
|
||||
assert not isinstance(response, int)
|
||||
status, prop = response["D:getetag"]
|
||||
assert status == 200 and prop.text
|
||||
self.put(path, event_modified, check=204, http_if_match=prop.text)
|
||||
|
||||
def test_update_event_with_etag_mismatch(self) -> None:
|
||||
"""Update an event with serving mismatch etag (Precondition Failed)."""
|
||||
self.mkcalendar("/calendar.ics/")
|
||||
event = get_file_content("event1.ics")
|
||||
event_modified = get_file_content("event1_modified.ics")
|
||||
path = "/calendar.ics/event1.ics"
|
||||
self.put(path, event, check=201)
|
||||
self.put(path, event_modified, check=412, http_if_match="0000")
|
||||
|
||||
def test_add_event_with_etag(self) -> None:
|
||||
"""Add an event with serving etag (Precondition Failed)."""
|
||||
self.mkcalendar("/calendar.ics/")
|
||||
event = get_file_content("event1.ics")
|
||||
path = "/calendar.ics/event1.ics"
|
||||
self.put(path, event, check=412, http_if_match="0000")
|
||||
|
||||
def test_update_event_uid_event(self) -> None:
|
||||
"""Update an event with a different UID."""
|
||||
self.mkcalendar("/calendar.ics/")
|
||||
@@ -306,6 +486,22 @@ permissions: RrWw""")
|
||||
for uid2 in uids[i + 1:]:
|
||||
assert uid1 != uid2
|
||||
|
||||
def test_add_event_tz_dtend_only(self) -> None:
|
||||
"""Add an event having TZ only on DTEND."""
|
||||
self.mkcalendar("/calendar.ics/")
|
||||
event = get_file_content("event_issue1847_1.ics")
|
||||
path = "/calendar.ics/event_issue1847_1.ics"
|
||||
self.put(path, event)
|
||||
_, headers, answer = self.request("GET", path, check=200)
|
||||
|
||||
def test_add_event_tz_dtstart_only(self) -> None:
|
||||
"""Add an event having TZ only on DTSTART."""
|
||||
self.mkcalendar("/calendar.ics/")
|
||||
event = get_file_content("event_issue1847_2.ics")
|
||||
path = "/calendar.ics/event_issue1847_2.ics"
|
||||
self.put(path, event)
|
||||
_, headers, answer = self.request("GET", path, check=200)
|
||||
|
||||
def test_verify(self) -> None:
|
||||
"""Verify the storage."""
|
||||
contacts = get_file_content("contact_multiple.vcf")
|
||||
@@ -401,6 +597,33 @@ permissions: RrWw""")
|
||||
self.get(path1, check=404)
|
||||
self.get(path2)
|
||||
|
||||
def test_move_between_collections_with_at_native(self) -> None:
|
||||
"""Move a item."""
|
||||
self.mkcalendar("/calendar1@domain.ics/")
|
||||
self.mkcalendar("/calendar2@domain.ics/")
|
||||
event = get_file_content("event1.ics")
|
||||
path1 = "/calendar1@domain.ics/event1.ics"
|
||||
path2 = "/calendar2@domain.ics/event2.ics"
|
||||
self.put(path1, event)
|
||||
self.request("MOVE", path1, check=201,
|
||||
HTTP_DESTINATION="http://127.0.0.1/"+path2)
|
||||
self.get(path1, check=404)
|
||||
self.get(path2)
|
||||
|
||||
def test_move_between_collections_with_at_encoded(self) -> None:
|
||||
"""Move a item."""
|
||||
self.mkcalendar("/calendar1@domain.ics/")
|
||||
self.mkcalendar("/calendar2@domain.ics/")
|
||||
event = get_file_content("event1.ics")
|
||||
path1 = "/calendar1@domain.ics/event1.ics"
|
||||
path2 = "/calendar2@domain.ics/event2.ics"
|
||||
path2_encoded = urllib.parse.quote(path2)
|
||||
self.put(path1, event)
|
||||
self.request("MOVE", path1, check=201,
|
||||
HTTP_DESTINATION="http://127.0.0.1/"+path2_encoded)
|
||||
self.get(path1, check=404)
|
||||
self.get(path2)
|
||||
|
||||
def test_move_between_collections_duplicate_uid(self) -> None:
|
||||
"""Move a item to a collection which already contains the UID."""
|
||||
self.mkcalendar("/calendar1.ics/")
|
||||
@@ -568,11 +791,13 @@ permissions: RrWw""")
|
||||
assert not isinstance(response, int)
|
||||
status, prop = response["D:sync-token"]
|
||||
assert status == 200 and prop.text
|
||||
assert "C:max-resource-size" not in response
|
||||
_, responses = self.propfind("/calendar.ics/event.ics", propfind)
|
||||
response = responses["/calendar.ics/event.ics"]
|
||||
assert not isinstance(response, int)
|
||||
status, prop = response["D:getetag"]
|
||||
assert status == 200 and prop.text
|
||||
assert "C:max-resource-size" not in response
|
||||
|
||||
def test_propfind_nonexistent(self) -> None:
|
||||
"""Read a property that does not exist."""
|
||||
@@ -584,6 +809,87 @@ permissions: RrWw""")
|
||||
status, prop = response["ICAL:calendar-color"]
|
||||
assert status == 404 and not prop.text
|
||||
|
||||
def test_propfind_max_resource_size(self) -> None:
|
||||
"""Read property C:max-resource-size"""
|
||||
self.mkcalendar("/calendar.ics/")
|
||||
event = get_file_content("event1.ics")
|
||||
self.put("/calendar.ics/event.ics", event)
|
||||
_, responses = self.propfind("/calendar.ics/", """\
|
||||
<?xml version="1.0"?>
|
||||
<propfind xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
||||
<prop>
|
||||
<C:max-resource-size />
|
||||
</prop>
|
||||
</propfind>""")
|
||||
response = responses["/calendar.ics/"]
|
||||
assert not isinstance(response, int)
|
||||
status, prop = response["C:max-resource-size"]
|
||||
assert status == 200 and prop.text
|
||||
|
||||
def test_propfind_getctag(self) -> None:
|
||||
"""Read property CS:getctag"""
|
||||
self.mkcalendar("/calendar.ics/")
|
||||
event = get_file_content("event1.ics")
|
||||
self.put("/calendar.ics/event.ics", event)
|
||||
_, responses = self.propfind("/calendar.ics/", """\
|
||||
<?xml version="1.0"?>
|
||||
<propfind xmlns="DAV:" xmlns:CS="http://calendarserver.org/ns/">
|
||||
<prop>
|
||||
<CS:getctag />
|
||||
</prop>
|
||||
</propfind>""")
|
||||
response = responses["/calendar.ics/"]
|
||||
assert not isinstance(response, int)
|
||||
status, prop = response["CS:getctag"]
|
||||
assert status == 200 and prop.text
|
||||
|
||||
def test_propfind_supported_address_data(self) -> None:
|
||||
"""Read property CR:supported-address-data on addressbook"""
|
||||
self.create_addressbook("/addressbook.vcf/")
|
||||
contact = get_file_content("contact1.vcf")
|
||||
self.put("/addressbook.vcf/contact.vcf", contact)
|
||||
_, responses = self.propfind("/addressbook.vcf/", """\
|
||||
<?xml version="1.0"?>
|
||||
<propfind xmlns="DAV:" xmlns:CR="urn:ietf:params:xml:ns:carddav">
|
||||
<prop>
|
||||
<CR:supported-address-data />
|
||||
</prop>
|
||||
</propfind>""")
|
||||
response = responses["/addressbook.vcf/"]
|
||||
assert not isinstance(response, int)
|
||||
status, prop = response["CR:supported-address-data"]
|
||||
assert status == 200
|
||||
# Should have at least one address-data-type element
|
||||
address_data_types = prop.findall(
|
||||
xmlutils.make_clark("CR:address-data-type"))
|
||||
assert len(address_data_types) >= 1
|
||||
# Check that 3.0 is always supported
|
||||
versions = [e.get("version") for e in address_data_types]
|
||||
assert "3.0" in versions
|
||||
# Check content-type is text/vcard for all
|
||||
for e in address_data_types:
|
||||
assert e.get("content-type") == "text/vcard"
|
||||
# If vobject >= 1.0.0, should also support 4.0
|
||||
if utils.vobject_supports_vcard4():
|
||||
assert "4.0" in versions
|
||||
# vCard 4.0 should be listed first (preferred)
|
||||
assert versions[0] == "4.0"
|
||||
|
||||
def test_propfind_supported_address_data_on_calendar(self) -> None:
|
||||
"""Read property CR:supported-address-data on calendar (should 404)"""
|
||||
self.mkcalendar("/calendar.ics/")
|
||||
_, responses = self.propfind("/calendar.ics/", """\
|
||||
<?xml version="1.0"?>
|
||||
<propfind xmlns="DAV:" xmlns:CR="urn:ietf:params:xml:ns:carddav">
|
||||
<prop>
|
||||
<CR:supported-address-data />
|
||||
</prop>
|
||||
</propfind>""")
|
||||
response = responses["/calendar.ics/"]
|
||||
assert not isinstance(response, int)
|
||||
status, prop = response["CR:supported-address-data"]
|
||||
assert status == 404
|
||||
|
||||
def test_proppatch(self) -> None:
|
||||
"""Set/Remove a property and read it back."""
|
||||
self.mkcalendar("/calendar.ics/")
|
||||
@@ -1621,7 +1927,7 @@ permissions: RrWw""")
|
||||
</C:free-busy-query>""", 400, is_xml=False)
|
||||
|
||||
def _report_sync_token(
|
||||
self, calendar_path: str, sync_token: Optional[str] = None
|
||||
self, calendar_path: str, sync_token: Optional[str] = None, **kwargs
|
||||
) -> Tuple[str, RESPONSES]:
|
||||
sync_token_xml = (
|
||||
"<sync-token><![CDATA[%s]]></sync-token>" % sync_token
|
||||
@@ -1633,7 +1939,7 @@ permissions: RrWw""")
|
||||
<getetag />
|
||||
</prop>
|
||||
%s
|
||||
</sync-collection>""" % sync_token_xml)
|
||||
</sync-collection>""" % sync_token_xml, **kwargs)
|
||||
xml = DefusedET.fromstring(answer)
|
||||
if status in (403, 409):
|
||||
assert xml.tag == xmlutils.make_clark("D:error")
|
||||
@@ -1781,6 +2087,15 @@ permissions: RrWw""")
|
||||
calendar_path, "http://radicale.org/ns/sync/INVALID")
|
||||
assert not sync_token
|
||||
|
||||
def test_report_sync_collection_invalid_sync_token_with_user(self) -> None:
|
||||
"""Test sync-collection report with an invalid sync token and user+host+useragent"""
|
||||
self.configure({"auth": {"type": "none"}})
|
||||
calendar_path = "/calendar.ics/"
|
||||
self.mkcalendar(calendar_path)
|
||||
sync_token, _ = self._report_sync_token(
|
||||
calendar_path, "http://radicale.org/ns/sync/INVALID", login="testuser:", remote_host="192.0.2.1", remote_useragent="Testclient/1.0")
|
||||
assert not sync_token
|
||||
|
||||
def test_propfind_sync_token(self) -> None:
|
||||
"""Retrieve the sync-token with a propfind request"""
|
||||
calendar_path = "/calendar.ics/"
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
# Copyright © 2017-2019 Unrud <unrud@outlook.com>
|
||||
# Copyright © 2024 Pieter Hijma <pieterhijma@users.noreply.github.com>
|
||||
# Copyright © 2025 David Greaves <david@dgreaves.com>
|
||||
# Copyright © 2025 Peter Bieringer <pb@bieringer.de>
|
||||
#
|
||||
# This library is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
@@ -512,3 +513,188 @@ permissions: RrWw""")
|
||||
status, event2_calendar_data = responses["/test/event2.ics"]["C:calendar-data"]
|
||||
assert event2_calendar_data.text
|
||||
assert "UID:c6be8b2c-3d72-453c-b698-4f25cdf1569e" in event2_calendar_data.text
|
||||
|
||||
def test_report_getetag_expand_filter(self) -> None:
|
||||
"""Test getetag with time-range filter and expand (example from #1880)."""
|
||||
self.mkcalendar("/test/")
|
||||
self.put("/test/event_issue1880_1.ics", get_file_content("event_issue1880_1.ics"))
|
||||
self.put("/test/event_issue1880_2.ics", get_file_content("event_issue1880_2.ics"))
|
||||
|
||||
request = """
|
||||
<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
||||
<D:prop>
|
||||
<D:getetag>
|
||||
<C:expand start="20250921T220000Z" end="20250928T220000Z"/>
|
||||
</D:getetag>
|
||||
</D:prop>
|
||||
<C:filter>
|
||||
<C:comp-filter name="VCALENDAR">
|
||||
<C:comp-filter name="VEVENT">
|
||||
<C:time-range start="20250921T220000Z" end="20250928T220000Z"/>
|
||||
</C:comp-filter>
|
||||
</C:comp-filter>
|
||||
</C:filter>
|
||||
</C:calendar-query>
|
||||
"""
|
||||
status, responses = self.report("/test", request)
|
||||
assert status == 207
|
||||
assert len(responses) == 2
|
||||
assert isinstance(responses["/test/event_issue1880_1.ics"], dict)
|
||||
assert isinstance(responses["/test/event_issue1880_2.ics"], dict)
|
||||
assert "D:getetag" in responses["/test/event_issue1880_1.ics"]
|
||||
assert "D:getetag" in responses["/test/event_issue1880_2.ics"]
|
||||
|
||||
def test_report_getetag_expand_filter_positive1(self) -> None:
|
||||
"""Test getetag with time-range filter and expand (not applicable), should return as matching filter range (example from #1812)."""
|
||||
self.mkcalendar("/test/")
|
||||
self.put("/test/event_issue1812_getetag.ics", get_file_content("event_issue1812_getetag.ics"))
|
||||
|
||||
request = """
|
||||
<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
||||
<D:prop>
|
||||
<D:getetag>
|
||||
<C:expand start="20250706T220000Z" end="20250713T220000Z" />
|
||||
</D:getetag>
|
||||
</D:prop>
|
||||
<C:filter>
|
||||
<C:comp-filter name="VCALENDAR">
|
||||
<C:comp-filter name="VEVENT">
|
||||
<C:time-range start="20250716T220000Z" end="20250717T220000Z" />
|
||||
</C:comp-filter>
|
||||
</C:comp-filter>
|
||||
</C:filter>
|
||||
</C:calendar-query>
|
||||
"""
|
||||
status, responses = self.report("/test", request)
|
||||
assert status == 207
|
||||
assert len(responses) == 1
|
||||
assert isinstance(responses["/test/event_issue1812_getetag.ics"], dict)
|
||||
assert "D:getetag" in responses["/test/event_issue1812_getetag.ics"]
|
||||
|
||||
def test_report_getetag_expand_filter_positive2(self) -> None:
|
||||
"""Test getetag with time-range filter and expand, should return as matching filter range (example from #1812)."""
|
||||
self.mkcalendar("/test/")
|
||||
self.put("/test/event_issue1812.ics", get_file_content("event_issue1812.ics"))
|
||||
|
||||
request = """
|
||||
<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
||||
<D:prop>
|
||||
<D:getetag>
|
||||
<C:expand start="20250706T220000Z" end="20250730T220000Z" />
|
||||
</D:getetag>
|
||||
</D:prop>
|
||||
<C:filter>
|
||||
<C:comp-filter name="VCALENDAR">
|
||||
<C:comp-filter name="VEVENT">
|
||||
<C:time-range start="20250716T220000Z" end="20250723T220000Z" />
|
||||
</C:comp-filter>
|
||||
</C:comp-filter>
|
||||
</C:filter>
|
||||
</C:calendar-query>
|
||||
"""
|
||||
status, responses = self.report("/test", request)
|
||||
assert status == 207
|
||||
assert len(responses) == 1
|
||||
assert isinstance(responses["/test/event_issue1812.ics"], dict)
|
||||
assert "D:getetag" in responses["/test/event_issue1812.ics"]
|
||||
|
||||
def test_report_getetag_expand_filter_negative1(self) -> None:
|
||||
"""Test getetag with time-range filter and expand, should not return anything (example from #1812)."""
|
||||
self.mkcalendar("/test/")
|
||||
self.put("/test/event_issue1812_getetag.ics", get_file_content("event_issue1812_getetag.ics"))
|
||||
|
||||
request = """
|
||||
<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
||||
<D:prop>
|
||||
<D:getetag>
|
||||
<C:expand start="20250706T220000Z" end="20250713T220000Z" />
|
||||
</D:getetag>
|
||||
</D:prop>
|
||||
<C:filter>
|
||||
<C:comp-filter name="VCALENDAR">
|
||||
<C:comp-filter name="VEVENT">
|
||||
<C:time-range start="20250706T220000Z" end="20250713T220000Z" />
|
||||
</C:comp-filter>
|
||||
</C:comp-filter>
|
||||
</C:filter>
|
||||
</C:calendar-query>
|
||||
"""
|
||||
status, responses = self.report("/test", request)
|
||||
assert status == 207
|
||||
assert len(responses) == 0
|
||||
|
||||
def test_report_getetag_expand_filter_negative2(self) -> None:
|
||||
"""Test getetag with time-range filter and expand, should not return anything (example from #1812)."""
|
||||
self.mkcalendar("/test/")
|
||||
self.put("/test/event_issue1812_getetag.ics", get_file_content("event_issue1812_getetag.ics"))
|
||||
|
||||
request = """
|
||||
<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
||||
<D:prop>
|
||||
<D:getetag />
|
||||
<C:calendar-data>
|
||||
<C:expand start="20240706T220000Z" end="20240713T220000Z" />
|
||||
</C:calendar-data>
|
||||
</D:prop>
|
||||
<C:filter>
|
||||
<C:comp-filter name="VCALENDAR">
|
||||
<C:comp-filter name="VEVENT">
|
||||
<C:time-range start="20250706T220000Z" end="20250713T220000Z" />
|
||||
</C:comp-filter>
|
||||
</C:comp-filter>
|
||||
</C:filter>
|
||||
</C:calendar-query>
|
||||
"""
|
||||
status, responses = self.report("/test", request)
|
||||
assert status == 207
|
||||
assert len(responses) == 0
|
||||
|
||||
def test_report_getetag_expand_filter_negative3(self) -> None:
|
||||
"""Test getetag with time-range filter and expand, should not return anything (example from #1812)."""
|
||||
self.mkcalendar("/test/")
|
||||
self.put("/test/event_issue1812_getetag.ics", get_file_content("event_issue1812_getetag.ics"))
|
||||
|
||||
request = """
|
||||
<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
||||
<D:prop>
|
||||
<C:calendar-data>
|
||||
<C:expand start="20240706T220000Z" end="20240713T220000Z" />
|
||||
</C:calendar-data>
|
||||
</D:prop>
|
||||
<C:filter>
|
||||
<C:comp-filter name="VCALENDAR">
|
||||
<C:comp-filter name="VEVENT">
|
||||
<C:time-range start="20250706T220000Z" end="20250713T220000Z" />
|
||||
</C:comp-filter>
|
||||
</C:comp-filter>
|
||||
</C:filter>
|
||||
</C:calendar-query>
|
||||
"""
|
||||
status, responses = self.report("/test", request)
|
||||
assert status == 207
|
||||
assert len(responses) == 0
|
||||
|
||||
def test_report_getetag_expand_filter_negative4(self) -> None:
|
||||
"""Test getetag with time-range filter and expand, nothing returned as filter is not matching (example from #1812)."""
|
||||
self.mkcalendar("/test/")
|
||||
self.put("/test/event_issue1812.ics", get_file_content("event_issue1812.ics"))
|
||||
|
||||
request = """
|
||||
<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
||||
<D:prop>
|
||||
<D:getetag>
|
||||
<C:expand start="20250706T220000Z" end="20250730T220000Z" />
|
||||
</D:getetag>
|
||||
</D:prop>
|
||||
<C:filter>
|
||||
<C:comp-filter name="VCALENDAR">
|
||||
<C:comp-filter name="VEVENT">
|
||||
<C:time-range start="20240716T220000Z" end="20240723T220000Z" />
|
||||
</C:comp-filter>
|
||||
</C:comp-filter>
|
||||
</C:filter>
|
||||
</C:calendar-query>
|
||||
"""
|
||||
status, responses = self.report("/test", request)
|
||||
assert status == 207
|
||||
assert len(responses) == 0
|
||||
|
||||
@@ -21,6 +21,8 @@ Radicale tests related to hook 'email'
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from radicale.tests import BaseTest
|
||||
from radicale.tests.helpers import get_file_content
|
||||
@@ -63,11 +65,26 @@ permissions: RrWw""")
|
||||
self.configure({"hook": {"type": "email",
|
||||
"dryrun": "True"}})
|
||||
|
||||
def test_add_event(self, caplog) -> None:
|
||||
def _future_date_timestamp(self) -> str:
|
||||
"""Return a date timestamp for a future date."""
|
||||
future_date = datetime.now() + timedelta(days=1)
|
||||
return future_date.strftime("%Y%m%dT%H%M%S")
|
||||
|
||||
def _past_date_timestamp(self) -> str:
|
||||
past_date = datetime.now() - timedelta(days=1)
|
||||
return past_date.strftime("%Y%m%dT%H%M%S")
|
||||
|
||||
def _replace_end_date_in_event(self, event: str, new_date: str) -> str:
|
||||
"""Replace the end date in an event string."""
|
||||
return re.sub(r"DTEND;TZID=Europe/Paris:\d{8}T\d{6}",
|
||||
f"DTEND;TZID=Europe/Paris:{new_date}", event)
|
||||
|
||||
def test_add_event_with_future_end_date(self, caplog) -> None:
|
||||
caplog.set_level(logging.WARNING)
|
||||
"""Add an event."""
|
||||
self.mkcalendar("/calendar.ics/")
|
||||
event = get_file_content("event1.ics")
|
||||
event = self._replace_end_date_in_event(event, self._future_date_timestamp())
|
||||
path = "/calendar.ics/event1.ics"
|
||||
self.put(path, event)
|
||||
_, headers, answer = self.request("GET", path, check=200)
|
||||
@@ -76,35 +93,68 @@ permissions: RrWw""")
|
||||
assert "VEVENT" in answer
|
||||
assert "Event" in answer
|
||||
assert "UID:event" in answer
|
||||
found = 0
|
||||
for line in caplog.messages:
|
||||
if line.find("notification_item: {'type': 'upsert'") != -1:
|
||||
found = found | 1
|
||||
if line.find("to_addresses=['janedoe@example.com']") != -1:
|
||||
found = found | 2
|
||||
if line.find("to_addresses=['johndoe@example.com']") != -1:
|
||||
found = found | 4
|
||||
if (found != 7):
|
||||
raise ValueError("Logging misses expected log lines, found=%d", found)
|
||||
|
||||
def test_delete_event(self, caplog) -> None:
|
||||
logs = caplog.messages
|
||||
# Should have a log saying the notification item was received
|
||||
assert len([log for log in logs if "received notification_item: {'type': 'upsert'," in log]) == 1
|
||||
# Should NOT have a log saying that no email is sent (email won't actually be sent due to dryrun)
|
||||
assert len([log for log in logs if "skipping notification for event: event1" in log]) == 0
|
||||
|
||||
def test_add_event_with_past_end_date(self, caplog) -> None:
|
||||
caplog.set_level(logging.WARNING)
|
||||
"""Add an event."""
|
||||
self.mkcalendar("/calendar.ics/")
|
||||
event = get_file_content("event1.ics")
|
||||
event = self._replace_end_date_in_event(event, self._past_date_timestamp())
|
||||
path = "/calendar.ics/event1.ics"
|
||||
self.put(path, event)
|
||||
_, headers, answer = self.request("GET", path, check=200)
|
||||
assert "ETag" in headers
|
||||
assert headers["Content-Type"] == "text/calendar; charset=utf-8"
|
||||
assert "VEVENT" in answer
|
||||
assert "Event" in answer
|
||||
assert "UID:event" in answer
|
||||
|
||||
logs = caplog.messages
|
||||
# Should have a log saying the notification item was received
|
||||
assert len([log for log in logs if "received notification_item: {'type': 'upsert'," in log]) == 1
|
||||
# Should have a log saying that no email is sent due to past end date
|
||||
assert len([log for log in logs if "Event end time is in the past, skipping notification for event: event1" in log]) == 1
|
||||
|
||||
def test_delete_event_with_future_end_date(self, caplog) -> None:
|
||||
caplog.set_level(logging.WARNING)
|
||||
"""Delete an event."""
|
||||
self.mkcalendar("/calendar.ics/")
|
||||
event = get_file_content("event1.ics")
|
||||
event = self._replace_end_date_in_event(event, self._future_date_timestamp())
|
||||
path = "/calendar.ics/event1.ics"
|
||||
self.put(path, event)
|
||||
_, responses = self.delete(path)
|
||||
assert responses[path] == 200
|
||||
_, answer = self.get("/calendar.ics/")
|
||||
assert "VEVENT" not in answer
|
||||
found = 0
|
||||
for line in caplog.messages:
|
||||
if line.find("notification_item: {'type': 'delete'") != -1:
|
||||
found = found | 1
|
||||
if line.find("to_addresses=['janedoe@example.com']") != -1:
|
||||
found = found | 2
|
||||
if line.find("to_addresses=['johndoe@example.com']") != -1:
|
||||
found = found | 4
|
||||
if (found != 7):
|
||||
raise ValueError("Logging misses expected log lines, found=%d", found)
|
||||
|
||||
logs = caplog.messages
|
||||
# Should have a log saying the notification item was received
|
||||
assert len([log for log in logs if "received notification_item: {'type': 'delete'," in log]) == 1
|
||||
# Should NOT have a log saying that no email is sent (email won't actually be sent due to dryrun)
|
||||
assert len([log for log in logs if "skipping notification for event: event1" in log]) == 0
|
||||
|
||||
def test_delete_event_with_past_end_date(self, caplog) -> None:
|
||||
caplog.set_level(logging.WARNING)
|
||||
"""Delete an event."""
|
||||
self.mkcalendar("/calendar.ics/")
|
||||
event = get_file_content("event1.ics")
|
||||
event = self._replace_end_date_in_event(event, self._past_date_timestamp())
|
||||
path = "/calendar.ics/event1.ics"
|
||||
self.put(path, event)
|
||||
_, responses = self.delete(path)
|
||||
assert responses[path] == 200
|
||||
_, answer = self.get("/calendar.ics/")
|
||||
assert "VEVENT" not in answer
|
||||
|
||||
logs = caplog.messages
|
||||
# Should have a log saying the notification item was received
|
||||
assert len([log for log in logs if "received notification_item: {'type': 'delete'," in log]) == 1
|
||||
# Should have a log saying that no email is sent due to past end date
|
||||
assert len([log for log in logs if "Event end time is in the past, skipping notification for event: event1" in log]) == 1
|
||||
|
||||
@@ -22,6 +22,8 @@ Radicale tests related to hook 'rabbitmq'
|
||||
import logging
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from radicale.tests import BaseTest
|
||||
from radicale.tests.helpers import get_file_content
|
||||
|
||||
@@ -29,6 +31,14 @@ from radicale.tests.helpers import get_file_content
|
||||
class TestHooks(BaseTest):
|
||||
"""Tests with hooks."""
|
||||
|
||||
# test for available pika module
|
||||
try:
|
||||
import pika
|
||||
except ImportError:
|
||||
has_pika = 0
|
||||
else:
|
||||
has_pika = 1
|
||||
|
||||
def setup_method(self) -> None:
|
||||
BaseTest.setup_method(self)
|
||||
rights_file_path = os.path.join(self.colpath, "rights")
|
||||
@@ -63,6 +73,7 @@ permissions: RrWw""")
|
||||
self.configure({"hook": {"type": "rabbitmq",
|
||||
"dryrun": "True"}})
|
||||
|
||||
@pytest.mark.skipif(has_pika == 0, reason="No pika module installed")
|
||||
def test_add_event(self, caplog) -> None:
|
||||
caplog.set_level(logging.WARNING)
|
||||
"""Add an event."""
|
||||
@@ -83,6 +94,7 @@ permissions: RrWw""")
|
||||
if (found is False):
|
||||
raise ValueError("Logging misses expected log line")
|
||||
|
||||
@pytest.mark.skipif(has_pika == 0, reason="No pika module installed")
|
||||
def test_delete_event(self, caplog) -> None:
|
||||
caplog.set_level(logging.WARNING)
|
||||
"""Delete an event."""
|
||||
|
||||
91
radicale/tests/test_pathutils.py
Normal file
91
radicale/tests/test_pathutils.py
Normal file
@@ -0,0 +1,91 @@
|
||||
# This file is part of Radicale - CalDAV and CardDAV server
|
||||
# Copyright © 2025 Tobias Brox <tobias@tobix.eu>
|
||||
#
|
||||
# This library is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This library is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Radicale. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
"""
|
||||
Tests for pathutils module.
|
||||
|
||||
"""
|
||||
|
||||
import gc
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
from radicale import pathutils
|
||||
|
||||
|
||||
class TestPathToFilesystem:
|
||||
"""Tests for path_to_filesystem function."""
|
||||
|
||||
@pytest.mark.filterwarnings("error::ResourceWarning")
|
||||
@pytest.mark.filterwarnings("error::pytest.PytestUnraisableExceptionWarning")
|
||||
def test_scandir_iterator_closed(self) -> None:
|
||||
"""Verify that os.scandir iterator is properly closed.
|
||||
|
||||
This test catches ResourceWarning: unclosed scandir iterator
|
||||
which occurs when os.scandir() is used without a context manager.
|
||||
See: https://github.com/Kozea/Radicale/issues/1972
|
||||
|
||||
The ResourceWarning is emitted during garbage collection when an
|
||||
unclosed scandir iterator is finalized. We use pytest.mark.filterwarnings
|
||||
to convert both ResourceWarning and PytestUnraisableExceptionWarning
|
||||
to errors.
|
||||
"""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
# Create a subdirectory so path_to_filesystem has something
|
||||
# to scan (the scandir check is for case-insensitive filesystems)
|
||||
subdir = os.path.join(tmpdir, "testdir")
|
||||
os.makedirs(subdir)
|
||||
|
||||
# Call path_to_filesystem - if scandir iterator is not closed,
|
||||
# a ResourceWarning will be emitted during garbage collection
|
||||
result = pathutils.path_to_filesystem(tmpdir, "testdir")
|
||||
assert result == subdir
|
||||
|
||||
# Force garbage collection to trigger any ResourceWarning
|
||||
# from unclosed iterators
|
||||
gc.collect()
|
||||
|
||||
def test_path_to_filesystem_basic(self) -> None:
|
||||
"""Test basic path_to_filesystem functionality."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
# Test empty path
|
||||
result = pathutils.path_to_filesystem(tmpdir, "")
|
||||
assert result == tmpdir
|
||||
|
||||
# Test single component
|
||||
subdir = os.path.join(tmpdir, "test")
|
||||
os.makedirs(subdir)
|
||||
result = pathutils.path_to_filesystem(tmpdir, "test")
|
||||
assert result == subdir
|
||||
|
||||
# Test nested path
|
||||
nested = os.path.join(subdir, "nested")
|
||||
os.makedirs(nested)
|
||||
result = pathutils.path_to_filesystem(tmpdir, "test/nested")
|
||||
assert result == nested
|
||||
|
||||
def test_unsafe_path_raises(self) -> None:
|
||||
"""Test that unsafe path components raise UnsafePathError."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
# Hidden files (starting with .) are not safe
|
||||
with pytest.raises(pathutils.UnsafePathError):
|
||||
pathutils.path_to_filesystem(tmpdir, ".hidden")
|
||||
|
||||
# Backup files (ending with ~) are not safe
|
||||
with pytest.raises(pathutils.UnsafePathError):
|
||||
pathutils.path_to_filesystem(tmpdir, "backup~")
|
||||
@@ -1,5 +1,6 @@
|
||||
# This file is part of Radicale - CalDAV and CardDAV server
|
||||
# Copyright © 2020 Unrud <unrud@outlook.com>
|
||||
# Copyright © 2020-2023 Unrud <unrud@outlook.com>
|
||||
# Copyright © 2024-2025 Peter Bieringer <pb@bieringer.de>
|
||||
#
|
||||
# This library is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
@@ -20,7 +21,7 @@ from typing import (Any, Callable, ContextManager, Iterator, List, Mapping,
|
||||
runtime_checkable)
|
||||
|
||||
WSGIResponseHeaders = Union[Mapping[str, str], Sequence[Tuple[str, str]]]
|
||||
WSGIResponse = Tuple[int, WSGIResponseHeaders, Union[None, str, bytes]]
|
||||
WSGIResponse = Tuple[int, WSGIResponseHeaders, Union[None, str, bytes], Union[None, str]]
|
||||
WSGIEnviron = Mapping[str, Any]
|
||||
WSGIStartResponse = Callable[[str, List[Tuple[str, str]]], Any]
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# Copyright © 2014 Jean-Marc Martins
|
||||
# Copyright © 2012-2017 Guillaume Ayoub
|
||||
# Copyright © 2017-2018 Unrud <unrud@outlook.com>
|
||||
# Copyright © 2024-2025 Peter Bieringer <pb@bieringer.de>
|
||||
# Copyright © 2024-2026 Peter Bieringer <pb@bieringer.de>
|
||||
#
|
||||
# This library is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
@@ -21,9 +21,14 @@ import datetime
|
||||
import os
|
||||
import ssl
|
||||
import sys
|
||||
import textwrap
|
||||
from hashlib import sha256
|
||||
from importlib import import_module, metadata
|
||||
from string import ascii_letters, digits, punctuation
|
||||
from typing import Callable, Sequence, Tuple, Type, TypeVar, Union
|
||||
|
||||
from packaging.version import Version
|
||||
|
||||
from radicale import config
|
||||
from radicale.log import logger
|
||||
|
||||
@@ -47,8 +52,18 @@ ADDRESS_TYPE = Union[Tuple[Union[str, bytes, bytearray], int],
|
||||
Tuple[str, int, int, int]]
|
||||
|
||||
|
||||
# Max YEAR in datetime in unixtime
|
||||
# Max/Min YEAR in datetime in unixtime
|
||||
DATETIME_MAX_UNIXTIME: int = (datetime.MAXYEAR - 1970) * 365 * 24 * 60 * 60
|
||||
DATETIME_MIN_UNIXTIME: int = (datetime.MINYEAR - 1970) * 365 * 24 * 60 * 60
|
||||
|
||||
|
||||
# Number units
|
||||
UNIT_g: int = (1000 * 1000 * 1000)
|
||||
UNIT_m: int = (1000 * 1000)
|
||||
UNIT_k: int = (1000)
|
||||
UNIT_G: int = (1024 * 1024 * 1024)
|
||||
UNIT_M: int = (1024 * 1024)
|
||||
UNIT_K: int = (1024)
|
||||
|
||||
|
||||
def load_plugin(internal_types: Sequence[str], module_name: str,
|
||||
@@ -72,9 +87,48 @@ def load_plugin(internal_types: Sequence[str], module_name: str,
|
||||
|
||||
|
||||
def package_version(name):
|
||||
if name == "passlib":
|
||||
# passlib(libpass) requires special handling as module name is unchanged, but metadata has new name
|
||||
import passlib
|
||||
return passlib.__version__
|
||||
return metadata.version(name)
|
||||
|
||||
|
||||
def vobject_supports_vcard4() -> bool:
|
||||
"""Check if vobject supports vCard 4.0 (requires version >= 1.0.0)."""
|
||||
try:
|
||||
version = package_version("vobject")
|
||||
parts = version.split(".")
|
||||
major = int(parts[0])
|
||||
return major >= 1
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def passlib_libpass_supports_bcrypt() -> Tuple[bool, str]:
|
||||
"""Check if passlib(libpass) version supports bcrypt version."""
|
||||
info = ""
|
||||
try:
|
||||
version_bcrypt = package_version("bcrypt")
|
||||
version_bcrypt_check = "5.0.0"
|
||||
version_passlib = package_version("passlib")
|
||||
version_passlib_check = "1.9.3"
|
||||
if Version(version_bcrypt) >= Version(version_bcrypt_check):
|
||||
# bcrypt >= 5.0.0 has issues with passlib(libpass) < 1.9.3
|
||||
if Version(version_passlib) < Version(version_passlib_check):
|
||||
info = "bcrypt module version %r >= %r and passlib(libpass) module version %r < %r found => incompatible, downgrade bcrypt or upgrade passlib(libpass)" % (version_bcrypt, version_bcrypt_check, version_passlib, version_passlib_check)
|
||||
return (False, info)
|
||||
else:
|
||||
info = "bcrypt module version %r >= %r and passlib(libpass) module version %r >= %r found => ok" % (version_bcrypt, version_bcrypt_check, version_passlib, version_passlib_check)
|
||||
return (True, info)
|
||||
else:
|
||||
info = "bcrypt module version %r < %r and passlib(libpass) module version %r found => ok" % (version_bcrypt, version_bcrypt_check, version_passlib)
|
||||
return (True, info)
|
||||
except Exception:
|
||||
info = "bcrypt module version or passlib(libpass) module version %r not found => problem"
|
||||
return (False, info)
|
||||
|
||||
|
||||
def packages_version():
|
||||
versions = []
|
||||
versions.append("python=%s.%s.%s" % (sys.version_info[0], sys.version_info[1], sys.version_info[2]))
|
||||
@@ -226,25 +280,49 @@ def ssl_get_protocols(context):
|
||||
return protocols
|
||||
|
||||
|
||||
def unknown_if_empty(value):
|
||||
if value == "":
|
||||
return "UNKNOWN"
|
||||
else:
|
||||
return value
|
||||
|
||||
|
||||
def user_groups_as_string():
|
||||
if sys.platform != "win32":
|
||||
euid = os.geteuid()
|
||||
egid = os.getegid()
|
||||
try:
|
||||
username = pwd.getpwuid(euid)[0]
|
||||
user = "%s(%d)" % (unknown_if_empty(username), euid)
|
||||
except Exception:
|
||||
# name of user not found
|
||||
s = "user=(%d) group=(%d)" % (euid, egid)
|
||||
return s
|
||||
gids = os.getgrouplist(username, egid)
|
||||
user = "UNKNOWN(%d)" % euid
|
||||
|
||||
egid = os.getegid()
|
||||
groups = []
|
||||
for gid in gids:
|
||||
try:
|
||||
gids = os.getgrouplist(username, egid)
|
||||
for gid in gids:
|
||||
try:
|
||||
gi = grp.getgrgid(gid)
|
||||
groups.append("%s(%d)" % (unknown_if_empty(gi.gr_name), gid))
|
||||
except Exception:
|
||||
groups.append("UNKNOWN(%d)" % gid)
|
||||
except Exception:
|
||||
try:
|
||||
gi = grp.getgrgid(gid)
|
||||
groups.append("%s(%d)" % (gi.gr_name, gid))
|
||||
groups.append("%s(%d)" % (grp.getgrnam(egid)[0], egid))
|
||||
except Exception:
|
||||
groups.append("%s(%d)" % (gid, gid))
|
||||
s = "user=%s(%d) groups=%s" % (username, euid, ','.join(groups))
|
||||
# workaround to get groupid by name
|
||||
groups_all = grp.getgrall()
|
||||
found = False
|
||||
for entry in groups_all:
|
||||
if entry[2] == egid:
|
||||
groups.append("%s(%d)" % (unknown_if_empty(entry[0]), egid))
|
||||
found = True
|
||||
break
|
||||
if not found:
|
||||
groups.append("UNKNOWN(%d)" % egid)
|
||||
|
||||
s = "user=%s groups=%s" % (user, ','.join(groups))
|
||||
else:
|
||||
username = os.getlogin()
|
||||
s = "user=%s" % (username)
|
||||
@@ -255,12 +333,175 @@ def format_ut(unixtime: int) -> str:
|
||||
if sys.platform == "win32":
|
||||
# TODO check how to support this better
|
||||
return str(unixtime)
|
||||
if unixtime < DATETIME_MAX_UNIXTIME:
|
||||
if unixtime <= DATETIME_MIN_UNIXTIME:
|
||||
r = str(unixtime) + "(<=MIN:" + str(DATETIME_MIN_UNIXTIME) + ")"
|
||||
elif unixtime >= DATETIME_MAX_UNIXTIME:
|
||||
r = str(unixtime) + "(>=MAX:" + str(DATETIME_MAX_UNIXTIME) + ")"
|
||||
else:
|
||||
if sys.version_info < (3, 11):
|
||||
dt = datetime.datetime.utcfromtimestamp(unixtime)
|
||||
else:
|
||||
dt = datetime.datetime.fromtimestamp(unixtime, datetime.UTC)
|
||||
r = str(unixtime) + "(" + dt.strftime('%Y-%m-%dT%H:%M:%SZ') + ")"
|
||||
else:
|
||||
r = str(unixtime) + "(>MAX:" + str(DATETIME_MAX_UNIXTIME) + ")"
|
||||
return r
|
||||
|
||||
|
||||
def format_unit(value: float, binary: bool = False) -> str:
|
||||
if binary:
|
||||
if value > UNIT_G:
|
||||
value = value / UNIT_G
|
||||
unit = "G"
|
||||
elif value > UNIT_M:
|
||||
value = value / UNIT_M
|
||||
unit = "M"
|
||||
elif value > UNIT_K:
|
||||
value = value / UNIT_K
|
||||
unit = "K"
|
||||
else:
|
||||
unit = ""
|
||||
else:
|
||||
if value > UNIT_g:
|
||||
value = value / UNIT_g
|
||||
unit = "g"
|
||||
elif value > UNIT_m:
|
||||
value = value / UNIT_m
|
||||
unit = "m"
|
||||
elif value > UNIT_k:
|
||||
value = value / UNIT_k
|
||||
unit = "k"
|
||||
else:
|
||||
unit = ""
|
||||
return ("%.1f %s" % (value, unit))
|
||||
|
||||
|
||||
def limit_str(content: str, limit: int) -> str:
|
||||
length = len(content)
|
||||
if limit > 0 and length >= limit:
|
||||
return content[:limit] + ("...(shortened because original length %d > limit %d)" % (length, limit))
|
||||
else:
|
||||
return content
|
||||
|
||||
|
||||
def textwrap_str(content: str, limit: int = 2000) -> str:
|
||||
# TODO: add support for config option and prefix
|
||||
return textwrap.indent(limit_str(content, limit), " ", lambda line: True)
|
||||
|
||||
|
||||
def dataToHex(data, count):
|
||||
result = ''
|
||||
for item in range(count):
|
||||
if ((item > 0) and ((item % 8) == 0)):
|
||||
result += ' '
|
||||
if (item < len(data)):
|
||||
result += '%02x' % data[item] + ' '
|
||||
else:
|
||||
result += ' '
|
||||
return result
|
||||
|
||||
|
||||
def dataToAscii(data, count):
|
||||
result = ''
|
||||
for item in range(count):
|
||||
if (item < len(data)):
|
||||
char = chr(data[item])
|
||||
if char in ascii_letters or \
|
||||
char in digits or \
|
||||
char in punctuation or \
|
||||
char == ' ':
|
||||
result += char
|
||||
else:
|
||||
result += '.'
|
||||
return result
|
||||
|
||||
|
||||
def dataToSpecial(data, count):
|
||||
result = ''
|
||||
for item in range(count):
|
||||
if (item < len(data)):
|
||||
char = chr(data[item])
|
||||
if char == '\r':
|
||||
result += 'C'
|
||||
elif char == '\n':
|
||||
result += 'L'
|
||||
elif (ord(char) & 0xf8) == 0xf0: # assuming UTF-8
|
||||
result += '4'
|
||||
elif (ord(char) & 0xf0) == 0xf0: # assuming UTF-8
|
||||
result += '3'
|
||||
elif (ord(char) & 0xe0) == 0xe0: # assuming UTF-8
|
||||
result += '2'
|
||||
else:
|
||||
result += '.'
|
||||
return result
|
||||
|
||||
|
||||
def hexdump_str(content: str, limit: int = 2000) -> str:
|
||||
result = "Hexdump of string: index <bytes> | <ASCII> | <CTRL: C=CR L=LF 2/3/4=UTF-8-length> |\n"
|
||||
index = 0
|
||||
size = 16
|
||||
bytestring = content.encode("utf-8") # assuming UTF-8
|
||||
length = len(bytestring)
|
||||
|
||||
while (index < length) and (index < limit):
|
||||
data = bytestring[index:index+size]
|
||||
hex = dataToHex(data, size)
|
||||
ascii = dataToAscii(data, size)
|
||||
special = dataToSpecial(data, size)
|
||||
result += '%08x ' % index
|
||||
result += hex
|
||||
result += '|'
|
||||
result += '%-16s' % ascii
|
||||
result += '|'
|
||||
result += '%-16s' % special
|
||||
result += '|'
|
||||
result += '\n'
|
||||
index += size
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def hexdump_line(line: str, limit: int = 200) -> str:
|
||||
result = ""
|
||||
length_str = len(line)
|
||||
bytestring = line.encode("utf-8") # assuming UTF-8
|
||||
length = len(bytestring)
|
||||
size = length
|
||||
if (size > limit):
|
||||
size = limit
|
||||
|
||||
hex = dataToHex(bytestring, size)
|
||||
ascii = dataToAscii(bytestring, size)
|
||||
special = dataToSpecial(bytestring, size)
|
||||
result += '%3d/%3d' % (length_str, length)
|
||||
result += ': '
|
||||
result += hex
|
||||
result += '|'
|
||||
result += ascii
|
||||
result += '|'
|
||||
result += special
|
||||
result += '|'
|
||||
result += '\n'
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def hexdump_lines(lines: str, limit: int = 200) -> str:
|
||||
result = "Hexdump of lines: nr chars/bytes: <bytes> | <ASCII> | <CTRL: C=CR L=LF 2/3/4=UTF-8-length> |\n"
|
||||
counter = 0
|
||||
for line in lines.splitlines(True):
|
||||
result += '% 4d ' % counter
|
||||
result += hexdump_line(line)
|
||||
counter += 1
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def sha256_str(content: str) -> str:
|
||||
_hash = sha256()
|
||||
_hash.update(content.encode("utf-8")) # assuming UTF-8
|
||||
return _hash.hexdigest()
|
||||
|
||||
|
||||
def sha256_bytes(content: bytes) -> str:
|
||||
_hash = sha256()
|
||||
_hash.update(content)
|
||||
return _hash.hexdigest()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# This file is part of Radicale - CalDAV and CardDAV server
|
||||
# Copyright © 2017-2018 Unrud <unrud@outlook.com>
|
||||
# Copyright © 2017-2022 Unrud <unrud@outlook.com>
|
||||
# Copyright © 2025-2025 Peter Bieringer <pb@bieringer.de>
|
||||
#
|
||||
# This library is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
@@ -32,4 +33,4 @@ class Web(web.BaseWeb):
|
||||
assert pathutils.sanitize_path(path) == path
|
||||
if path != "/.web":
|
||||
return httputils.redirect(base_prefix + "/.web")
|
||||
return client.OK, {"Content-Type": "text/plain"}, "Radicale works!"
|
||||
return client.OK, {"Content-Type": "text/plain"}, "Radicale works!", None
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
# Copyright © 2008 Nicolas Kandel
|
||||
# Copyright © 2008 Pascal Halter
|
||||
# Copyright © 2008-2015 Guillaume Ayoub
|
||||
# Copyright © 2017-2018 Unrud <unrud@outlook.com>
|
||||
# Copyright © 2017-2021 Unrud <unrud@outlook.com>
|
||||
# Copyright © 2025-2025 Peter Bieringer <pb@bieringer.de>
|
||||
#
|
||||
# This library is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
@@ -26,7 +27,7 @@ import copy
|
||||
import xml.etree.ElementTree as ET
|
||||
from collections import OrderedDict
|
||||
from http import client
|
||||
from typing import Dict, Mapping, Optional
|
||||
from typing import Dict, Mapping, Optional, Union
|
||||
from urllib.parse import quote
|
||||
|
||||
from radicale import item, pathutils
|
||||
@@ -56,7 +57,7 @@ for short, url in NAMESPACES.items():
|
||||
ET.register_namespace("" if short == "D" else short, url)
|
||||
|
||||
|
||||
def pretty_xml(element: ET.Element) -> str:
|
||||
def pretty_xml(element: Union[ET.Element, None]) -> str:
|
||||
"""Indent an ElementTree ``element`` and its children."""
|
||||
def pretty_xml_recursive(element: ET.Element, level: int) -> None:
|
||||
indent = "\n" + level * " "
|
||||
@@ -71,6 +72,9 @@ def pretty_xml(element: ET.Element) -> str:
|
||||
sub_element.tail = indent
|
||||
elif level > 0 and not (element.tail or "").strip():
|
||||
element.tail = indent
|
||||
|
||||
if element is None:
|
||||
return ""
|
||||
element = copy.deepcopy(element)
|
||||
pretty_xml_recursive(element, 0)
|
||||
return '<?xml version="1.0"?>\n%s' % ET.tostring(element, "unicode")
|
||||
|
||||
@@ -29,13 +29,31 @@ skip_install = True
|
||||
|
||||
[tool:isort]
|
||||
known_standard_library = _dummy_thread,_thread,abc,aifc,argparse,array,ast,asynchat,asyncio,asyncore,atexit,audioop,base64,bdb,binascii,binhex,bisect,builtins,bz2,cProfile,calendar,cgi,cgitb,chunk,cmath,cmd,code,codecs,codeop,collections,colorsys,compileall,concurrent,configparser,contextlib,contextvars,copy,copyreg,crypt,csv,ctypes,curses,dataclasses,datetime,dbm,decimal,difflib,dis,distutils,doctest,dummy_threading,email,encodings,ensurepip,enum,errno,faulthandler,fcntl,filecmp,fileinput,fnmatch,formatter,fpectl,fractions,ftplib,functools,gc,getopt,getpass,gettext,glob,grp,gzip,hashlib,heapq,hmac,html,http,imaplib,imghdr,imp,importlib,inspect,io,ipaddress,itertools,json,keyword,lib2to3,linecache,locale,logging,lzma,macpath,mailbox,mailcap,marshal,math,mimetypes,mmap,modulefinder,msilib,msvcrt,multiprocessing,netrc,nis,nntplib,ntpath,numbers,operator,optparse,os,ossaudiodev,parser,pathlib,pdb,pickle,pickletools,pipes,pkgutil,platform,plistlib,poplib,posix,posixpath,pprint,profile,pstats,pty,pwd,py_compile,pyclbr,pydoc,queue,quopri,random,re,readline,reprlib,resource,rlcompleter,runpy,sched,secrets,select,selectors,shelve,shlex,shutil,signal,site,smtpd,smtplib,sndhdr,socket,socketserver,spwd,sqlite3,sre,sre_compile,sre_constants,sre_parse,ssl,stat,statistics,string,stringprep,struct,subprocess,sunau,symbol,symtable,sys,sysconfig,syslog,tabnanny,tarfile,telnetlib,tempfile,termios,test,textwrap,threading,time,timeit,tkinter,token,tokenize,trace,traceback,tracemalloc,tty,turtle,turtledemo,types,typing,unicodedata,unittest,urllib,uu,uuid,venv,warnings,wave,weakref,webbrowser,winreg,winsound,wsgiref,xdrlib,xml,xmlrpc,zipapp,zipfile,zipimport,zlib
|
||||
known_third_party = defusedxml,passlib,pkg_resources,pytest,vobject
|
||||
known_third_party = defusedxml,libpass,pkg_resources,pytest,vobject
|
||||
|
||||
[flake8]
|
||||
# Only enable default tests (https://github.com/PyCQA/flake8/issues/790#issuecomment-812823398)
|
||||
# DNE: DOES-NOT-EXIST
|
||||
select = E,F,W,C90,DNE000
|
||||
ignore = E121,E123,E126,E226,E24,E704,W503,W504,DNE000,E501
|
||||
ignore = E121,E123,E126,E226,E24,E704,W503,W504,DNE000,E501,E261
|
||||
exclude = .git,
|
||||
__pycache__,
|
||||
build,
|
||||
dist,
|
||||
*.egg,
|
||||
*.egg-info,
|
||||
*.eggs,
|
||||
*.pyc,
|
||||
*.pyo,
|
||||
*.pyd,
|
||||
.tox,
|
||||
venv,
|
||||
venv3,
|
||||
.venv,
|
||||
.venv3,
|
||||
.env,
|
||||
.mypy_cache,
|
||||
.pytest_cache
|
||||
extend-exclude = build
|
||||
|
||||
[mypy]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# This file is part of Radicale - CalDAV and CardDAV server
|
||||
# Copyright © 2009-2017 Guillaume Ayoub
|
||||
# Copyright © 2017-2018 Unrud <unrud@outlook.com>
|
||||
# Copyright © 2024-2025 Peter Bieringer <pb@bieringer.de>
|
||||
# Copyright © 2024-2026 Peter Bieringer <pb@bieringer.de>
|
||||
#
|
||||
# This library is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
@@ -20,7 +20,7 @@ from setuptools import find_packages, setup
|
||||
|
||||
# When the version is updated, a new section in the CHANGELOG.md file must be
|
||||
# added too.
|
||||
VERSION = "3.5.5.dev"
|
||||
VERSION = "3.6.1.dev"
|
||||
|
||||
with open("README.md", encoding="utf-8") as f:
|
||||
long_description = f.read()
|
||||
@@ -36,9 +36,11 @@ web_files = ["web/internal_data/css/icon.png",
|
||||
"web/internal_data/fn.js",
|
||||
"web/internal_data/index.html"]
|
||||
|
||||
install_requires = ["defusedxml", "passlib", "vobject>=0.9.6",
|
||||
# Hint: if bcyrpt < 5.0.0 is used, passlib(libpass) dependency can be downgraded/reverted by: sed -i 's|libpass[^"]*|passlib|' setup.py.legacy
|
||||
install_requires = ["defusedxml", "libpass>=1.9.3", "vobject>=0.9.6",
|
||||
"pika>=1.1.0",
|
||||
"requests",
|
||||
"packaging",
|
||||
]
|
||||
bcrypt_requires = ["bcrypt"]
|
||||
argon2_requires = ["argon2-cffi"]
|
||||
|
||||
Reference in New Issue
Block a user