Merge branch 'master' of github.com:metallerok/Radicale into overridden_recurrence_validation

This commit is contained in:
Georgiy
2026-02-05 19:30:50 +03:00
78 changed files with 4807 additions and 974 deletions

View 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

View File

@@ -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

View File

@@ -1,7 +1,7 @@
name: PyPI publish
on:
release:
types: [published]
types: [released]
jobs:
publish:

View File

@@ -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

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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
View 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
View File

@@ -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

View File

@@ -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"

View File

@@ -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
}
}

View 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" )
}

View File

@@ -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

View File

@@ -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
View 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

View File

@@ -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

View File

@@ -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()

View File

@@ -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)

View File

@@ -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:

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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())
)

View File

@@ -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:

View 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", ""), ""

View File

@@ -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 ""

View File

@@ -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):

View File

@@ -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",

View File

@@ -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

View File

@@ -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

View File

@@ -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(

View File

@@ -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."""

View File

@@ -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)

View File

@@ -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

View File

@@ -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)")

View File

@@ -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

View File

@@ -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

View File

@@ -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))

View File

@@ -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:

View File

@@ -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:

View File

@@ -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

View 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

View 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

View 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

View 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

View File

@@ -0,0 +1,7 @@
BEGIN:VCARD
VERSION:4.0
UID:contact1
N:Contact;;;;
FN:Contact
NICKNAME:test
END:VCARD

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View File

@@ -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"}})

View File

@@ -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/"

View File

@@ -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

View File

@@ -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

View File

@@ -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."""

View 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~")

View File

@@ -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]

View File

@@ -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()

View File

@@ -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

View File

@@ -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")

View File

@@ -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]

View File

@@ -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"]