Merge branch 'master' of github.com:metallerok/Radicale into recurrence_all_day_comparsion
This commit is contained in:
41
.github/workflows/docker-nightly-cleanup.yml
vendored
Normal file
41
.github/workflows/docker-nightly-cleanup.yml
vendored
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
name: Cleanup old nightly docker images
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '10 0 * * *'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
delete-package-versions:
|
||||||
|
name: Cleanup old nightly docker images
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Get list of all docker image versions in registry
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
gh api --paginate -X GET "/orgs/Kozea/packages/container/Radicale/versions" -F package_type=container -F per_page=200 > data.json
|
||||||
|
|
||||||
|
- name: Delete each nightly image older than cutoff date
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
cutoff_date=$(date --date="30 days ago" --iso-8601)
|
||||||
|
echo "Cutoff date is: $cutoff_date"
|
||||||
|
|
||||||
|
# Loop through each nightly image version (tag) older than the cutoff date
|
||||||
|
jq --arg cutoff_date "$cutoff_date" -r '.[] | select((.metadata.container.tags | any(. | contains("nightly"))) and (.created_at < $cutoff_date)) | [.metadata.container.tags[], .id] | @tsv' data.json | while IFS=$'\t' read -r tag nightly_image_id ; do
|
||||||
|
echo "Tag - $tag"
|
||||||
|
|
||||||
|
# Because of multi-platform, manifest for each tag would contain more than 1 image. Loop through all
|
||||||
|
all_digests=$(docker manifest inspect "ghcr.io/kozea/radicale:${tag}" | jq -r 'if .manifests then .manifests[]?.digest else empty end')
|
||||||
|
for digest in $all_digests; do
|
||||||
|
image_id=$(jq -r --arg digest "$digest" '.[] | select(.name == $digest) | .id' data.json)
|
||||||
|
echo "Deleting $image_id"
|
||||||
|
gh api -X DELETE "/orgs/Kozea/packages/container/Radicale/versions/$image_id"
|
||||||
|
done
|
||||||
|
# Now that all dependents are deleted, delete this tag
|
||||||
|
echo "Deleting $tag with ID: $nightly_image_id"
|
||||||
|
gh api -X DELETE "/orgs/Kozea/packages/container/Radicale/versions/$nightly_image_id"
|
||||||
|
done
|
||||||
21
.github/workflows/docker-publish.yml
vendored
21
.github/workflows/docker-publish.yml
vendored
@@ -2,13 +2,13 @@ name: Build and publish Docker image
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
release:
|
release:
|
||||||
types: [published]
|
types: [released]
|
||||||
schedule:
|
schedule:
|
||||||
- cron: '0 0 * * *'
|
- cron: '0 0 * * *'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
REGISTRY: ghcr.io
|
GHCR_REGISTRY: ghcr.io
|
||||||
IMAGE_NAME: ${{ github.repository }}
|
IMAGE_NAME: ${{ github.repository }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -22,23 +22,34 @@ jobs:
|
|||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Log in to the Container registry
|
- name: Log in to the ghcr container registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.GHCR_REGISTRY }}
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
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
|
- name: Extract metadata for Docker build
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
images: |
|
||||||
|
name=${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
name=${{ env.IMAGE_NAME }}
|
||||||
flavor: latest=true
|
flavor: latest=true
|
||||||
tags: |
|
tags: |
|
||||||
type=semver,pattern={{version}}
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{major}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
type=schedule,prefix=nightly-,pattern={{date 'YYYYMMDD'}}
|
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 == 'workflow_dispatch' }},value=workflow_dispatch-{{branch}}-{{sha}}
|
||||||
|
type=raw,enable=${{ github.event_name == 'release' }},value=stable
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|||||||
2
.github/workflows/pypi-publish.yml
vendored
2
.github/workflows/pypi-publish.yml
vendored
@@ -1,7 +1,7 @@
|
|||||||
name: PyPI publish
|
name: PyPI publish
|
||||||
on:
|
on:
|
||||||
release:
|
release:
|
||||||
types: [published]
|
types: [released]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
publish:
|
publish:
|
||||||
|
|||||||
105
.github/workflows/test.yml
vendored
105
.github/workflows/test.yml
vendored
@@ -2,14 +2,105 @@ name: Test
|
|||||||
on: [push, pull_request]
|
on: [push, pull_request]
|
||||||
|
|
||||||
jobs:
|
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:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
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:
|
exclude:
|
||||||
- os: windows-latest
|
- 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 }}
|
runs-on: ${{ matrix.os }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@@ -19,7 +110,7 @@ jobs:
|
|||||||
- name: Install Test dependencies
|
- name: Install Test dependencies
|
||||||
run: pip install tox
|
run: pip install tox
|
||||||
- name: Test
|
- name: Test
|
||||||
run: tox -e py
|
run: tox -c pyproject.toml -e py
|
||||||
- name: Install Coveralls
|
- name: Install Coveralls
|
||||||
if: github.event_name == 'push'
|
if: github.event_name == 'push'
|
||||||
run: pip install coveralls
|
run: pip install coveralls
|
||||||
@@ -31,7 +122,7 @@ jobs:
|
|||||||
run: coveralls --service=github
|
run: coveralls --service=github
|
||||||
|
|
||||||
coveralls-finish:
|
coveralls-finish:
|
||||||
needs: test
|
needs: coveralls-test
|
||||||
if: github.event_name == 'push'
|
if: github.event_name == 'push'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
@@ -51,8 +142,8 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-python@v5
|
- uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: '3.12'
|
python-version: '3.13'
|
||||||
- name: Install tox
|
- name: Install tox
|
||||||
run: pip install tox
|
run: pip install tox
|
||||||
- name: Lint
|
- name: Lint
|
||||||
run: tox -e flake8,mypy,isort
|
run: tox -c pyproject.toml -e flake8,mypy,isort
|
||||||
|
|||||||
61
CHANGELOG.md
61
CHANGELOG.md
@@ -1,6 +1,61 @@
|
|||||||
# Changelog
|
# 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
|
* Improve: [auth] ldap: do not read server info by bind to avoid needless network traffic
|
||||||
* Fix: [storage] broken support of 'folder_umask'
|
* Fix: [storage] broken support of 'folder_umask'
|
||||||
* Improve: add details about platform and effective user on startup
|
* 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
|
* 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
|
* 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)
|
* 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
|
## 3.5.4
|
||||||
* Improve: item filter enhanced for 3rd level supporting VALARM and honoring TRIGGER (offset or absolute)
|
* 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: Using icalendar's tzinfo on created datetime to fix issue with icalendar
|
||||||
* Fix: typos in code
|
* Fix: typos in code
|
||||||
* Enhancement: Added free-busy report
|
* 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: remove unexpected control codes from uploaded items
|
||||||
* Enhancement: add 'strip_domain' setting for username handling
|
* Enhancement: add 'strip_domain' setting for username handling
|
||||||
* Enhancement: add option to toggle debug log of rights rule with doesn't match
|
* Enhancement: add option to toggle debug log of rights rule with doesn't match
|
||||||
|
|||||||
1163
DOCUMENTATION.md
1163
DOCUMENTATION.md
File diff suppressed because it is too large
Load Diff
@@ -19,14 +19,12 @@ WORKDIR /app
|
|||||||
|
|
||||||
RUN addgroup -g 1000 radicale \
|
RUN addgroup -g 1000 radicale \
|
||||||
&& adduser radicale --home /var/lib/radicale --system --uid 1000 --disabled-password -G 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
|
COPY --chown=radicale:radicale --from=builder /app/venv /app
|
||||||
|
|
||||||
# Persistent storage for data
|
# Persistent storage for data
|
||||||
VOLUME /var/lib/radicale
|
VOLUME /var/lib/radicale
|
||||||
# TCP port of Radicale
|
|
||||||
EXPOSE 5232
|
|
||||||
# Run Radicale
|
# Run Radicale
|
||||||
ENTRYPOINT [ "/app/bin/python", "/app/bin/radicale"]
|
ENTRYPOINT [ "/app/bin/python", "/app/bin/radicale"]
|
||||||
CMD ["--hosts", "0.0.0.0:5232,[::]:5232"]
|
CMD ["--hosts", "0.0.0.0:5232,[::]:5232"]
|
||||||
|
|||||||
25
compose.yaml
Normal file
25
compose.yaml
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
name: Radicale
|
||||||
|
services:
|
||||||
|
radicale:
|
||||||
|
image: ghcr.io/kozea/radicale:stable
|
||||||
|
ports:
|
||||||
|
- 5232:5232
|
||||||
|
volumes:
|
||||||
|
- config:/etc/radicale
|
||||||
|
- data:/var/lib/radicale
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
config:
|
||||||
|
name: radicale-config
|
||||||
|
driver: local
|
||||||
|
driver_opts:
|
||||||
|
type: none
|
||||||
|
o: bind
|
||||||
|
device: ./config
|
||||||
|
data:
|
||||||
|
name: radicale-data
|
||||||
|
driver: local
|
||||||
|
driver_opts:
|
||||||
|
type: none
|
||||||
|
o: bind
|
||||||
|
device: ./data
|
||||||
116
config
116
config
@@ -21,10 +21,15 @@
|
|||||||
# Max parallel connections
|
# Max parallel connections
|
||||||
#max_connections = 8
|
#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
|
# In case of using a reverse proxy in front of check also there related option
|
||||||
#max_content_length = 100000000
|
#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)
|
# Socket timeout (seconds)
|
||||||
#timeout = 30
|
#timeout = 30
|
||||||
|
|
||||||
@@ -63,7 +68,7 @@
|
|||||||
[auth]
|
[auth]
|
||||||
|
|
||||||
# Authentication method
|
# 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
|
#type = denyall
|
||||||
|
|
||||||
# Cache logins for until expiration time
|
# Cache logins for until expiration time
|
||||||
@@ -75,46 +80,54 @@
|
|||||||
## Expiration time of caching failed logins in seconds
|
## Expiration time of caching failed logins in seconds
|
||||||
#cache_failed_logins_expiry = 90
|
#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
|
# URI to the LDAP server
|
||||||
#ldap_uri = ldap://localhost
|
#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##
|
#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##
|
#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
|
#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
|
#ldap_secret_file = /run/secrets/ldap_password
|
||||||
|
|
||||||
# the attribute to read the group memberships from in the user's LDAP entry (default: not set)
|
# Filter to search for the LDAP entry of the user to authenticate. It must contain '{0}' as placeholder for the login name.
|
||||||
#ldap_groups_attribute = memberOf
|
|
||||||
|
|
||||||
# The filter to find the DN of the user. This filter must contain a python-style placeholder for the login
|
|
||||||
#ldap_filter = (&(objectClass=person)(uid={0}))
|
#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
|
#ldap_user_attribute = cn
|
||||||
|
|
||||||
# Use ssl on the ldap connection
|
# Use ssl on the LDAP connection (DEPRECATED - use 'ldap_security'!)
|
||||||
# Soon to be deprecated, use ldap_security instead
|
|
||||||
#ldap_use_ssl = False
|
#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
|
#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
|
#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 =
|
#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)
|
# Connection type for dovecot authentication (AF_UNIX|AF_INET|AF_INET6)
|
||||||
# Note: credentials are transmitted in cleartext
|
# Note: credentials are transmitted in cleartext
|
||||||
#dovecot_connection_type = AF_UNIX
|
#dovecot_connection_type = AF_UNIX
|
||||||
@@ -128,6 +141,10 @@
|
|||||||
# Port of via network exposed dovecot socket
|
# Port of via network exposed dovecot socket
|
||||||
#dovecot_port = 12345
|
#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
|
# IMAP server hostname
|
||||||
# Syntax: address | address:port | [address]:port | imap.server.tld
|
# Syntax: address | address:port | [address]:port | imap.server.tld
|
||||||
#imap_host = localhost
|
#imap_host = localhost
|
||||||
@@ -169,6 +186,9 @@
|
|||||||
# Strip domain name from username
|
# Strip domain name from username
|
||||||
#strip_domain = False
|
#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]
|
[rights]
|
||||||
|
|
||||||
@@ -185,8 +205,6 @@
|
|||||||
# Permit overwrite of a collection (global)
|
# Permit overwrite of a collection (global)
|
||||||
#permit_overwrite_collection = True
|
#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]
|
[storage]
|
||||||
|
|
||||||
@@ -229,6 +247,9 @@
|
|||||||
# Skip broken item instead of triggering an exception
|
# Skip broken item instead of triggering an exception
|
||||||
#skip_broken_item = True
|
#skip_broken_item = True
|
||||||
|
|
||||||
|
# Strict preconditions check on PUT
|
||||||
|
#strict_preconditions = False
|
||||||
|
|
||||||
# Command that is run after changes to storage, default is emtpy
|
# Command that is run after changes to storage, default is emtpy
|
||||||
# Supported placeholders:
|
# Supported placeholders:
|
||||||
# %(user)s: logged-in user
|
# %(user)s: logged-in user
|
||||||
@@ -247,18 +268,31 @@
|
|||||||
#
|
#
|
||||||
# json format:
|
# json format:
|
||||||
#
|
#
|
||||||
# {
|
# predefined_collections = {
|
||||||
# "def-addressbook": {
|
# "def-personal-addressbook": {
|
||||||
# "D:displayname": "Personal Address Book",
|
# "D:displayname": "Personal Address Book",
|
||||||
# "tag": "VADDRESSBOOK"
|
# "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",
|
# "C:supported-calendar-component-set": "VEVENT,VJOURNAL,VTODO",
|
||||||
# "D:displayname": "Personal Calendar",
|
# "D:displayname": "Personal Calendar",
|
||||||
# "tag": "VCALENDAR"
|
# "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 =
|
#predefined_collections =
|
||||||
|
|
||||||
|
|
||||||
@@ -296,6 +330,9 @@
|
|||||||
# Log request content on level=debug
|
# Log request content on level=debug
|
||||||
#request_content_on_debug = False
|
#request_content_on_debug = False
|
||||||
|
|
||||||
|
# Log response header on level=debug
|
||||||
|
#response_header_on_debug = False
|
||||||
|
|
||||||
# Log response content on level=debug
|
# Log response content on level=debug
|
||||||
#response_content_on_debug = False
|
#response_content_on_debug = False
|
||||||
|
|
||||||
@@ -305,6 +342,26 @@
|
|||||||
# Log storage cache actions on level=debug
|
# Log storage cache actions on level=debug
|
||||||
#storage_cache_actions_on_debug = False
|
#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]
|
[headers]
|
||||||
|
|
||||||
# Additional HTTP headers
|
# Additional HTTP headers
|
||||||
@@ -334,10 +391,13 @@
|
|||||||
#smtp_password =
|
#smtp_password =
|
||||||
#from_email =
|
#from_email =
|
||||||
#mass_email = False
|
#mass_email = False
|
||||||
|
#new_or_added_to_event_template =
|
||||||
|
#deleted_or_removed_from_event_template =
|
||||||
|
#updated_event_template =
|
||||||
|
|
||||||
|
|
||||||
[reporting]
|
[reporting]
|
||||||
|
|
||||||
# When returning a free-busy report, limit the number of returned
|
# 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
|
#max_freebusy_occurrence = 10000
|
||||||
|
|||||||
@@ -59,6 +59,9 @@
|
|||||||
|
|
||||||
ProxyPass http://localhost:5232/ retry=0
|
ProxyPass http://localhost:5232/ retry=0
|
||||||
ProxyPassReverse http://localhost:5232/
|
ProxyPassReverse http://localhost:5232/
|
||||||
|
<IfVersion >= 2.4.40>
|
||||||
|
Proxy100Continue Off
|
||||||
|
</IfVersion>
|
||||||
|
|
||||||
Require local
|
Require local
|
||||||
<IfDefine RADICALE_PERMIT_PUBLIC_ACCESS>
|
<IfDefine RADICALE_PERMIT_PUBLIC_ACCESS>
|
||||||
@@ -74,6 +77,9 @@
|
|||||||
|
|
||||||
ProxyPass http://localhost:5232/ retry=0
|
ProxyPass http://localhost:5232/ retry=0
|
||||||
ProxyPassReverse http://localhost:5232/
|
ProxyPassReverse http://localhost:5232/
|
||||||
|
<IfVersion >= 2.4.40>
|
||||||
|
Proxy100Continue Off
|
||||||
|
</IfVersion>
|
||||||
|
|
||||||
<IfDefine !RADICALE_SERVER_USER_AUTHENTICATION>
|
<IfDefine !RADICALE_SERVER_USER_AUTHENTICATION>
|
||||||
## User authentication handled by "radicale"
|
## 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
|
ProxyPass http://localhost:5232/ retry=0
|
||||||
ProxyPassReverse http://localhost:5232/
|
ProxyPassReverse http://localhost:5232/
|
||||||
|
<IfVersion >= 2.4.40>
|
||||||
|
Proxy100Continue Off
|
||||||
|
</IfVersion>
|
||||||
|
|
||||||
Require local
|
Require local
|
||||||
<IfDefine RADICALE_PERMIT_PUBLIC_ACCESS>
|
<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
|
ProxyPass http://localhost:5232/ retry=0
|
||||||
ProxyPassReverse http://localhost:5232/
|
ProxyPassReverse http://localhost:5232/
|
||||||
|
<IfVersion >= 2.4.40>
|
||||||
|
Proxy100Continue Off
|
||||||
|
</IfVersion>
|
||||||
|
|
||||||
<IfDefine !RADICALE_SERVER_USER_AUTHENTICATION>
|
<IfDefine !RADICALE_SERVER_USER_AUTHENTICATION>
|
||||||
## User authentication handled by "radicale"
|
## User authentication handled by "radicale"
|
||||||
|
|||||||
@@ -16,11 +16,17 @@ caldav.example.com {
|
|||||||
not path /.web/*
|
not path /.web/*
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# disable this in case authentication is handled by Radicale
|
||||||
basic_auth @not-webui {
|
basic_auth @not-webui {
|
||||||
USER HASH
|
USER HASH
|
||||||
}
|
}
|
||||||
|
|
||||||
reverse_proxy localhost:5232 {
|
reverse_proxy localhost:5232 {
|
||||||
|
# disable this in case authentication is handled by Radicale
|
||||||
header_up X-Remote-User {http.auth.user.id}
|
header_up X-Remote-User {http.auth.user.id}
|
||||||
|
# replace "HOST" with configured hostname of URL (FQDN) in client
|
||||||
|
header_up Host HOST
|
||||||
|
# replace "PORT" with configured port of URL in client
|
||||||
|
header_up X-Forwarded-Port PORT
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
6
contrib/lighttpd/radicale.conf
Normal file
6
contrib/lighttpd/radicale.conf
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
server.modules += ( "mod_proxy" , "mod_setenv" )
|
||||||
|
|
||||||
|
$HTTP["url"] =~ "^/radicale/" {
|
||||||
|
proxy.server = ( "" => (( "host" => "127.0.0.1", "port" => "5232" )) )
|
||||||
|
setenv.add-request-header = ( "X-Script-Name" => "/radicale" )
|
||||||
|
}
|
||||||
@@ -1,20 +1,34 @@
|
|||||||
# This file is related to Radicale - CalDAV and CardDAV server
|
# This file is related to Radicale - CalDAV and CardDAV server
|
||||||
# for logwatch (script)
|
# for logwatch (script)
|
||||||
# Copyright © 2024-2024 Peter Bieringer <pb@bieringer.de>
|
# Copyright © 2024-2025 Peter Bieringer <pb@bieringer.de>
|
||||||
#
|
#
|
||||||
# Detail levels
|
# Detail levels
|
||||||
# >= 5: Logins
|
# < 5 : Request + ResponseCounters
|
||||||
# >= 10: ResponseTimes
|
# >= 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;
|
$Detail = $ENV{'LOGWATCH_DETAIL_LEVEL'} || 0;
|
||||||
|
|
||||||
|
my %ResponseTimesLocUsr;
|
||||||
|
my %ResponseSizesLocUsr;
|
||||||
my %ResponseTimes;
|
my %ResponseTimes;
|
||||||
|
my %ResponseSizes;
|
||||||
my %Responses;
|
my %Responses;
|
||||||
my %Requests;
|
my %Requests;
|
||||||
|
my %UserAgents;
|
||||||
my %Logins;
|
my %Logins;
|
||||||
my %Loglevel;
|
my %Loglevel;
|
||||||
my %OtherEvents;
|
my %OtherEvents;
|
||||||
|
|
||||||
|
my %Locations;
|
||||||
|
my %LocationsFile;
|
||||||
|
my %LoginsHash;
|
||||||
|
|
||||||
my $sum;
|
my $sum;
|
||||||
my $length;
|
my $length;
|
||||||
|
|
||||||
@@ -26,7 +40,7 @@ sub ResponseTimesMinMaxSum($$) {
|
|||||||
|
|
||||||
if (! defined $ResponseTimes{$req}->{'min'}) {
|
if (! defined $ResponseTimes{$req}->{'min'}) {
|
||||||
$ResponseTimes{$req}->{'min'} = $time;
|
$ResponseTimes{$req}->{'min'} = $time;
|
||||||
} elsif ($ResponseTimes->{$req}->{'min'} > $time) {
|
} elsif ($ResponseTimes{$req}->{'min'} > $time) {
|
||||||
$ResponseTimes{$req}->{'min'} = $time;
|
$ResponseTimes{$req}->{'min'} = $time;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,6 +53,28 @@ sub ResponseTimesMinMaxSum($$) {
|
|||||||
$ResponseTimes{$req}->{'sum'} += $time;
|
$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($) {
|
sub Sum($) {
|
||||||
my $phash = $_[0];
|
my $phash = $_[0];
|
||||||
my $sum = 0;
|
my $sum = 0;
|
||||||
@@ -57,6 +93,64 @@ sub MaxLength($) {
|
|||||||
return $length;
|
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>)) {
|
while (defined($ThisLine = <STDIN>)) {
|
||||||
# count loglevel
|
# count loglevel
|
||||||
if ( $ThisLine =~ /\[(DEBUG|INFO|WARNING|ERROR|CRITICAL)\] /o ) {
|
if ( $ThisLine =~ /\[(DEBUG|INFO|WARNING|ERROR|CRITICAL)\] /o ) {
|
||||||
@@ -72,34 +166,94 @@ while (defined($ThisLine = <STDIN>)) {
|
|||||||
}
|
}
|
||||||
elsif ( $ThisLine =~ / (\S+) response status/o ) {
|
elsif ( $ThisLine =~ / (\S+) response status/o ) {
|
||||||
my $req = $1;
|
my $req = $1;
|
||||||
if ( $ThisLine =~ / \S+ response status for .* with depth '(\d)' in ([0-9.]+) seconds: (\d+)/o ) {
|
if ( $ThisLine =~ / \S+ response status for (.*) with depth '(\d)' in ([0-9.]+) seconds: (\d+)/o ) {
|
||||||
$req .= ":D=" . $1 . ":R=" . $3;
|
$req .= ":D=" . $2 . ":R=" . $4;
|
||||||
|
$req .= ConvertLoc($1) if ($Detail >= 20);
|
||||||
ResponseTimesMinMaxSum($req, $2) if ($Detail >= 10);
|
ResponseTimesMinMaxSum($req, $2) if ($Detail >= 10);
|
||||||
} elsif ( $ThisLine =~ / \S+ response status for .* in ([0-9.]+) seconds: (\d+)/ ) {
|
} elsif ( $ThisLine =~ / \S+ response status for (.*) in ([0-9.]+) seconds: (\d+)/o ) {
|
||||||
$req .= ":R=" . $2;
|
$req .= ":R=" . $3;
|
||||||
|
$req .= ConvertLoc($1) if ($Detail >= 20);
|
||||||
ResponseTimesMinMaxSum($req, $1) if ($Detail >= 10);
|
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}++;
|
$Responses{$req}++;
|
||||||
}
|
}
|
||||||
elsif ( $ThisLine =~ / (\S+) request for/o ) {
|
elsif ( $ThisLine =~ / (\S+) request for ('[^']+')/o ) {
|
||||||
my $req = $1;
|
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 .= ":D=" . $1;
|
||||||
}
|
}
|
||||||
|
$req .= ConvertLoc($loc) if ($Detail >= 20);
|
||||||
$Requests{$req}++;
|
$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 ) {
|
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}++;
|
$OtherEvents{$1}++;
|
||||||
}
|
}
|
||||||
elsif ( $ThisLine =~ / (Failed login attempt) /o ) {
|
elsif ( $ThisLine =~ / (Failed login attempt) /o ) {
|
||||||
$OtherEvents{$1}++;
|
$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 ) {
|
elsif ( $ThisLine =~ /\[(DEBUG|INFO)\] /o ) {
|
||||||
# skip if DEBUG+INFO
|
# skip if DEBUG+INFO
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
# Report any unmatched entries...
|
# 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
|
$ThisLine =~ s/^\[\d+(\/Thread-\d+)?\] //; # remove process/Thread ID
|
||||||
chomp($ThisLine);
|
chomp($ThisLine);
|
||||||
$OtherList{$ThisLine}++;
|
$OtherList{$ThisLine}++;
|
||||||
@@ -114,64 +268,105 @@ if ($Started) {
|
|||||||
if (keys %Loglevel) {
|
if (keys %Loglevel) {
|
||||||
$sum = Sum(\%Loglevel);
|
$sum = Sum(\%Loglevel);
|
||||||
print "\n**Loglevel counters**\n";
|
print "\n**Loglevel counters**\n";
|
||||||
printf "%-18s | %7s | %5s |\n", "Loglevel", "cnt", "ratio";
|
printf "%-18s | %7s | %9s |\n", "Loglevel", "cnt", "ratio";
|
||||||
print "-" x38 . "\n";
|
print "-" x42 . "\n";
|
||||||
foreach my $level (sort keys %Loglevel) {
|
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";
|
print "-" x42 . "\n";
|
||||||
printf "%-18s | %7d | %3d%% |\n", "", $sum, 100;
|
printf "%-18s | %7d | %7.3f%% |\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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (keys %Logins) {
|
if (keys %Logins) {
|
||||||
$sum = Sum(\%Logins);
|
$sum = Sum(\%Logins);
|
||||||
$length = MaxLength(\%Logins);
|
$length = MaxLength(\%Logins);
|
||||||
print "\n**Successful login counters**\n";
|
print "\n**Successful login counters**\n";
|
||||||
printf "%-" . $length . "s | %7s | %5s |\n", "Login", "cnt", "ratio";
|
printf "%-" . $length . "s | %7s | %9s |\n", "Login", "cnt", "ratio";
|
||||||
print "-" x($length + 20) . "\n";
|
print "-" x($length + 24) . "\n";
|
||||||
foreach my $login (sort keys %Logins) {
|
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";
|
print "-" x($length + 24) . "\n";
|
||||||
printf "%-" . $length . "s | %7d | %3d%% |\n", "", $sum, 100;
|
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) {
|
if (keys %ResponseTimes) {
|
||||||
print "\n**Response timings (counts, seconds) (D=<depth> R=<result>)**\n";
|
$length = MaxLength(\%ResponseTimes);
|
||||||
printf "%-18s | %7s | %7s | %7s | %7s |\n", "Response", "cnt", "min", "max", "avg";
|
print "\n**Response timings (counts, seconds) (D=<depth> R=<result> F=<flags>)**\n";
|
||||||
print "-" x60 . "\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) {
|
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}->{'cnt'}
|
||||||
, $ResponseTimes{$req}->{'min'}
|
, $ResponseTimes{$req}->{'min'}
|
||||||
, $ResponseTimes{$req}->{'max'}
|
, $ResponseTimes{$req}->{'max'}
|
||||||
, $ResponseTimes{$req}->{'sum'} / $ResponseTimes{$req}->{'cnt'};
|
, $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) {
|
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);
|
exit(0);
|
||||||
|
|
||||||
# vim: shiftwidth=3 tabstop=3 syntax=perl et smartindent
|
# vim: shiftwidth=3 tabstop=3 syntax=perl et smartindent
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ rewrite ^/.well-known/caldav /radicale/ redirect;
|
|||||||
|
|
||||||
## Base URI: /radicale/
|
## Base URI: /radicale/
|
||||||
location /radicale/ {
|
location /radicale/ {
|
||||||
proxy_pass http://localhost:5232/;
|
proxy_pass http://localhost:5232;
|
||||||
proxy_set_header X-Script-Name /radicale;
|
proxy_set_header X-Script-Name /radicale;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Host $host;
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
@@ -20,7 +20,7 @@ location /radicale/ {
|
|||||||
|
|
||||||
## Base URI: /
|
## Base URI: /
|
||||||
#location / {
|
#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-For $proxy_add_x_forwarded_for;
|
||||||
# proxy_set_header X-Forwarded-Host $host;
|
# proxy_set_header X-Forwarded-Host $host;
|
||||||
# proxy_set_header X-Forwarded-Port $server_port;
|
# proxy_set_header X-Forwarded-Port $server_port;
|
||||||
|
|||||||
288
docs/DOCUMENTATION.te.md
Normal file
288
docs/DOCUMENTATION.te.md
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
> Last updated: 2025-10-20 by [@gowtham1412-p](https://github.com/gowtham1412-p)
|
||||||
|
|
||||||
|
> Based on commit: [4fdc78760914040d5f74ece8978013b8836a712e] of [DOCUMENTATION.md](https://github.com/Kozea/Radicale/blob/master/DOCUMENTATION.md)
|
||||||
|
|
||||||
|
\# డాక్యుమెంటేషన్
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
\## ప్రారంభించడం
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
\#### రాడికేల్ గురించి
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
రాడికేల్ అనేది ఒక చిన్న కానీ శక్తివంతమైన CalDAV (క్యాలెండర్లు, చేయవలసిన జాబితాలు) మరియు CardDAV
|
||||||
|
|
||||||
|
(పరిచయాలు) సర్వర్, ఇది:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
\* CalDAV, CardDAV మరియు HTTP ద్వారా క్యాలెండర్లు మరియు పరిచయ జాబితాలను పంచుకుంటుంది.
|
||||||
|
|
||||||
|
\* ఈవెంట్లు, టోడోలు, జర్నల్ ఎంట్రీలు మరియు వ్యాపార కార్డులకు మద్దతు ఇస్తుంది.
|
||||||
|
|
||||||
|
\* బాక్స్ వెలుపల పనిచేస్తుంది, సంక్లిష్టమైన సెటప్ లేదా కాన్ఫిగరేషన్ అవసరం లేదు.
|
||||||
|
|
||||||
|
\* సౌకర్యవంతమైన ప్రామాణీకరణ ఎంపికలను అందిస్తుంది.
|
||||||
|
|
||||||
|
\* అధికారం ద్వారా యాక్సెస్ను పరిమితం చేయవచ్చు.
|
||||||
|
|
||||||
|
\* TLSతో కనెక్షన్లను సురక్షితం చేయవచ్చు.
|
||||||
|
|
||||||
|
\* చాలా మందితో పనిచేస్తుంది
|
||||||
|
|
||||||
|
\[CalDAV మరియు CardDAV క్లయింట్లు](#సపోర్టెడ్-క్లయింట్లు).
|
||||||
|
|
||||||
|
\* ఫైల్ సిస్టమ్లోని అన్ని డేటాను సాధారణ ఫోల్డర్ నిర్మాణంలో నిల్వ చేస్తుంది.
|
||||||
|
|
||||||
|
\* ప్లగిన్లతో పొడిగించవచ్చు.
|
||||||
|
|
||||||
|
\* GPLv3-లైసెన్స్ పొందిన ఉచిత సాఫ్ట్వేర్.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
\#### ఇన్స్టాలేషన్
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
తనిఖీ చేయండి
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
\* \[ట్యుటోరియల్స్](#ట్యుటోరియల్స్)
|
||||||
|
|
||||||
|
\* \[డాక్యుమెంటేషన్](#డాక్యుమెంటేషన్-1)
|
||||||
|
|
||||||
|
\* \[GitHubలో వికీ](https://github.com/Kozea/Radicale/wiki)
|
||||||
|
|
||||||
|
\* \[GitHubలో చర్చలు](https://github.com/Kozea/Radicale/discussions)
|
||||||
|
|
||||||
|
\* \[GitHubలో తెరిచి ఉన్న మరియు ఇప్పటికే మూసివేయబడిన సమస్యలు](https://github.com/Kozea/Radicale/issues?q=is%3Aissue)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
\#### కొత్తగా ఏముంది?
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
\[GitHubలో చేంజ్లాగ్](https://github.com/Kozea/Radicale/blob/master/CHANGELOG.md) చదవండి.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
\## ట్యుటోరియల్స్
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
\### 5 నిమిషాల సులభమైన సెటప్
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
మీరు Radicaleని ప్రయత్నించాలనుకుంటున్నారా కానీ మీ క్యాలెండర్లో 5 నిమిషాలు మాత్రమే ఖాళీగా ఉందా?
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
ఇప్పుడే వెళ్లి Radicaleతో కొంచెం ఆడుదాం!
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
ఈ విభాగం నుండి సెట్టింగ్లతో కాన్ఫిగర్ చేయబడిన సర్వర్, localhost
|
||||||
|
|
||||||
|
కి మాత్రమే బైండ్ అవుతుంది (అంటే ఇది నెట్వర్క్ ద్వారా చేరుకోలేరు), మరియు మీరు ఏదైనా వినియోగదారు పేరు మరియు పాస్వర్డ్తో లాగిన్ అవ్వవచ్చు.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
ప్రతిదీ పనిచేసినప్పుడు, మీరు స్థానిక \[client](#supported-clients)
|
||||||
|
|
||||||
|
ని పొందవచ్చు మరియు క్యాలెండర్లు మరియు చిరునామా పుస్తకాలను సృష్టించడం ప్రారంభించవచ్చు.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Radicale మీ అవసరాలకు సరిపోతుంటే, రిమోట్ క్లయింట్లు మరియు కావలసిన ప్రామాణీకరణ రకానికి మద్దతు ఇవ్వడానికి కొంత \[ప్రాథమిక కాన్ఫిగరేషన్](#basic-configuration)
|
||||||
|
|
||||||
|
కి సమయం కావచ్చు.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
మీ ఆపరేటింగ్ సిస్టమ్ను బట్టి దిగువన ఉన్న అధ్యాయాలలో ఒకదాన్ని అనుసరించండి.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
\#### Linux / \\\*BSD
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
సూచన: PyPI నుండి డౌన్లోడ్ చేయడానికి బదులుగా, మీ \[distribution](#linux-distribution-packages) అందించిన ప్యాకేజీల కోసం చూడండి.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
అవి మీ పంపిణీలలో ఇంటిగ్రేట్ చేయబడిన స్టార్టప్ స్క్రిప్ట్లను కూడా కలిగి ఉంటాయి, ఇవి Radicaleని డెమోనైజ్ చేయడానికి అనుమతిస్తాయి.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
ముందుగా, \*\*python\*\* 3.9 లేదా తరువాత మరియు \*\*pip\*\* ఇన్స్టాల్ చేయబడిందని నిర్ధారించుకోండి. చాలా డిస్ట్రిబ్యూషన్లలో ``python3-pip`` ప్యాకేజీని ఇన్స్టాల్ చేయడానికి సరిపోతుంది.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
\##### సాధారణ వినియోగదారుగా
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
పరీక్ష కోసం మాత్రమే సిఫార్సు చేయబడింది - కన్సోల్ను తెరిచి ఇలా టైప్ చేయండి:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
```bash
|
||||||
|
|
||||||
|
\# ప్రస్తుత వినియోగదారు కోసం మాత్రమే ఇన్స్టాల్ చేయడానికి కింది ఆదేశాన్ని అమలు చేయండి
|
||||||
|
|
||||||
|
python3 -m pip install --user --upgrade https://github.com/Kozea/Radicale/archive/master.tar.gz
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
\_install\_ పని చేయకపోతే మరియు బదులుగా `error: externally-managed-environment` ప్రదర్శించబడితే,
|
||||||
|
|
||||||
|
ముందుగానే వర్చువల్ వాతావరణాన్ని సృష్టించండి మరియు సక్రియం చేయండి.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
```bash
|
||||||
|
|
||||||
|
python3 -m venv ~/venv
|
||||||
|
|
||||||
|
source ~/venv/bin/activate
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
మరియు దీనితో ఇన్స్టాల్ చేయడానికి ప్రయత్నించండి
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
```bash
|
||||||
|
|
||||||
|
python3 -m pip install --upgrade https://github.com/Kozea/Radicale/archive/master.tar.gz
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
సేవను మాన్యువల్గా ప్రారంభించండి, డేటా ప్రస్తుత వినియోగదారు కోసం మాత్రమే నిల్వ చేయబడుతుంది
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
```bash
|
||||||
|
|
||||||
|
\# ప్రారంభించు, డేటా ప్రస్తుత వినియోగదారు కోసం మాత్రమే నిల్వ చేయబడుతుంది
|
||||||
|
|
||||||
|
python3 -m radicale --storage-filesystem-folder=~/.var/lib/radicale/collections --auth-type none
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
\#### సిస్టమ్ వినియోగదారుగా (లేదా రూట్గా)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
ప్రత్యామ్నాయంగా, మీరు సిస్టమ్ వినియోగదారుగా లేదా రూట్గా ఇన్స్టాల్ చేసి అమలు చేయవచ్చు (సిఫార్సు చేయబడలేదు):
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
```bash
|
||||||
|
|
||||||
|
\# కింది ఆదేశాన్ని రూట్ (సిఫార్సు చేయబడలేదు) లేదా రూట్ కాని వ్యవస్థ వినియోగదారుగా అమలు చేయండి
|
||||||
|
|
||||||
|
\# (డిపెండెన్సీలు లేనప్పుడు తరువాతి వాటికి --user అవసరం కావచ్చు సిస్టమ్-వైడ్ మరియు/లేదా వర్చువల్ ఎన్విరాన్మెంట్ అందుబాటులో ఉంది)
|
||||||
|
|
||||||
|
python3 -m pip install --upgrade https://github.com/Kozea/Radicale/archive/master.tar.gz
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
`/var/lib/radicale/collections` కింద సిస్టమ్ ఫోల్డర్లో నిల్వ చేయబడిన డేటాతో సేవను మాన్యువల్గా ప్రారంభించండి:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
```bash
|
||||||
|
|
||||||
|
\# Start, డేటా సిస్టమ్ ఫోల్డర్లో నిల్వ చేయబడుతుంది (/var/lib/radicale/collections కు వ్రాయడానికి అనుమతులు అవసరం)
|
||||||
|
|
||||||
|
python3 -m radicale --storage-filesystem-folder=/var/lib/radicale/collections --auth-type none
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
\#### Windows
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
మొదటి దశ పైథాన్ను ఇన్స్టాల్ చేయడం.
|
||||||
|
|
||||||
|
\[python.org](https://python.org) కు వెళ్లి పైథాన్ 3 యొక్క తాజా వెర్షన్ను డౌన్లోడ్ చేసుకోండి.
|
||||||
|
|
||||||
|
తర్వాత ఇన్స్టాలర్ను అమలు చేయండి.
|
||||||
|
|
||||||
|
ఇన్స్టాలర్ యొక్క మొదటి విండోలో, "PATH కు పైథాన్ను జోడించు" బాక్స్ను తనిఖీ చేసి,
|
||||||
|
|
||||||
|
"ఇప్పుడే ఇన్స్టాల్ చేయి"పై క్లిక్ చేయండి. రెండు నిమిషాలు వేచి ఉండండి, పూర్తయింది!
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
కమాండ్ ప్రాంప్ట్ను ప్రారంభించి ఇలా టైప్ చేయండి:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
|
||||||
|
python -m pip install --upgrade https://github.com/Kozea/Radicale/archive/master.tar.gz
|
||||||
|
|
||||||
|
python -m radicale --storage-filesystem-folder=~/radicale/collections --auth-type none
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
\##### Common
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
విజయవంతం!!! మీ బ్రౌజర్లో <http://localhost:5232> తెరవండి!
|
||||||
|
|
||||||
|
ఉదాహరణ ఎంపిక `--auth-type none` ద్వారా ప్రామాణీకరణ అవసరం లేనందున మీరు ఏదైనా వినియోగదారు పేరు మరియు పాస్వర్డ్తో లాగిన్ అవ్వవచ్చు.
|
||||||
|
|
||||||
|
ఇది \*\*సురక్షితం\*\*, మరిన్ని వివరాల కోసం \[కాన్ఫిగరేషన్/ప్రామాణీకరణ](#auth) చూడండి.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
భద్రతా కారణాల దృష్ట్యా డిఫాల్ట్ కాన్ఫిగరేషన్ సర్వర్ను `localhost` (IPv4: `127.0.0.1`, IPv6: `::1`) కు బంధిస్తుందని గమనించండి.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
మరిన్ని వివరాల కోసం \[చిరునామాలు](#చిరునామాలు) మరియు \[కాన్ఫిగరేషన్/సర్వర్](#సర్వర్) చూడండి.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
\### ప్రాథమిక కాన్ఫిగరేషన్
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
ఇన్స్టాలేషన్ సూచనలను
|
||||||
|
|
||||||
|
\[సరళమైన 5-నిమిషాల సెటప్](#సింపుల్-5-నిమిషాల-సెటప్) ట్యుటోరియల్లో చూడవచ్చు.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
రాడికేల్ `/etc/radicale/config` మరియు
|
||||||
|
|
||||||
|
`~/.config/radicale/config` నుండి కాన్ఫిగరేషన్ ఫైల్లను లోడ్ చేయడానికి ప్రయత్నిస్తుంది.
|
||||||
|
|
||||||
|
Cu
|
||||||
|
|
||||||
@@ -3,7 +3,7 @@ name = "Radicale"
|
|||||||
# When the version is updated, a new section in the CHANGELOG.md file must be
|
# When the version is updated, a new section in the CHANGELOG.md file must be
|
||||||
# added too.
|
# added too.
|
||||||
readme = "README.md"
|
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"}]
|
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"}
|
license = {text = "GNU GPL v3"}
|
||||||
description = "CalDAV and CardDAV Server"
|
description = "CalDAV and CardDAV Server"
|
||||||
@@ -28,12 +28,14 @@ classifiers = [
|
|||||||
]
|
]
|
||||||
urls = {Homepage = "https://radicale.org/"}
|
urls = {Homepage = "https://radicale.org/"}
|
||||||
requires-python = ">=3.9.0"
|
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 = [
|
dependencies = [
|
||||||
"defusedxml",
|
"defusedxml",
|
||||||
"passlib",
|
"libpass>=1.9.3",
|
||||||
"vobject>=0.9.6",
|
"vobject>=0.9.6",
|
||||||
"pika>=1.1.0",
|
"pika>=1.1.0",
|
||||||
"requests",
|
"requests",
|
||||||
|
"packaging",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -104,7 +106,7 @@ radicale = [
|
|||||||
|
|
||||||
[tool.isort]
|
[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_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]
|
[tool.mypy]
|
||||||
ignore_missing_imports = true
|
ignore_missing_imports = true
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# This file is part of Radicale - CalDAV and CardDAV server
|
# This file is part of Radicale - CalDAV and CardDAV server
|
||||||
# Copyright © 2011-2017 Guillaume Ayoub
|
# Copyright © 2011-2017 Guillaume Ayoub
|
||||||
# Copyright © 2017-2022 Unrud <unrud@outlook.com>
|
# 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
|
# 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
|
# it under the terms of the GNU General Public License as published by
|
||||||
@@ -33,7 +33,7 @@ import sys
|
|||||||
from types import FrameType
|
from types import FrameType
|
||||||
from typing import List, Optional, cast
|
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
|
from radicale.log import logger
|
||||||
|
|
||||||
|
|
||||||
@@ -65,6 +65,8 @@ def run() -> None:
|
|||||||
parser.add_argument("--version", action="version", version=VERSION)
|
parser.add_argument("--version", action="version", version=VERSION)
|
||||||
parser.add_argument("--verify-storage", action="store_true",
|
parser.add_argument("--verify-storage", action="store_true",
|
||||||
help="check the storage for errors and exit")
|
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",
|
parser.add_argument("-C", "--config",
|
||||||
help="use specific configuration files", nargs="*")
|
help="use specific configuration files", nargs="*")
|
||||||
parser.add_argument("-D", "--debug", action="store_const", const="debug",
|
parser.add_argument("-D", "--debug", action="store_const", const="debug",
|
||||||
@@ -194,6 +196,19 @@ def run() -> None:
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
return
|
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
|
# Create a socket pair to notify the server of program shutdown
|
||||||
shutdown_socket, shutdown_socket_out = socket.socketpair()
|
shutdown_socket, shutdown_socket_out = socket.socketpair()
|
||||||
|
|
||||||
|
|||||||
@@ -27,15 +27,19 @@ the built-in server (see ``radicale.server`` module).
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
|
import cProfile
|
||||||
import datetime
|
import datetime
|
||||||
|
import io
|
||||||
|
import logging
|
||||||
import pprint
|
import pprint
|
||||||
|
import pstats
|
||||||
import random
|
import random
|
||||||
import time
|
import time
|
||||||
import zlib
|
import zlib
|
||||||
from http import client
|
from http import client
|
||||||
from typing import Iterable, List, Mapping, Tuple, Union
|
from typing import Iterable, List, Mapping, Tuple, Union
|
||||||
|
|
||||||
from radicale import config, httputils, log, pathutils, types
|
from radicale import config, httputils, log, pathutils, types, utils
|
||||||
from radicale.app.base import ApplicationBase
|
from radicale.app.base import ApplicationBase
|
||||||
from radicale.app.delete import ApplicationPartDelete
|
from radicale.app.delete import ApplicationPartDelete
|
||||||
from radicale.app.get import ApplicationPartGet
|
from radicale.app.get import ApplicationPartGet
|
||||||
@@ -49,11 +53,14 @@ from radicale.app.propfind import ApplicationPartPropfind
|
|||||||
from radicale.app.proppatch import ApplicationPartProppatch
|
from radicale.app.proppatch import ApplicationPartProppatch
|
||||||
from radicale.app.put import ApplicationPartPut
|
from radicale.app.put import ApplicationPartPut
|
||||||
from radicale.app.report import ApplicationPartReport
|
from radicale.app.report import ApplicationPartReport
|
||||||
|
from radicale.auth import AuthContext
|
||||||
from radicale.log import logger
|
from radicale.log import logger
|
||||||
|
|
||||||
# Combination of types.WSGIStartResponse and WSGI application return value
|
# Combination of types.WSGIStartResponse and WSGI application return value
|
||||||
_IntermediateResponse = Tuple[str, List[Tuple[str, str]], Iterable[bytes]]
|
_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,
|
class Application(ApplicationPartDelete, ApplicationPartHead,
|
||||||
ApplicationPartGet, ApplicationPartMkcalendar,
|
ApplicationPartGet, ApplicationPartMkcalendar,
|
||||||
@@ -67,11 +74,18 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
|
|||||||
_auth_delay: float
|
_auth_delay: float
|
||||||
_internal_server: bool
|
_internal_server: bool
|
||||||
_max_content_length: int
|
_max_content_length: int
|
||||||
|
_max_resource_size: int
|
||||||
_auth_realm: str
|
_auth_realm: str
|
||||||
|
_auth_type: str
|
||||||
|
_web_type: str
|
||||||
_script_name: str
|
_script_name: str
|
||||||
_extra_headers: Mapping[str, str]
|
_extra_headers: Mapping[str, str]
|
||||||
_permit_delete_collection: bool
|
_profiling_per_request: bool = False
|
||||||
_permit_overwrite_collection: bool
|
_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:
|
def __init__(self, configuration: config.Configuration) -> None:
|
||||||
"""Initialize Application.
|
"""Initialize Application.
|
||||||
@@ -83,10 +97,28 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
|
|||||||
"""
|
"""
|
||||||
super().__init__(configuration)
|
super().__init__(configuration)
|
||||||
self._mask_passwords = configuration.get("logging", "mask_passwords")
|
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")
|
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_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")
|
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_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._internal_server = configuration.get("server", "_internal_server")
|
||||||
self._script_name = configuration.get("server", "script_name")
|
self._script_name = configuration.get("server", "script_name")
|
||||||
if self._script_name:
|
if self._script_name:
|
||||||
@@ -111,6 +143,59 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
|
|||||||
self._extra_headers = dict()
|
self._extra_headers = dict()
|
||||||
for key in self.configuration.options("headers"):
|
for key in self.configuration.options("headers"):
|
||||||
self._extra_headers[key] = configuration.get("headers", key)
|
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:
|
def _scrub_headers(self, environ: types.WSGIEnviron) -> types.WSGIEnviron:
|
||||||
"""Mask passwords and cookies."""
|
"""Mask passwords and cookies."""
|
||||||
@@ -132,7 +217,7 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
|
|||||||
"%s", environ.get("REQUEST_METHOD", "unknown"),
|
"%s", environ.get("REQUEST_METHOD", "unknown"),
|
||||||
environ.get("PATH_INFO", ""), e, exc_info=True)
|
environ.get("PATH_INFO", ""), e, exc_info=True)
|
||||||
# Make minimal response
|
# Make minimal response
|
||||||
status, raw_headers, raw_answer = (
|
status, raw_headers, raw_answer, xml_request = (
|
||||||
httputils.INTERNAL_SERVER_ERROR)
|
httputils.INTERNAL_SERVER_ERROR)
|
||||||
assert isinstance(raw_answer, str)
|
assert isinstance(raw_answer, str)
|
||||||
answer = raw_answer.encode("ascii")
|
answer = raw_answer.encode("ascii")
|
||||||
@@ -151,20 +236,29 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
|
|||||||
request_method = environ["REQUEST_METHOD"].upper()
|
request_method = environ["REQUEST_METHOD"].upper()
|
||||||
unsafe_path = environ.get("PATH_INFO", "")
|
unsafe_path = environ.get("PATH_INFO", "")
|
||||||
https = environ.get("HTTPS", "")
|
https = environ.get("HTTPS", "")
|
||||||
|
profiler = None
|
||||||
|
profiler_active = False
|
||||||
|
xml_request = None
|
||||||
|
|
||||||
|
context = AuthContext()
|
||||||
|
|
||||||
"""Manage a request."""
|
"""Manage a request."""
|
||||||
def response(status: int, headers: types.WSGIResponseHeaders,
|
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"""
|
"""Helper to create response from internal types.WSGIResponse"""
|
||||||
headers = dict(headers)
|
headers = dict(headers)
|
||||||
|
content_encoding = "plain"
|
||||||
# Set content length
|
# Set content length
|
||||||
answers = []
|
answers = []
|
||||||
if answer is not None:
|
if answer is not None:
|
||||||
if isinstance(answer, str):
|
if isinstance(answer, str):
|
||||||
if self._response_content_on_debug:
|
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:
|
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
|
headers["Content-Type"] += "; charset=%s" % self._encoding
|
||||||
answer = answer.encode(self._encoding)
|
answer = answer.encode(self._encoding)
|
||||||
accept_encoding = [
|
accept_encoding = [
|
||||||
@@ -176,6 +270,7 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
|
|||||||
zcomp = zlib.compressobj(wbits=16 + zlib.MAX_WBITS)
|
zcomp = zlib.compressobj(wbits=16 + zlib.MAX_WBITS)
|
||||||
answer = zcomp.compress(answer) + zcomp.flush()
|
answer = zcomp.compress(answer) + zcomp.flush()
|
||||||
headers["Content-Encoding"] = "gzip"
|
headers["Content-Encoding"] = "gzip"
|
||||||
|
content_encoding = "gzip"
|
||||||
|
|
||||||
headers["Content-Length"] = str(len(answer))
|
headers["Content-Length"] = str(len(answer))
|
||||||
answers.append(answer)
|
answers.append(answer)
|
||||||
@@ -183,13 +278,79 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
|
|||||||
# Add extra headers set in configuration
|
# Add extra headers set in configuration
|
||||||
headers.update(self._extra_headers)
|
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
|
# Start response
|
||||||
time_end = datetime.datetime.now()
|
time_end = datetime.datetime.now()
|
||||||
|
time_delta_seconds = (time_end - time_begin).total_seconds()
|
||||||
status_text = "%d %s" % (
|
status_text = "%d %s" % (
|
||||||
status, client.responses.get(status, "Unknown"))
|
status, client.responses.get(status, "Unknown"))
|
||||||
logger.info("%s response status for %r%s in %.3f seconds: %s",
|
flags = []
|
||||||
request_method, unsafe_path, depthinfo,
|
if xml_request is not None:
|
||||||
(time_end - time_begin).total_seconds(), status_text)
|
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 response content
|
||||||
return status_text, list(headers.items()), answers
|
return status_text, list(headers.items()), answers
|
||||||
|
|
||||||
@@ -197,12 +358,16 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
|
|||||||
remote_host = "unknown"
|
remote_host = "unknown"
|
||||||
if environ.get("REMOTE_HOST"):
|
if environ.get("REMOTE_HOST"):
|
||||||
remote_host = repr(environ["REMOTE_HOST"])
|
remote_host = repr(environ["REMOTE_HOST"])
|
||||||
elif environ.get("REMOTE_ADDR"):
|
if environ.get("REMOTE_ADDR"):
|
||||||
remote_host = environ["REMOTE_ADDR"]
|
if remote_host == 'unknown':
|
||||||
|
remote_host = environ["REMOTE_ADDR"]
|
||||||
|
context.remote_addr = environ["REMOTE_ADDR"]
|
||||||
if environ.get("HTTP_X_FORWARDED_FOR"):
|
if environ.get("HTTP_X_FORWARDED_FOR"):
|
||||||
reverse_proxy = True
|
reverse_proxy = True
|
||||||
remote_host = "%s (forwarded for %r)" % (
|
remote_host = "%s (forwarded for %r)" % (
|
||||||
remote_host, environ["HTTP_X_FORWARDED_FOR"])
|
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"):
|
if environ.get("HTTP_X_FORWARDED_HOST") or environ.get("HTTP_X_FORWARDED_PROTO") or environ.get("HTTP_X_FORWARDED_SERVER"):
|
||||||
reverse_proxy = True
|
reverse_proxy = True
|
||||||
remote_useragent = ""
|
remote_useragent = ""
|
||||||
@@ -220,7 +385,7 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
|
|||||||
remote_host, remote_useragent, https_info)
|
remote_host, remote_useragent, https_info)
|
||||||
if self._request_header_on_debug:
|
if self._request_header_on_debug:
|
||||||
logger.debug("Request header:\n%s",
|
logger.debug("Request header:\n%s",
|
||||||
pprint.pformat(self._scrub_headers(environ)))
|
utils.textwrap_str(pprint.pformat(self._scrub_headers(environ))))
|
||||||
else:
|
else:
|
||||||
logger.debug("Request header: suppressed by config/option [logging] request_header_on_debug")
|
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)
|
logger.debug("Called by reverse proxy, remove base prefix %r from path: %r => %r", base_prefix, path, path_new)
|
||||||
path = path_new
|
path = path_new
|
||||||
else:
|
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
|
# Get function corresponding to method
|
||||||
function = getattr(self, "do_%s" % request_method, None)
|
function = getattr(self, "do_%s" % request_method, None)
|
||||||
@@ -288,7 +456,7 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
|
|||||||
self.configuration, environ, base64.b64decode(
|
self.configuration, environ, base64.b64decode(
|
||||||
authorization.encode("ascii"))).split(":", 1)
|
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":
|
if self.configuration.get("auth", "type") == "ldap":
|
||||||
try:
|
try:
|
||||||
logger.debug("Groups received from LDAP: %r", ",".join(self._auth._ldap_groups))
|
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):
|
if "W" in self._rights.authorization(user, principal_path):
|
||||||
with self._storage.acquire_lock("w", user):
|
with self._storage.acquire_lock("w", user):
|
||||||
try:
|
try:
|
||||||
new_coll = self._storage.create_collection(principal_path)
|
new_coll, _, _ = self._storage.create_collection(principal_path)
|
||||||
if new_coll:
|
if new_coll:
|
||||||
jsn_coll = self.configuration.get("storage", "predefined_collections")
|
jsn_coll = self.configuration.get("storage", "predefined_collections")
|
||||||
for (name_coll, props) in jsn_coll.items():
|
for (name_coll, props) in jsn_coll.items():
|
||||||
@@ -349,15 +517,42 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
|
|||||||
return response(*httputils.REQUEST_ENTITY_TOO_LARGE)
|
return response(*httputils.REQUEST_ENTITY_TOO_LARGE)
|
||||||
|
|
||||||
if not login or user:
|
if not login or user:
|
||||||
status, headers, answer = function(
|
# Profiling
|
||||||
environ, base_prefix, path, user)
|
if self._profiling_per_request:
|
||||||
if (status, headers, answer) == httputils.NOT_ALLOWED:
|
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,
|
logger.info("Access to %r denied for %s", path,
|
||||||
repr(user) if user else "anonymous user")
|
repr(user) if user else "anonymous user")
|
||||||
else:
|
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):
|
not external_login):
|
||||||
# Unknown or unauthorized user
|
# Unknown or unauthorized user
|
||||||
logger.debug("Asking client for authentication")
|
logger.debug("Asking client for authentication")
|
||||||
@@ -367,4 +562,4 @@ class Application(ApplicationPartDelete, ApplicationPartHead,
|
|||||||
"WWW-Authenticate":
|
"WWW-Authenticate":
|
||||||
"Basic realm=\"%s\"" % self._auth_realm})
|
"Basic realm=\"%s\"" % self._auth_realm})
|
||||||
|
|
||||||
return response(status, headers, answer)
|
return response(status, headers, answer, xml_request)
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import xml.etree.ElementTree as ET
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from radicale import (auth, config, hook, httputils, pathutils, rights,
|
from radicale import (auth, config, hook, httputils, pathutils, rights,
|
||||||
storage, types, web, xmlutils)
|
storage, types, utils, web, xmlutils)
|
||||||
from radicale.log import logger
|
from radicale.log import logger
|
||||||
|
|
||||||
# HACK: https://github.com/tiran/defusedxml/issues/54
|
# HACK: https://github.com/tiran/defusedxml/issues/54
|
||||||
@@ -39,8 +39,10 @@ class ApplicationBase:
|
|||||||
_rights: rights.BaseRights
|
_rights: rights.BaseRights
|
||||||
_web: web.BaseWeb
|
_web: web.BaseWeb
|
||||||
_encoding: str
|
_encoding: str
|
||||||
|
_max_resource_size: int
|
||||||
_permit_delete_collection: bool
|
_permit_delete_collection: bool
|
||||||
_permit_overwrite_collection: bool
|
_permit_overwrite_collection: bool
|
||||||
|
_strict_preconditions: bool
|
||||||
_hook: hook.BaseHook
|
_hook: hook.BaseHook
|
||||||
|
|
||||||
def __init__(self, configuration: config.Configuration) -> None:
|
def __init__(self, configuration: config.Configuration) -> None:
|
||||||
@@ -70,7 +72,7 @@ class ApplicationBase:
|
|||||||
if logger.isEnabledFor(logging.DEBUG):
|
if logger.isEnabledFor(logging.DEBUG):
|
||||||
if self._request_content_on_debug:
|
if self._request_content_on_debug:
|
||||||
logger.debug("Request content (XML):\n%s",
|
logger.debug("Request content (XML):\n%s",
|
||||||
xmlutils.pretty_xml(xml_content))
|
utils.textwrap_str(xmlutils.pretty_xml(xml_content)))
|
||||||
else:
|
else:
|
||||||
logger.debug("Request content (XML): suppressed by config/option [logging] request_content_on_debug")
|
logger.debug("Request content (XML): suppressed by config/option [logging] request_content_on_debug")
|
||||||
return xml_content
|
return xml_content
|
||||||
@@ -79,7 +81,7 @@ class ApplicationBase:
|
|||||||
if logger.isEnabledFor(logging.DEBUG):
|
if logger.isEnabledFor(logging.DEBUG):
|
||||||
if self._response_content_on_debug:
|
if self._response_content_on_debug:
|
||||||
logger.debug("Response content (XML):\n%s",
|
logger.debug("Response content (XML):\n%s",
|
||||||
xmlutils.pretty_xml(xml_content))
|
utils.textwrap_str(xmlutils.pretty_xml(xml_content)))
|
||||||
else:
|
else:
|
||||||
logger.debug("Response content (XML): suppressed by config/option [logging] response_content_on_debug")
|
logger.debug("Response content (XML): suppressed by config/option [logging] response_content_on_debug")
|
||||||
f = io.BytesIO()
|
f = io.BytesIO()
|
||||||
@@ -92,7 +94,7 @@ class ApplicationBase:
|
|||||||
"""Generate XML error response."""
|
"""Generate XML error response."""
|
||||||
headers = {"Content-Type": "text/xml; charset=%s" % self._encoding}
|
headers = {"Content-Type": "text/xml; charset=%s" % self._encoding}
|
||||||
content = self._xml_response(xmlutils.webdav_error(human_tag))
|
content = self._xml_response(xmlutils.webdav_error(human_tag))
|
||||||
return status, headers, content
|
return status, headers, content, None
|
||||||
|
|
||||||
|
|
||||||
class Access:
|
class Access:
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ from typing import Optional
|
|||||||
|
|
||||||
from radicale import httputils, storage, types, xmlutils
|
from radicale import httputils, storage, types, xmlutils
|
||||||
from radicale.app.base import Access, ApplicationBase
|
from radicale.app.base import Access, ApplicationBase
|
||||||
from radicale.hook import DeleteHookNotificationItem
|
from radicale.hook import HookNotificationItem, HookNotificationItemTypes
|
||||||
from radicale.log import logger
|
from radicale.log import logger
|
||||||
|
|
||||||
|
|
||||||
@@ -55,7 +55,7 @@ def xml_delete(base_prefix: str, path: str, collection: storage.BaseCollection,
|
|||||||
class ApplicationPartDelete(ApplicationBase):
|
class ApplicationPartDelete(ApplicationBase):
|
||||||
|
|
||||||
def do_DELETE(self, environ: types.WSGIEnviron, base_prefix: str,
|
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."""
|
"""Manage DELETE request."""
|
||||||
access = Access(self._rights, user, path)
|
access = Access(self._rights, user, path)
|
||||||
if not access.check("w"):
|
if not access.check("w"):
|
||||||
@@ -82,10 +82,13 @@ class ApplicationPartDelete(ApplicationBase):
|
|||||||
return httputils.NOT_ALLOWED
|
return httputils.NOT_ALLOWED
|
||||||
for i in item.get_all():
|
for i in item.get_all():
|
||||||
hook_notification_item_list.append(
|
hook_notification_item_list.append(
|
||||||
DeleteHookNotificationItem(
|
HookNotificationItem(
|
||||||
access.path,
|
notification_item_type=HookNotificationItemTypes.DELETE,
|
||||||
i.uid,
|
path=access.path,
|
||||||
old_content=item.serialize() # type: ignore
|
content=i.uid,
|
||||||
|
uid=i.uid,
|
||||||
|
old_content=i.serialize(), # type: ignore
|
||||||
|
new_content=None
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
xml_answer = xml_delete(base_prefix, path, item)
|
xml_answer = xml_delete(base_prefix, path, item)
|
||||||
@@ -93,10 +96,13 @@ class ApplicationPartDelete(ApplicationBase):
|
|||||||
assert item.collection is not None
|
assert item.collection is not None
|
||||||
assert item.href is not None
|
assert item.href is not None
|
||||||
hook_notification_item_list.append(
|
hook_notification_item_list.append(
|
||||||
DeleteHookNotificationItem(
|
HookNotificationItem(
|
||||||
access.path,
|
notification_item_type=HookNotificationItemTypes.DELETE,
|
||||||
item.uid,
|
path=access.path,
|
||||||
old_content=item.serialize() # type: ignore
|
content=item.uid,
|
||||||
|
uid=item.uid,
|
||||||
|
old_content=item.serialize(), # type: ignore
|
||||||
|
new_content=None,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
xml_answer = xml_delete(
|
xml_answer = xml_delete(
|
||||||
@@ -104,4 +110,4 @@ class ApplicationPartDelete(ApplicationBase):
|
|||||||
for notification_item in hook_notification_item_list:
|
for notification_item in hook_notification_item_list:
|
||||||
self._hook.notify(notification_item)
|
self._hook.notify(notification_item)
|
||||||
headers = {"Content-Type": "text/xml; charset=%s" % self._encoding}
|
headers = {"Content-Type": "text/xml; charset=%s" % self._encoding}
|
||||||
return client.OK, headers, self._xml_response(xml_answer)
|
return client.OK, headers, self._xml_response(xml_answer), None
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
# Copyright © 2008 Nicolas Kandel
|
# Copyright © 2008 Nicolas Kandel
|
||||||
# Copyright © 2008 Pascal Halter
|
# Copyright © 2008 Pascal Halter
|
||||||
# Copyright © 2008-2017 Guillaume Ayoub
|
# 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
|
# 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
|
# it under the terms of the GNU General Public License as published by
|
||||||
@@ -58,7 +59,7 @@ class ApplicationPartGet(ApplicationBase):
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
def do_GET(self, environ: types.WSGIEnviron, base_prefix: str, path: str,
|
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."""
|
"""Manage GET request."""
|
||||||
# Redirect to /.web if the root path is requested
|
# Redirect to /.web if the root path is requested
|
||||||
if not pathutils.strip_path(path):
|
if not pathutils.strip_path(path):
|
||||||
@@ -108,4 +109,4 @@ class ApplicationPartGet(ApplicationBase):
|
|||||||
if content_disposition:
|
if content_disposition:
|
||||||
headers["Content-Disposition"] = content_disposition
|
headers["Content-Disposition"] = content_disposition
|
||||||
answer = item.serialize()
|
answer = item.serialize()
|
||||||
return client.OK, headers, answer
|
return client.OK, headers, answer, None
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
# Copyright © 2008 Nicolas Kandel
|
# Copyright © 2008 Nicolas Kandel
|
||||||
# Copyright © 2008 Pascal Halter
|
# Copyright © 2008 Pascal Halter
|
||||||
# Copyright © 2008-2017 Guillaume Ayoub
|
# 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
|
# 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
|
# 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):
|
class ApplicationPartHead(ApplicationPartGet, ApplicationBase):
|
||||||
|
|
||||||
def do_HEAD(self, environ: types.WSGIEnviron, base_prefix: str, path: str,
|
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."""
|
"""Manage HEAD request."""
|
||||||
# Body is dropped in `Application.__call__` for HEAD requests
|
# Body is dropped in `Application.__call__` for HEAD requests
|
||||||
return self.do_GET(environ, base_prefix, path, user)
|
return self.do_GET(environ, base_prefix, path, user, remote_host, remote_useragent)
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ from radicale.log import logger
|
|||||||
class ApplicationPartMkcalendar(ApplicationBase):
|
class ApplicationPartMkcalendar(ApplicationBase):
|
||||||
|
|
||||||
def do_MKCALENDAR(self, environ: types.WSGIEnviron, base_prefix: str,
|
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."""
|
"""Manage MKCALENDAR request."""
|
||||||
if "w" not in self._rights.authorization(user, path):
|
if "w" not in self._rights.authorization(user, path):
|
||||||
return httputils.NOT_ALLOWED
|
return httputils.NOT_ALLOWED
|
||||||
@@ -89,4 +89,4 @@ class ApplicationPartMkcalendar(ApplicationBase):
|
|||||||
logger.warning(
|
logger.warning(
|
||||||
"Bad MKCALENDAR request on %r: %s", path, e, exc_info=True)
|
"Bad MKCALENDAR request on %r: %s", path, e, exc_info=True)
|
||||||
return httputils.BAD_REQUEST
|
return httputils.BAD_REQUEST
|
||||||
return client.CREATED, {}, None
|
return client.CREATED, {}, None, xmlutils.pretty_xml(xml_content)
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ from radicale.log import logger
|
|||||||
class ApplicationPartMkcol(ApplicationBase):
|
class ApplicationPartMkcol(ApplicationBase):
|
||||||
|
|
||||||
def do_MKCOL(self, environ: types.WSGIEnviron, base_prefix: str,
|
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."""
|
"""Manage MKCOL request."""
|
||||||
permissions = self._rights.authorization(user, path)
|
permissions = self._rights.authorization(user, path)
|
||||||
if not rights.intersect(permissions, "Ww"):
|
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)
|
"Bad MKCOL request on %r (type:%s): %s", path, collection_type, e, exc_info=True)
|
||||||
return httputils.BAD_REQUEST
|
return httputils.BAD_REQUEST
|
||||||
logger.info("MKCOL request %r (type:%s): %s", path, collection_type, "successful")
|
logger.info("MKCOL request %r (type:%s): %s", path, collection_type, "successful")
|
||||||
return client.CREATED, {}, None
|
return client.CREATED, {}, None, xmlutils.pretty_xml(xml_content)
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import errno
|
|||||||
import posixpath
|
import posixpath
|
||||||
import re
|
import re
|
||||||
from http import client
|
from http import client
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import unquote, urlparse
|
||||||
|
|
||||||
from radicale import httputils, pathutils, storage, types
|
from radicale import httputils, pathutils, storage, types
|
||||||
from radicale.app.base import Access, ApplicationBase
|
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"]
|
host = environ["HTTP_X_FORWARDED_HOST"]
|
||||||
proto = environ.get("HTTP_X_FORWARDED_PROTO") or "http"
|
proto = environ.get("HTTP_X_FORWARDED_PROTO") or "http"
|
||||||
port = "443" if proto == "https" else "80"
|
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:
|
else:
|
||||||
host = environ.get("HTTP_HOST") or environ["SERVER_NAME"]
|
host = environ.get("HTTP_HOST") or environ["SERVER_NAME"]
|
||||||
proto = environ["wsgi.url_scheme"]
|
proto = environ["wsgi.url_scheme"]
|
||||||
@@ -48,18 +48,25 @@ def get_server_netloc(environ: types.WSGIEnviron, force_port: bool = False):
|
|||||||
class ApplicationPartMove(ApplicationBase):
|
class ApplicationPartMove(ApplicationBase):
|
||||||
|
|
||||||
def do_MOVE(self, environ: types.WSGIEnviron, base_prefix: str,
|
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."""
|
"""Manage MOVE request."""
|
||||||
raw_dest = environ.get("HTTP_DESTINATION", "")
|
raw_dest = environ.get("HTTP_DESTINATION", "")
|
||||||
to_url = urlparse(raw_dest)
|
|
||||||
to_netloc_with_port = to_url.netloc
|
# Decode URL-encoded characters (e.g. %40 -> @) before parsing
|
||||||
if to_url.port is None:
|
raw_dest_decoded = unquote(raw_dest)
|
||||||
to_netloc_with_port += (":443" if to_url.scheme == "https"
|
to_url = urlparse(raw_dest_decoded)
|
||||||
else ":80")
|
|
||||||
if to_netloc_with_port != get_server_netloc(environ, force_port=True):
|
# Only check netloc for absolute URLs
|
||||||
logger.info("Unsupported destination address: %r", raw_dest)
|
if to_url.netloc:
|
||||||
# Remote destination server, not supported
|
to_netloc_with_port = to_url.netloc
|
||||||
return httputils.REMOTE_DESTINATION
|
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)
|
access = Access(self._rights, user, path)
|
||||||
if not access.check("w"):
|
if not access.check("w"):
|
||||||
return httputils.NOT_ALLOWED
|
return httputils.NOT_ALLOWED
|
||||||
@@ -127,4 +134,4 @@ class ApplicationPartMove(ApplicationBase):
|
|||||||
logger.warning(
|
logger.warning(
|
||||||
"Bad MOVE request on %r: %s", path, e, exc_info=True)
|
"Bad MOVE request on %r: %s", path, e, exc_info=True)
|
||||||
return httputils.BAD_REQUEST
|
return httputils.BAD_REQUEST
|
||||||
return client.NO_CONTENT if to_item else client.CREATED, {}, None
|
return client.NO_CONTENT if to_item else client.CREATED, {}, None, None
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
# Copyright © 2008 Nicolas Kandel
|
# Copyright © 2008 Nicolas Kandel
|
||||||
# Copyright © 2008 Pascal Halter
|
# Copyright © 2008 Pascal Halter
|
||||||
# Copyright © 2008-2017 Guillaume Ayoub
|
# 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
|
# 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
|
# 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):
|
class ApplicationPartOptions(ApplicationBase):
|
||||||
|
|
||||||
def do_OPTIONS(self, environ: types.WSGIEnviron, base_prefix: str,
|
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."""
|
"""Manage OPTIONS request."""
|
||||||
headers = {
|
headers = {
|
||||||
"Allow": ", ".join(
|
"Allow": ", ".join(
|
||||||
name[3:] for name in dir(self) if name.startswith("do_")),
|
name[3:] for name in dir(self) if name.startswith("do_")),
|
||||||
"DAV": httputils.DAV_HEADERS}
|
"DAV": httputils.DAV_HEADERS}
|
||||||
return client.OK, headers, None
|
return client.OK, headers, None, None
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
# Copyright © 2008 Nicolas Kandel
|
# Copyright © 2008 Nicolas Kandel
|
||||||
# Copyright © 2008 Pascal Halter
|
# Copyright © 2008 Pascal Halter
|
||||||
# Copyright © 2008-2017 Guillaume Ayoub
|
# Copyright © 2008-2017 Guillaume Ayoub
|
||||||
# Copyright © 2017-2018 Unrud <unrud@outlook.com>
|
# Copyright © 2017-2021 Unrud <unrud@outlook.com>
|
||||||
# Copyright © 2020 Tom Hacohen <tom@stosb.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
|
# 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
|
# 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):
|
class ApplicationPartPost(ApplicationBase):
|
||||||
|
|
||||||
def do_POST(self, environ: types.WSGIEnviron, base_prefix: str,
|
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."""
|
"""Manage POST request."""
|
||||||
if path == "/.web" or path.startswith("/.web/"):
|
if path == "/.web" or path.startswith("/.web/"):
|
||||||
return self._web.post(environ, base_prefix, path, user)
|
return self._web.post(environ, base_prefix, path, user)
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
# Copyright © 2008 Nicolas Kandel
|
# Copyright © 2008 Nicolas Kandel
|
||||||
# Copyright © 2008 Pascal Halter
|
# Copyright © 2008 Pascal Halter
|
||||||
# Copyright © 2008-2017 Guillaume Ayoub
|
# 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
|
# 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
|
# 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 http import client
|
||||||
from typing import Dict, Iterable, Iterator, List, Optional, Sequence, Tuple
|
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.app.base import Access, ApplicationBase
|
||||||
from radicale.log import logger
|
from radicale.log import logger
|
||||||
|
|
||||||
@@ -33,7 +35,7 @@ from radicale.log import logger
|
|||||||
def xml_propfind(base_prefix: str, path: str,
|
def xml_propfind(base_prefix: str, path: str,
|
||||||
xml_request: Optional[ET.Element],
|
xml_request: Optional[ET.Element],
|
||||||
allowed_items: Iterable[Tuple[types.CollectionOrItem, str]],
|
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 and answer PROPFIND requests.
|
||||||
|
|
||||||
Read rfc4918-9.1 for info.
|
Read rfc4918-9.1 for info.
|
||||||
@@ -70,14 +72,14 @@ def xml_propfind(base_prefix: str, path: str,
|
|||||||
write = permission == "w"
|
write = permission == "w"
|
||||||
multistatus.append(xml_propfind_response(
|
multistatus.append(xml_propfind_response(
|
||||||
base_prefix, path, item, props, user, encoding, write=write,
|
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
|
return multistatus
|
||||||
|
|
||||||
|
|
||||||
def xml_propfind_response(
|
def xml_propfind_response(
|
||||||
base_prefix: str, path: str, item: types.CollectionOrItem,
|
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:
|
propname: bool = False, allprop: bool = False) -> ET.Element:
|
||||||
"""Build and return a PROPFIND response."""
|
"""Build and return a PROPFIND response."""
|
||||||
if propname and allprop or (props and (propname or allprop)):
|
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:supported-report-set"))
|
||||||
props.append(xmlutils.make_clark("D:resourcetype"))
|
props.append(xmlutils.make_clark("D:resourcetype"))
|
||||||
props.append(xmlutils.make_clark("D:owner"))
|
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:
|
if is_collection and collection.is_principal:
|
||||||
props.append(xmlutils.make_clark("C:calendar-user-address-set"))
|
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("CS:getctag"))
|
||||||
props.append(
|
props.append(
|
||||||
xmlutils.make_clark("C:supported-calendar-component-set"))
|
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()
|
meta = collection.get_meta()
|
||||||
for tag in meta:
|
for tag in meta:
|
||||||
@@ -184,6 +193,21 @@ def xml_propfind_response(
|
|||||||
element.append(comp)
|
element.append(comp)
|
||||||
else:
|
else:
|
||||||
is404 = True
|
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"):
|
elif tag == xmlutils.make_clark("D:current-user-principal"):
|
||||||
if user:
|
if user:
|
||||||
child_element = ET.Element(xmlutils.make_clark("D:href"))
|
child_element = ET.Element(xmlutils.make_clark("D:href"))
|
||||||
@@ -238,6 +262,9 @@ def xml_propfind_response(
|
|||||||
child_element.text = xmlutils.make_href(
|
child_element.text = xmlutils.make_href(
|
||||||
base_prefix, "/%s/" % collection.owner)
|
base_prefix, "/%s/" % collection.owner)
|
||||||
element.append(child_element)
|
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:
|
elif is_collection:
|
||||||
if tag == xmlutils.make_clark("D:getcontenttype"):
|
if tag == xmlutils.make_clark("D:getcontenttype"):
|
||||||
if is_leaf:
|
if is_leaf:
|
||||||
@@ -376,7 +403,7 @@ class ApplicationPartPropfind(ApplicationBase):
|
|||||||
yield item, permission
|
yield item, permission
|
||||||
|
|
||||||
def do_PROPFIND(self, environ: types.WSGIEnviron, base_prefix: str,
|
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."""
|
"""Manage PROPFIND request."""
|
||||||
access = Access(self._rights, user, path)
|
access = Access(self._rights, user, path)
|
||||||
if not access.check("r"):
|
if not access.check("r"):
|
||||||
@@ -406,7 +433,7 @@ class ApplicationPartPropfind(ApplicationBase):
|
|||||||
headers = {"DAV": httputils.DAV_HEADERS,
|
headers = {"DAV": httputils.DAV_HEADERS,
|
||||||
"Content-Type": "text/xml; charset=%s" % self._encoding}
|
"Content-Type": "text/xml; charset=%s" % self._encoding}
|
||||||
xml_answer = xml_propfind(base_prefix, path, xml_content,
|
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:
|
if xml_answer is None:
|
||||||
return httputils.NOT_ALLOWED
|
return httputils.NOT_ALLOWED
|
||||||
return client.MULTI_STATUS, headers, self._xml_response(xml_answer)
|
return client.MULTI_STATUS, headers, self._xml_response(xml_answer), xmlutils.pretty_xml(xml_content)
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ def xml_proppatch(base_prefix: str, path: str,
|
|||||||
class ApplicationPartProppatch(ApplicationBase):
|
class ApplicationPartProppatch(ApplicationBase):
|
||||||
|
|
||||||
def do_PROPPATCH(self, environ: types.WSGIEnviron, base_prefix: str,
|
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."""
|
"""Manage PROPPATCH request."""
|
||||||
access = Access(self._rights, user, path)
|
access = Access(self._rights, user, path)
|
||||||
if not access.check("w"):
|
if not access.check("w"):
|
||||||
@@ -101,13 +101,17 @@ class ApplicationPartProppatch(ApplicationBase):
|
|||||||
xml_answer = xml_proppatch(base_prefix, path, xml_content,
|
xml_answer = xml_proppatch(base_prefix, path, xml_content,
|
||||||
item)
|
item)
|
||||||
if xml_content is not None:
|
if xml_content is not None:
|
||||||
|
content = DefusedET.tostring(
|
||||||
|
xml_content,
|
||||||
|
encoding=self._encoding
|
||||||
|
).decode(encoding=self._encoding)
|
||||||
hook_notification_item = HookNotificationItem(
|
hook_notification_item = HookNotificationItem(
|
||||||
HookNotificationItemTypes.CPATCH,
|
notification_item_type=HookNotificationItemTypes.CPATCH,
|
||||||
access.path,
|
path=access.path,
|
||||||
DefusedET.tostring(
|
content=content,
|
||||||
xml_content,
|
uid=None,
|
||||||
encoding=self._encoding
|
old_content=None,
|
||||||
).decode(encoding=self._encoding)
|
new_content=content
|
||||||
)
|
)
|
||||||
self._hook.notify(hook_notification_item)
|
self._hook.notify(hook_notification_item)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
@@ -127,4 +131,4 @@ class ApplicationPartProppatch(ApplicationBase):
|
|||||||
logger.warning(
|
logger.warning(
|
||||||
"Bad PROPPATCH request on %r: %s", path, e, exc_info=True)
|
"Bad PROPPATCH request on %r: %s", path, e, exc_info=True)
|
||||||
return httputils.BAD_REQUEST
|
return httputils.BAD_REQUEST
|
||||||
return client.MULTI_STATUS, headers, self._xml_response(xml_answer)
|
return client.MULTI_STATUS, headers, self._xml_response(xml_answer), xmlutils.pretty_xml(xml_content)
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ PRODID = u"-//Radicale//NONSGML Version " + utils.package_version("radicale") +
|
|||||||
|
|
||||||
|
|
||||||
def prepare(vobject_items: List[vobject.base.Component], path: str,
|
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,
|
tag: Optional[str] = None,
|
||||||
write_whole_collection: Optional[bool] = None) -> Tuple[
|
write_whole_collection: Optional[bool] = None) -> Tuple[
|
||||||
Iterator[radicale_item.Item], # items
|
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)
|
logger.debug("Prepare item with UID '%s'", item.uid)
|
||||||
try:
|
try:
|
||||||
item.prepare()
|
item.prepare()
|
||||||
except ValueError as e:
|
except (RuntimeError, ValueError, AttributeError) as e:
|
||||||
if logger.isEnabledFor(logging.DEBUG):
|
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:
|
else:
|
||||||
logger.warning("Problem during prepare item with UID '%s' (content suppressed in this loglevel): %s", item.uid, e)
|
logger.warning("Problem during prepare item with UID '%s' (content suppressed in this loglevel): %s", item.uid, e)
|
||||||
raise
|
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)
|
items.append(item)
|
||||||
elif write_whole_collection and tag == "VADDRESSBOOK":
|
elif write_whole_collection and tag == "VADDRESSBOOK":
|
||||||
for vobject_item in vobject_items:
|
for vobject_item in vobject_items:
|
||||||
item = radicale_item.Item(collection_path=collection_path,
|
item = radicale_item.Item(collection_path=collection_path,
|
||||||
vobject_item=vobject_item)
|
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)
|
items.append(item)
|
||||||
elif not write_whole_collection:
|
elif not write_whole_collection:
|
||||||
vobject_item, = vobject_items
|
vobject_item, = vobject_items
|
||||||
item = radicale_item.Item(collection_path=collection_path,
|
item = radicale_item.Item(collection_path=collection_path,
|
||||||
vobject_item=vobject_item)
|
vobject_item=vobject_item)
|
||||||
item.prepare()
|
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)
|
items.append(item)
|
||||||
|
|
||||||
if write_whole_collection:
|
if write_whole_collection:
|
||||||
@@ -142,7 +179,7 @@ def prepare(vobject_items: List[vobject.base.Component], path: str,
|
|||||||
class ApplicationPartPut(ApplicationBase):
|
class ApplicationPartPut(ApplicationBase):
|
||||||
|
|
||||||
def do_PUT(self, environ: types.WSGIEnviron, base_prefix: str,
|
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."""
|
"""Manage PUT request."""
|
||||||
access = Access(self._rights, user, path)
|
access = Access(self._rights, user, path)
|
||||||
if not access.check("w"):
|
if not access.check("w"):
|
||||||
@@ -164,7 +201,10 @@ class ApplicationPartPut(ApplicationBase):
|
|||||||
logger.warning(
|
logger.warning(
|
||||||
"Bad PUT request on %r (read_components): %s", path, e, exc_info=True)
|
"Bad PUT request on %r (read_components): %s", path, e, exc_info=True)
|
||||||
if self._log_bad_put_request_content:
|
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:
|
else:
|
||||||
logger.debug("Bad PUT request content: suppressed by config/option [logging] bad_put_request_content")
|
logger.debug("Bad PUT request content: suppressed by config/option [logging] bad_put_request_content")
|
||||||
return httputils.BAD_REQUEST
|
return httputils.BAD_REQUEST
|
||||||
@@ -172,7 +212,8 @@ class ApplicationPartPut(ApplicationBase):
|
|||||||
prepared_props, prepared_exc_info) = prepare(
|
prepared_props, prepared_exc_info) = prepare(
|
||||||
vobject_items, path, content_type,
|
vobject_items, path, content_type,
|
||||||
bool(rights.intersect(access.permissions, "Ww")),
|
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"):
|
with self._storage.acquire_lock("w", user, path=path, request="PUT"):
|
||||||
item = next(iter(self._storage.discover(path)), None)
|
item = next(iter(self._storage.discover(path)), None)
|
||||||
@@ -207,6 +248,9 @@ class ApplicationPartPut(ApplicationBase):
|
|||||||
return httputils.NOT_ALLOWED
|
return httputils.NOT_ALLOWED
|
||||||
|
|
||||||
etag = environ.get("HTTP_IF_MATCH", "")
|
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:
|
if not item and etag:
|
||||||
# Etag asked but no item found: item has been removed
|
# 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)
|
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,
|
vobject_items, path, content_type,
|
||||||
bool(rights.intersect(access.permissions, "Ww")),
|
bool(rights.intersect(access.permissions, "Ww")),
|
||||||
bool(rights.intersect(access.parent_permissions, "w")),
|
bool(rights.intersect(access.parent_permissions, "w")),
|
||||||
|
self._max_resource_size,
|
||||||
tag, write_whole_collection)
|
tag, write_whole_collection)
|
||||||
props = prepared_props
|
props = prepared_props
|
||||||
if prepared_exc_info:
|
if prepared_exc_info:
|
||||||
logger.warning(
|
# Use OverflowError as flag for max_resource_size
|
||||||
"Bad PUT request on %r (prepare): %s", path, prepared_exc_info[1],
|
if prepared_exc_info[0] == OverflowError:
|
||||||
exc_info=prepared_exc_info)
|
return httputils.PRECONDITION_FAILED
|
||||||
return httputils.BAD_REQUEST
|
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:
|
if write_whole_collection:
|
||||||
try:
|
try:
|
||||||
etag = self._storage.create_collection(
|
col, replaced_items, new_item_hrefs = self._storage.create_collection(
|
||||||
path, prepared_items, props).etag
|
href=path,
|
||||||
|
items=prepared_items,
|
||||||
|
props=props)
|
||||||
for item in prepared_items:
|
for item in prepared_items:
|
||||||
hook_notification_item = HookNotificationItem(
|
# Try to grab the previously-existing item by href
|
||||||
HookNotificationItemTypes.UPSERT,
|
existing_item = replaced_items.get(item.href, None) # type: ignore
|
||||||
access.path,
|
if existing_item:
|
||||||
item.serialize()
|
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)
|
self._hook.notify(hook_notification_item)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@@ -267,11 +333,15 @@ class ApplicationPartPut(ApplicationBase):
|
|||||||
|
|
||||||
href = posixpath.basename(pathutils.strip_path(path))
|
href = posixpath.basename(pathutils.strip_path(path))
|
||||||
try:
|
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(
|
hook_notification_item = HookNotificationItem(
|
||||||
HookNotificationItemTypes.UPSERT,
|
notification_item_type=HookNotificationItemTypes.UPSERT,
|
||||||
access.path,
|
path=access.path,
|
||||||
prepared_item.serialize()
|
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)
|
self._hook.notify(hook_notification_item)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
@@ -294,7 +364,7 @@ class ApplicationPartPut(ApplicationBase):
|
|||||||
if (item and item.uid == prepared_item.uid):
|
if (item and item.uid == prepared_item.uid):
|
||||||
logger.debug("PUT request updated existing item %r", path)
|
logger.debug("PUT request updated existing item %r", path)
|
||||||
headers = {"ETag": etag}
|
headers = {"ETag": etag}
|
||||||
return client.NO_CONTENT, headers, None
|
return client.NO_CONTENT, headers, None, None
|
||||||
|
|
||||||
headers = {"ETag": etag}
|
headers = {"ETag": etag}
|
||||||
return client.CREATED, headers, None
|
return client.CREATED, headers, None, None
|
||||||
|
|||||||
@@ -149,13 +149,14 @@ def free_busy_report(base_prefix: str, path: str, xml_request: Optional[ET.Eleme
|
|||||||
def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element],
|
def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element],
|
||||||
collection: storage.BaseCollection, encoding: str,
|
collection: storage.BaseCollection, encoding: str,
|
||||||
unlock_storage_fn: Callable[[], None],
|
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]:
|
) -> Tuple[int, ET.Element]:
|
||||||
"""Read and answer REPORT requests that return XML.
|
"""Read and answer REPORT requests that return XML.
|
||||||
|
|
||||||
Read rfc3253-3.6 for info.
|
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"))
|
multistatus = ET.Element(xmlutils.make_clark("D:multistatus"))
|
||||||
if xml_request is None:
|
if xml_request is None:
|
||||||
return client.MULTI_STATUS, multistatus
|
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)
|
sync_token, names = collection.sync(old_sync_token)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
# Invalid sync token
|
# Invalid sync token
|
||||||
logger.warning("Client provided invalid sync token %r: %s",
|
logger.warning("Client provided invalid sync token for path %r (user %r from %s%s): %s",
|
||||||
old_sync_token, e, exc_info=True)
|
path, user, remote_addr, remote_useragent, e, exc_info=True)
|
||||||
# client.CONFLICT doesn't work with some clients (e.g. InfCloud)
|
# client.CONFLICT doesn't work with some clients (e.g. InfCloud)
|
||||||
return (client.FORBIDDEN,
|
return (client.FORBIDDEN,
|
||||||
xmlutils.webdav_error("D:valid-sync-token"))
|
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_)
|
filter_copy = copy.deepcopy(filter_)
|
||||||
|
|
||||||
if expand is not None:
|
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")):
|
for comp_filter in filter_copy.findall(".//" + xmlutils.make_clark("C:comp-filter")):
|
||||||
if comp_filter.get("name", "").upper() == "VCALENDAR":
|
if comp_filter.get("name", "").upper() == "VCALENDAR":
|
||||||
continue
|
continue
|
||||||
@@ -275,21 +277,15 @@ def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element],
|
|||||||
|
|
||||||
found_props = []
|
found_props = []
|
||||||
not_found_props = []
|
not_found_props = []
|
||||||
item_etag: str = ""
|
|
||||||
|
|
||||||
for prop in props:
|
for prop in props:
|
||||||
element = ET.Element(prop.tag)
|
element = ET.Element(prop.tag)
|
||||||
if prop.tag == xmlutils.make_clark("D:getetag"):
|
if prop.tag == xmlutils.make_clark("D:getcontenttype"):
|
||||||
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"):
|
|
||||||
element.text = xmlutils.get_content_type(item, encoding)
|
element.text = xmlutils.get_content_type(item, encoding)
|
||||||
found_props.append(element)
|
found_props.append(element)
|
||||||
elif prop.tag in (
|
elif prop.tag in (
|
||||||
xmlutils.make_clark("C:calendar-data"),
|
xmlutils.make_clark("C:calendar-data"),
|
||||||
|
xmlutils.make_clark("D:getetag"),
|
||||||
xmlutils.make_clark("CR:address-data")):
|
xmlutils.make_clark("CR:address-data")):
|
||||||
element.text = item.serialize()
|
element.text = item.serialize()
|
||||||
|
|
||||||
@@ -326,11 +322,24 @@ def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element],
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
n_vevents += n_vev
|
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:
|
else:
|
||||||
found_props.append(element)
|
if prop.tag == xmlutils.make_clark("D:getetag"):
|
||||||
if hasattr(item.vobject_item, "vevent_list"):
|
element.text = item.etag
|
||||||
n_vevents += len(item.vobject_item.vevent_list)
|
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
|
# Avoid DoS with too many events
|
||||||
if max_occurrence and n_vevents > max_occurrence:
|
if max_occurrence and n_vevents > max_occurrence:
|
||||||
raise ValueError("REPORT occurrences limit of {} hit"
|
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:
|
if found_props or not_found_props:
|
||||||
multistatus.append(xml_item_response(
|
multistatus.append(xml_item_response(
|
||||||
base_prefix, uri, found_props=found_props,
|
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
|
return client.MULTI_STATUS, multistatus
|
||||||
|
|
||||||
@@ -481,7 +490,7 @@ def _expand(
|
|||||||
|
|
||||||
if not vevent:
|
if not vevent:
|
||||||
# Create new instance from recurrence
|
# 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
|
# For all day events, the system timezone may influence the
|
||||||
# results, so use recurrence_dt
|
# results, so use recurrence_dt
|
||||||
@@ -679,7 +688,7 @@ def _find_overridden(
|
|||||||
def xml_item_response(base_prefix: str, href: str,
|
def xml_item_response(base_prefix: str, href: str,
|
||||||
found_props: Sequence[ET.Element] = (),
|
found_props: Sequence[ET.Element] = (),
|
||||||
not_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"))
|
response = ET.Element(xmlutils.make_clark("D:response"))
|
||||||
|
|
||||||
href_element = ET.Element(xmlutils.make_clark("D:href"))
|
href_element = ET.Element(xmlutils.make_clark("D:href"))
|
||||||
@@ -693,10 +702,6 @@ def xml_item_response(base_prefix: str, href: str,
|
|||||||
status = ET.Element(xmlutils.make_clark("D:status"))
|
status = ET.Element(xmlutils.make_clark("D:status"))
|
||||||
status.text = xmlutils.make_response(code)
|
status.text = xmlutils.make_response(code)
|
||||||
prop_element = ET.Element(xmlutils.make_clark("D:prop"))
|
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:
|
for prop in props:
|
||||||
prop_element.append(prop)
|
prop_element.append(prop)
|
||||||
propstat.append(prop_element)
|
propstat.append(prop_element)
|
||||||
@@ -750,6 +755,7 @@ def retrieve_items(
|
|||||||
else:
|
else:
|
||||||
yield item, False
|
yield item, False
|
||||||
if collection_requested:
|
if collection_requested:
|
||||||
|
logger.debug("TRACE/REPORT/retrieve_items: get_filtered")
|
||||||
yield from collection.get_filtered(filters)
|
yield from collection.get_filtered(filters)
|
||||||
|
|
||||||
|
|
||||||
@@ -785,7 +791,7 @@ def test_filter(collection_tag: str, item: radicale_item.Item,
|
|||||||
class ApplicationPartReport(ApplicationBase):
|
class ApplicationPartReport(ApplicationBase):
|
||||||
|
|
||||||
def do_REPORT(self, environ: types.WSGIEnviron, base_prefix: str,
|
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."""
|
"""Manage REPORT request."""
|
||||||
access = Access(self._rights, user, path)
|
access = Access(self._rights, user, path)
|
||||||
if not access.check("r"):
|
if not access.check("r"):
|
||||||
@@ -824,15 +830,15 @@ class ApplicationPartReport(ApplicationBase):
|
|||||||
"Bad REPORT request on %r: %s", path, e, exc_info=True)
|
"Bad REPORT request on %r: %s", path, e, exc_info=True)
|
||||||
return httputils.BAD_REQUEST
|
return httputils.BAD_REQUEST
|
||||||
headers = {"Content-Type": "text/calendar; charset=%s" % self._encoding}
|
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:
|
else:
|
||||||
try:
|
try:
|
||||||
status, xml_answer = xml_report(
|
status, xml_answer = xml_report(
|
||||||
base_prefix, path, xml_content, collection, self._encoding,
|
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:
|
except ValueError as e:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Bad REPORT request on %r: %s", path, e, exc_info=True)
|
"Bad REPORT request on %r: %s", path, e, exc_info=True)
|
||||||
return httputils.BAD_REQUEST
|
return httputils.BAD_REQUEST
|
||||||
headers = {"Content-Type": "text/xml; charset=%s" % self._encoding}
|
headers = {"Content-Type": "text/xml; charset=%s" % self._encoding}
|
||||||
return status, headers, self._xml_response(xml_answer)
|
return status, headers, self._xml_response(xml_answer), xmlutils.pretty_xml(xml_content)
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ Authentication module.
|
|||||||
|
|
||||||
Authentication is based on usernames and passwords. If something more
|
Authentication is based on usernames and passwords. If something more
|
||||||
advanced is needed an external WSGI server or reverse proxy can be used
|
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.
|
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
|
from radicale.log import logger
|
||||||
|
|
||||||
INTERNAL_TYPES: Sequence[str] = ("none", "remote_user", "http_x_remote_user",
|
INTERNAL_TYPES: Sequence[str] = ("none", "remote_user", "http_x_remote_user",
|
||||||
|
"http_remote_user",
|
||||||
"denyall",
|
"denyall",
|
||||||
"htpasswd",
|
"htpasswd",
|
||||||
"ldap",
|
"ldap",
|
||||||
@@ -59,11 +60,14 @@ CACHE_LOGIN_TYPES: Sequence[str] = (
|
|||||||
|
|
||||||
INSECURE_IF_NO_LOOPBACK_TYPES: Sequence[str] = (
|
INSECURE_IF_NO_LOOPBACK_TYPES: Sequence[str] = (
|
||||||
"remote_user",
|
"remote_user",
|
||||||
|
"http_remote_user",
|
||||||
"http_x_remote_user",
|
"http_x_remote_user",
|
||||||
)
|
)
|
||||||
|
|
||||||
AUTH_SOCKET_FAMILY: Sequence[str] = ("AF_UNIX", "AF_INET", "AF_INET6")
|
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":
|
def load(configuration: "config.Configuration") -> "BaseAuth":
|
||||||
"""Load the authentication module chosen in configuration."""
|
"""Load the authentication module chosen in configuration."""
|
||||||
@@ -91,6 +95,15 @@ def load(configuration: "config.Configuration") -> "BaseAuth":
|
|||||||
configuration)
|
configuration)
|
||||||
|
|
||||||
|
|
||||||
|
class AuthContext:
|
||||||
|
remote_addr: str
|
||||||
|
x_remote_addr: str
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.remote_addr = None
|
||||||
|
self.x_remote_addr = None
|
||||||
|
|
||||||
|
|
||||||
class BaseAuth:
|
class BaseAuth:
|
||||||
|
|
||||||
_ldap_groups: Set[str] = set([])
|
_ldap_groups: Set[str] = set([])
|
||||||
@@ -129,7 +142,7 @@ class BaseAuth:
|
|||||||
if self._lc_username is True and self._uc_username is True:
|
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")
|
raise RuntimeError("auth.lc_username and auth.uc_username cannot be enabled together")
|
||||||
self._auth_delay = configuration.get("auth", "delay")
|
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._failed_auth_delay = 0
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
# cache_successful_logins
|
# cache_successful_logins
|
||||||
@@ -187,6 +200,21 @@ class BaseAuth:
|
|||||||
|
|
||||||
raise NotImplementedError
|
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):
|
def _sleep_for_constant_exec_time(self, time_ns_begin: int):
|
||||||
"""Sleep some time to reach a constant execution time for failed logins
|
"""Sleep some time to reach a constant execution time for failed logins
|
||||||
|
|
||||||
@@ -216,7 +244,7 @@ class BaseAuth:
|
|||||||
time.sleep(sleep)
|
time.sleep(sleep)
|
||||||
|
|
||||||
@final
|
@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()
|
time_ns_begin = time.time_ns()
|
||||||
result_from_cache = False
|
result_from_cache = False
|
||||||
if self._lc_username:
|
if self._lc_username:
|
||||||
@@ -284,7 +312,7 @@ class BaseAuth:
|
|||||||
if result == "":
|
if result == "":
|
||||||
# verify login+password via configured backend
|
# verify login+password via configured backend
|
||||||
logger.debug("Login verification for user+password via backend: '%s'", login)
|
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 != "":
|
if result != "":
|
||||||
logger.debug("Login successful for user+password via backend: '%s'", login)
|
logger.debug("Login successful for user+password via backend: '%s'", login)
|
||||||
if digest == "":
|
if digest == "":
|
||||||
@@ -314,7 +342,7 @@ class BaseAuth:
|
|||||||
return (result, self._type)
|
return (result, self._type)
|
||||||
else:
|
else:
|
||||||
# self._cache_logins is False
|
# self._cache_logins is False
|
||||||
result = self._login(login, password)
|
result = self._login_ext(login, password, context)
|
||||||
if result == "":
|
if result == "":
|
||||||
self._sleep_for_constant_exec_time(time_ns_begin)
|
self._sleep_for_constant_exec_time(time_ns_begin)
|
||||||
return (result, self._type)
|
return (result, self._type)
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
import base64
|
import base64
|
||||||
import itertools
|
import itertools
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import socket
|
import socket
|
||||||
from contextlib import closing
|
from contextlib import closing
|
||||||
|
|
||||||
@@ -32,6 +33,9 @@ class Auth(auth.BaseAuth):
|
|||||||
self.timeout = 5
|
self.timeout = 5
|
||||||
self.request_id_gen = itertools.count(1)
|
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")
|
config_family = configuration.get("auth", "dovecot_connection_type")
|
||||||
if config_family == "AF_UNIX":
|
if config_family == "AF_UNIX":
|
||||||
self.family = socket.AF_UNIX
|
self.family = socket.AF_UNIX
|
||||||
@@ -46,7 +50,7 @@ class Auth(auth.BaseAuth):
|
|||||||
else:
|
else:
|
||||||
self.family = socket.AF_INET6
|
self.family = socket.AF_INET6
|
||||||
|
|
||||||
def _login(self, login, password):
|
def _login_ext(self, login, password, context):
|
||||||
"""Validate credentials.
|
"""Validate credentials.
|
||||||
|
|
||||||
Check if the ``login``/``password`` pair is valid according to Dovecot.
|
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
|
# Hence, we try to read just once with a buffer big
|
||||||
# enough to hold all of it.
|
# enough to hold all of it.
|
||||||
buf = sock.recv(1024)
|
buf = sock.recv(1024)
|
||||||
|
version_sent = False
|
||||||
while b'\n' in buf and not done:
|
while b'\n' in buf and not done:
|
||||||
line, buf = buf.split(b'\n', 1)
|
line, buf = buf.split(b'\n', 1)
|
||||||
parts = line.split(b'\t')
|
parts = line.split(b'\t')
|
||||||
@@ -110,6 +115,10 @@ class Auth(auth.BaseAuth):
|
|||||||
)
|
)
|
||||||
return ""
|
return ""
|
||||||
seen_part[0] += 1
|
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':
|
elif first == b'MECH':
|
||||||
supported_mechs.append(parts[0])
|
supported_mechs.append(parts[0])
|
||||||
seen_part[1] += 1
|
seen_part[1] += 1
|
||||||
@@ -140,7 +149,8 @@ class Auth(auth.BaseAuth):
|
|||||||
|
|
||||||
# Handshake
|
# Handshake
|
||||||
logger.debug("Sending auth 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())
|
sock.send(b'CPID\t%u\n' % os.getpid())
|
||||||
|
|
||||||
request_id = next(self.request_id_gen)
|
request_id = next(self.request_id_gen)
|
||||||
@@ -148,10 +158,19 @@ class Auth(auth.BaseAuth):
|
|||||||
"Authenticating with request id: '{}'"
|
"Authenticating with request id: '{}'"
|
||||||
.format(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(
|
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' %
|
b'\0%b\0%b' %
|
||||||
(login.encode(), password.encode())
|
(login.encode(), password.encode())
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
# Copyright © 2008 Pascal Halter
|
# Copyright © 2008 Pascal Halter
|
||||||
# Copyright © 2008-2017 Guillaume Ayoub
|
# Copyright © 2008-2017 Guillaume Ayoub
|
||||||
# Copyright © 2017-2019 Unrud <unrud@outlook.com>
|
# 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
|
# 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
|
# it under the terms of the GNU General Public License as published by
|
||||||
@@ -43,7 +43,7 @@ out-of-the-box:
|
|||||||
- SHA256 (htpasswd -2 ...)
|
- SHA256 (htpasswd -2 ...)
|
||||||
- SHA512 (htpasswd -5 ...)
|
- 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
|
- BCRYPT (htpasswd -B ...) -- Requires htpasswd 2.4.x
|
||||||
|
|
||||||
When argon2 is installed:
|
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 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):
|
class Auth(auth.BaseAuth):
|
||||||
@@ -120,12 +120,22 @@ class Auth(auth.BaseAuth):
|
|||||||
"The htpasswd encryption method 'bcrypt' or 'autodetect' requires "
|
"The htpasswd encryption method 'bcrypt' or 'autodetect' requires "
|
||||||
"the bcrypt module (entries found: %d)." % self._htpasswd_bcrypt_use) from e
|
"the bcrypt module (entries found: %d)." % self._htpasswd_bcrypt_use) from e
|
||||||
else:
|
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._encryption == "autodetect":
|
||||||
if self._htpasswd_bcrypt_use == 0:
|
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:
|
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":
|
if self._encryption == "bcrypt":
|
||||||
self._verify = functools.partial(self._bcrypt, bcrypt)
|
self._verify = functools.partial(self._bcrypt, bcrypt)
|
||||||
else:
|
else:
|
||||||
@@ -181,37 +191,28 @@ class Auth(auth.BaseAuth):
|
|||||||
return ("ARGON2", argon2.verify(password, hash_value.strip()))
|
return ("ARGON2", argon2.verify(password, hash_value.strip()))
|
||||||
|
|
||||||
def _md5apr1(self, hash_value: str, password: str) -> tuple[str, bool]:
|
def _md5apr1(self, hash_value: str, password: str) -> tuple[str, bool]:
|
||||||
if self._encryption == "autodetect" and len(hash_value) != 37:
|
return ("MD5-APR1", apr_md5_crypt.verify(password, hash_value.strip()))
|
||||||
return self._plain_fallback("MD5-APR1", hash_value, password)
|
|
||||||
else:
|
|
||||||
return ("MD5-APR1", apr_md5_crypt.verify(password, hash_value.strip()))
|
|
||||||
|
|
||||||
def _sha256(self, hash_value: str, password: str) -> tuple[str, bool]:
|
def _sha256(self, hash_value: str, password: str) -> tuple[str, bool]:
|
||||||
if self._encryption == "autodetect" and len(hash_value) != 63:
|
return ("SHA-256", sha256_crypt.verify(password, hash_value.strip()))
|
||||||
return self._plain_fallback("SHA-256", hash_value, password)
|
|
||||||
else:
|
|
||||||
return ("SHA-256", sha256_crypt.verify(password, hash_value.strip()))
|
|
||||||
|
|
||||||
def _sha512(self, hash_value: str, password: str) -> tuple[str, bool]:
|
def _sha512(self, hash_value: str, password: str) -> tuple[str, bool]:
|
||||||
if self._encryption == "autodetect" and len(hash_value) != 106:
|
return ("SHA-512", sha512_crypt.verify(password, hash_value.strip()))
|
||||||
return self._plain_fallback("SHA-512", hash_value, password)
|
|
||||||
else:
|
|
||||||
return ("SHA-512", sha512_crypt.verify(password, hash_value.strip()))
|
|
||||||
|
|
||||||
def _autodetect(self, hash_value: str, password: str) -> tuple[str, bool]:
|
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
|
# MD5-APR1
|
||||||
return self._md5apr1(hash_value, password)
|
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
|
# BCRYPT
|
||||||
return self._verify_bcrypt(hash_value, password)
|
return self._verify_bcrypt(hash_value, password)
|
||||||
elif re.match(r"^\$argon2(i|d|id)\$", hash_value):
|
elif re.match(r"^\$argon2(i|d|id)\$", hash_value):
|
||||||
# ARGON2
|
# ARGON2
|
||||||
return self._verify_argon2(hash_value, password)
|
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
|
# SHA-256
|
||||||
return self._sha256(hash_value, password)
|
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
|
# SHA-512
|
||||||
return self._sha512(hash_value, password)
|
return self._sha512(hash_value, password)
|
||||||
else:
|
else:
|
||||||
|
|||||||
36
radicale/auth/http_remote_user.py
Normal file
36
radicale/auth/http_remote_user.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# This file is part of Radicale - CalDAV and CardDAV server
|
||||||
|
# Copyright © 2025-2025 Peter Bieringer <pb@bieringer.de>
|
||||||
|
#
|
||||||
|
# This library is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This library is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with Radicale. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
"""
|
||||||
|
Authentication backend that takes the username from the
|
||||||
|
``HTTP_REMOTE_USER`` header.
|
||||||
|
|
||||||
|
It's intended for use with a reverse proxy. Be aware as this will be insecure
|
||||||
|
if the reverse proxy is not configured properly.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Tuple, Union
|
||||||
|
|
||||||
|
from radicale import types
|
||||||
|
from radicale.auth import none
|
||||||
|
|
||||||
|
|
||||||
|
class Auth(none.Auth):
|
||||||
|
|
||||||
|
def get_external_login(self, environ: types.WSGIEnviron) -> Union[
|
||||||
|
Tuple[()], Tuple[str, str]]:
|
||||||
|
return environ.get("HTTP_REMOTE_USER", ""), ""
|
||||||
@@ -64,10 +64,18 @@ class Auth(auth.BaseAuth):
|
|||||||
if self._security == "starttls":
|
if self._security == "starttls":
|
||||||
connection.starttls(ssl.create_default_context())
|
connection.starttls(ssl.create_default_context())
|
||||||
try:
|
try:
|
||||||
connection.authenticate(
|
if "AUTH=PLAIN" in connection.capabilities:
|
||||||
"PLAIN",
|
logger.debug("IMAP authentication PLAIN selected for user %r via %s:%d (security: %s)", login, self._host, self._port, self._security)
|
||||||
lambda _: "{0}\x00{0}\x00{1}".format(login, password).encode(),
|
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:
|
except imaplib.IMAP4.error as e:
|
||||||
logger.warning("IMAP authentication failed for user %r: %s", login, e, exc_info=False)
|
logger.warning("IMAP authentication failed for user %r: %s", login, e, exc_info=False)
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
# This file is part of Radicale - CalDAV and CardDAV server
|
# This file is part of Radicale - CalDAV and CardDAV server
|
||||||
# Copyright © 2022-2024 Peter Varkoly
|
# Copyright © 2022-2024 Peter Varkoly
|
||||||
# Copyright © 2024-2024 Peter Bieringer <pb@bieringer.de>
|
# 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
|
# 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
|
# 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/>.
|
# along with Radicale. If not, see <http://www.gnu.org/licenses/>.
|
||||||
"""
|
"""
|
||||||
Authentication backend that checks credentials with a LDAP server.
|
Authentication backend that checks credentials with a LDAP server.
|
||||||
Following parameters are needed in the configuration:
|
The following parameters are needed in the configuration:
|
||||||
ldap_uri The LDAP URL to the server like ldap://localhost
|
ldap_uri URI to the LDAP server
|
||||||
ldap_base The baseDN of the LDAP server
|
ldap_base Base DN of the LDAP server
|
||||||
ldap_reader_dn The DN of a LDAP user with read access to get the user accounts
|
ldap_reader_dn DN of an LDAP user with read access to get the user accounts
|
||||||
ldap_secret The password of the ldap_reader_dn
|
ldap_secret Password of the 'ldap_reader_dn'
|
||||||
ldap_secret_file The path of the file containing the password of the ldap_reader_dn
|
Better: use 'ldap_secret_file'!
|
||||||
ldap_filter The search filter to find the user to authenticate by the username
|
ldap_secret_file Path of the file containing the password of the 'ldap_reader_dn'
|
||||||
ldap_user_attribute The attribute to be used as username after authentication
|
ldap_filter Search filter to find the user DN to authenticate
|
||||||
ldap_groups_attribute The attribute containing group memberships in the LDAP user entry
|
The following parameters control TLS connections:
|
||||||
Following parameters controls SSL connections:
|
ldap_use_ssl Use ssl on the ldap connection.
|
||||||
ldap_use_ssl If ssl encryption should be used (to be deprecated)
|
Deprecated, use 'ldap_security' instead!
|
||||||
ldap_security The encryption mode to be used: *none*|tls|starttls
|
ldap_security Encryption mode to be used,
|
||||||
ldap_ssl_verify_mode The certificate verification mode. Works for tls and starttls. NONE, OPTIONAL, default is REQUIRED
|
one of: *none* | tls | starttls
|
||||||
ldap_ssl_ca_file
|
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
|
import ssl
|
||||||
@@ -47,10 +64,12 @@ class Auth(auth.BaseAuth):
|
|||||||
_ldap_attributes: list[str] = []
|
_ldap_attributes: list[str] = []
|
||||||
_ldap_user_attr: str
|
_ldap_user_attr: str
|
||||||
_ldap_groups_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_module_version: int = 3
|
||||||
_ldap_use_ssl: bool = False
|
|
||||||
_ldap_security: str = "none"
|
_ldap_security: str = "none"
|
||||||
_ldap_ssl_verify_mode: int = ssl.CERT_REQUIRED
|
_ldap_ssl_verify_mode: str = "REQUIRED"
|
||||||
_ldap_ssl_ca_file: str = ""
|
_ldap_ssl_ca_file: str = ""
|
||||||
|
|
||||||
def __init__(self, configuration: config.Configuration) -> None:
|
def __init__(self, configuration: config.Configuration) -> None:
|
||||||
@@ -61,16 +80,13 @@ class Auth(auth.BaseAuth):
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
try:
|
try:
|
||||||
import ldap
|
import ldap
|
||||||
|
import ldap.filter
|
||||||
self._ldap_module_version = 2
|
self._ldap_module_version = 2
|
||||||
self.ldap = ldap
|
self.ldap = ldap
|
||||||
except ImportError as e:
|
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")
|
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_uri = configuration.get("auth", "ldap_uri")
|
||||||
self._ldap_base = configuration.get("auth", "ldap_base")
|
self._ldap_base = configuration.get("auth", "ldap_base")
|
||||||
self._ldap_reader_dn = configuration.get("auth", "ldap_reader_dn")
|
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_filter = configuration.get("auth", "ldap_filter")
|
||||||
self._ldap_user_attr = configuration.get("auth", "ldap_user_attribute")
|
self._ldap_user_attr = configuration.get("auth", "ldap_user_attribute")
|
||||||
self._ldap_groups_attr = configuration.get("auth", "ldap_groups_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")
|
ldap_secret_file_path = configuration.get("auth", "ldap_secret_file")
|
||||||
if ldap_secret_file_path:
|
if ldap_secret_file_path:
|
||||||
with open(ldap_secret_file_path, 'r') as file:
|
with open(ldap_secret_file_path, 'r') as file:
|
||||||
self._ldap_secret = file.read().rstrip('\n')
|
self._ldap_secret = file.read().rstrip('\n')
|
||||||
if self._ldap_module_version == 3:
|
self._ldap_security = configuration.get("auth", "ldap_security")
|
||||||
self._ldap_use_ssl = configuration.get("auth", "ldap_use_ssl")
|
if self._ldap_security not in ("none", "tls", "starttls"):
|
||||||
self._ldap_security = configuration.get("auth", "ldap_security")
|
raise RuntimeError("Illegal value for config setting ´ldap_security'")
|
||||||
self._use_encryption = self._ldap_use_ssl or self._ldap_security in ("tls", "starttls")
|
ldap_use_ssl = configuration.get("auth", "ldap_use_ssl")
|
||||||
if self._ldap_use_ssl and self._ldap_security == "starttls":
|
if ldap_use_ssl:
|
||||||
raise RuntimeError("Cannot set both 'ldap_use_ssl = True' and 'ldap_security' = 'starttls'")
|
logger.warning("Configuration uses deprecated 'ldap_use_ssl': use 'ldap_security' ('none', 'tls', 'starttls') instead.")
|
||||||
if self._ldap_use_ssl:
|
if self._ldap_security == "starttls":
|
||||||
logger.warning("Configuration uses soon to be deprecated 'ldap_use_ssl', use 'ldap_security' ('none', 'tls', 'starttls') instead.")
|
raise RuntimeError("Deprecated config setting 'ldap_use_ssl = True' conflicts with 'ldap_security' = 'starttls'")
|
||||||
if self._use_encryption:
|
elif self._ldap_security != "tls":
|
||||||
self._ldap_ssl_ca_file = configuration.get("auth", "ldap_ssl_ca_file")
|
logger.warning("Update configuration: set 'ldap_security = tls' instead of deprecated 'ldap_use_ssl = True'")
|
||||||
tmp = configuration.get("auth", "ldap_ssl_verify_mode")
|
self._ldap_security = "tls"
|
||||||
if tmp == "NONE":
|
self._ldap_ssl_ca_file = configuration.get("auth", "ldap_ssl_ca_file")
|
||||||
self._ldap_ssl_verify_mode = ssl.CERT_NONE
|
self._ldap_ssl_verify_mode = configuration.get("auth", "ldap_ssl_verify_mode")
|
||||||
elif tmp == "OPTIONAL":
|
if self._ldap_ssl_verify_mode not in ("NONE", "OPTIONAL", "REQUIRED"):
|
||||||
self._ldap_ssl_verify_mode = ssl.CERT_OPTIONAL
|
raise RuntimeError("Illegal value for config setting ´ldap_ssl_verify_mode'")
|
||||||
|
|
||||||
logger.info("auth.ldap_uri : %r" % self._ldap_uri)
|
if self._ldap_uri.lower().startswith("ldaps://") and self._ldap_security not in ("tls", "starttls"):
|
||||||
logger.info("auth.ldap_base : %r" % self._ldap_base)
|
logger.info("Inferring 'ldap_security' = tls from 'ldap_uri' starting with 'ldaps://'")
|
||||||
logger.info("auth.ldap_reader_dn : %r" % self._ldap_reader_dn)
|
self._ldap_security = "tls"
|
||||||
logger.info("auth.ldap_filter : %r" % self._ldap_filter)
|
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:
|
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:
|
else:
|
||||||
logger.info("auth.ldap_user_attribute : (not provided)")
|
logger.info("auth.ldap_user_attribute : (not provided)")
|
||||||
if self._ldap_groups_attr:
|
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:
|
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:
|
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:
|
if self._ldap_secret:
|
||||||
logger.info("auth.ldap_secret : (from file)")
|
logger.info("auth.ldap_secret : (from file)")
|
||||||
else:
|
else:
|
||||||
logger.info("auth.ldap_secret_file_path: (not provided)")
|
logger.info("auth.ldap_secret_file_path : (not provided)")
|
||||||
if self._ldap_secret:
|
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:
|
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")
|
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_use_ssl : %s" % ldap_use_ssl)
|
||||||
logger.info("auth.ldap_security : %s" % self._ldap_security)
|
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)
|
||||||
logger.info("auth.ldap_ssl_verify_mode : %s" % self._ldap_ssl_verify_mode)
|
if self._ldap_ssl_ca_file:
|
||||||
if self._ldap_ssl_ca_file:
|
logger.info("auth.ldap_ssl_ca_file : %r" % self._ldap_ssl_ca_file)
|
||||||
logger.info("auth.ldap_ssl_ca_file : %r" % self._ldap_ssl_ca_file)
|
else:
|
||||||
else:
|
logger.info("auth.ldap_ssl_ca_file : (not provided)")
|
||||||
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"""
|
"""Extend attributes to to be returned in the user query"""
|
||||||
if self._ldap_groups_attr:
|
if self._ldap_groups_attr:
|
||||||
self._ldap_attributes.append(self._ldap_groups_attr)
|
self._ldap_attributes.append(self._ldap_groups_attr)
|
||||||
if self._ldap_user_attr:
|
if self._ldap_user_attr:
|
||||||
self._ldap_attributes.append(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:
|
def _login2(self, login: str, password: str) -> str:
|
||||||
try:
|
try:
|
||||||
"""Bind as reader dn"""
|
"""Bind as reader dn"""
|
||||||
logger.debug(f"_login2 {self._ldap_uri}, {self._ldap_reader_dn}")
|
logger.debug(f"_login2 {self._ldap_uri}, {self._ldap_reader_dn}")
|
||||||
conn = self.ldap.initialize(self._ldap_uri)
|
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)
|
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)
|
conn.simple_bind_s(self._ldap_reader_dn, self._ldap_secret)
|
||||||
"""Search for the dn of user to authenticate"""
|
"""Search for the dn of user to authenticate"""
|
||||||
escaped_login = self.ldap.filter.escape_filter_chars(login)
|
escaped_login = self.ldap.filter.escape_filter_chars(login)
|
||||||
@@ -160,34 +221,56 @@ class Auth(auth.BaseAuth):
|
|||||||
user_entry = res[0]
|
user_entry = res[0]
|
||||||
user_dn = user_entry[0]
|
user_dn = user_entry[0]
|
||||||
logger.debug(f"_login2 found LDAP user DN {user_dn}")
|
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:
|
except Exception as e:
|
||||||
raise RuntimeError(f"Invalid LDAP configuration:{e}")
|
raise RuntimeError(f"Invalid LDAP configuration:{e}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
"""Bind as user to authenticate"""
|
"""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)
|
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 self._ldap_user_attr:
|
||||||
if user_entry[1][self._ldap_user_attr]:
|
if user_entry[1][self._ldap_user_attr]:
|
||||||
tmplogin = user_entry[1][self._ldap_user_attr][0]
|
login = user_entry[1][self._ldap_user_attr][0]
|
||||||
login = tmplogin.decode('utf-8')
|
if isinstance(login, bytes):
|
||||||
|
login = login.decode('utf-8')
|
||||||
logger.debug(f"_login2 user set to: '{login}'")
|
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()
|
conn.unbind()
|
||||||
logger.debug(f"_login2 {login} successfully authenticated")
|
logger.debug(f"_login2 {login} successfully authenticated")
|
||||||
return login
|
return login
|
||||||
@@ -195,18 +278,21 @@ class Auth(auth.BaseAuth):
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
def _login3(self, login: str, password: str) -> str:
|
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"""
|
"""Connect the server"""
|
||||||
try:
|
try:
|
||||||
logger.debug(f"_login3 {self._ldap_uri}, {self._ldap_reader_dn}")
|
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)")
|
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 != "":
|
if self._ldap_ssl_ca_file != "":
|
||||||
tls = self.ldap3.Tls(
|
tls = self.ldap3.Tls(validate=verifyMode[self._ldap_ssl_verify_mode], ca_certs_file=self._ldap_ssl_ca_file)
|
||||||
validate=self._ldap_ssl_verify_mode,
|
if self._ldap_security == "tls":
|
||||||
ca_certs_file=self._ldap_ssl_ca_file
|
|
||||||
)
|
|
||||||
if self._ldap_use_ssl or self._ldap_security == "tls":
|
|
||||||
logger.debug("_login3 using ssl (reader)")
|
logger.debug("_login3 using ssl (reader)")
|
||||||
server = self.ldap3.Server(self._ldap_uri, use_ssl=True, tls=tls)
|
server = self.ldap3.Server(self._ldap_uri, use_ssl=True, tls=tls)
|
||||||
else:
|
else:
|
||||||
@@ -249,9 +335,42 @@ class Auth(auth.BaseAuth):
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
user_entry = conn.response[0]
|
user_entry = conn.response[0]
|
||||||
conn.unbind()
|
|
||||||
user_dn = user_entry['dn']
|
user_dn = user_entry['dn']
|
||||||
logger.debug(f"_login3 found LDAP user DN {user_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:
|
||||||
"""Try to bind as the user itself"""
|
"""Try to bind as the user itself"""
|
||||||
try:
|
try:
|
||||||
@@ -264,18 +383,18 @@ class Auth(auth.BaseAuth):
|
|||||||
if not conn.bind(read_server_info=False):
|
if not conn.bind(read_server_info=False):
|
||||||
logger.debug(f"_login3 user '{login}' cannot be found")
|
logger.debug(f"_login3 user '{login}' cannot be found")
|
||||||
return ""
|
return ""
|
||||||
tmp: list[str] = []
|
|
||||||
if self._ldap_groups_attr:
|
"""Get RDNs of groups' DNs"""
|
||||||
tmp = []
|
tmp = []
|
||||||
for g in user_entry['attributes'][self._ldap_groups_attr]:
|
for g in groupDNs:
|
||||||
"""Get group g's RDN's attribute value"""
|
try:
|
||||||
try:
|
rdns = self.ldap3.utils.dn.parse_dn(g)
|
||||||
rdns = self.ldap3.utils.dn.parse_dn(g)
|
tmp.append(rdns[0][1])
|
||||||
tmp.append(rdns[0][1])
|
except Exception:
|
||||||
except Exception:
|
tmp.append(g)
|
||||||
tmp.append(g)
|
self._ldap_groups = set(tmp)
|
||||||
self._ldap_groups = set(tmp)
|
logger.debug("_login3 LDAP groups of user: %s", ",".join(self._ldap_groups))
|
||||||
logger.debug("_login3 LDAP groups of user: %s", ",".join(self._ldap_groups))
|
|
||||||
if self._ldap_user_attr:
|
if self._ldap_user_attr:
|
||||||
if user_entry['attributes'][self._ldap_user_attr]:
|
if user_entry['attributes'][self._ldap_user_attr]:
|
||||||
if isinstance(user_entry['attributes'][self._ldap_user_attr], list):
|
if isinstance(user_entry['attributes'][self._ldap_user_attr], list):
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ DEFAULT_CONFIG_PATH: str = os.pathsep.join([
|
|||||||
"?/etc/radicale/config",
|
"?/etc/radicale/config",
|
||||||
"?~/.config/radicale/config"])
|
"?~/.config/radicale/config"])
|
||||||
|
|
||||||
|
PROFILING: Sequence[str] = ("per_request", "per_request_method", "none")
|
||||||
|
|
||||||
|
|
||||||
def positive_int(value: Any) -> int:
|
def positive_int(value: Any) -> int:
|
||||||
value = int(value)
|
value = int(value)
|
||||||
@@ -70,6 +72,12 @@ def logging_level(value: Any) -> str:
|
|||||||
return value
|
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:
|
def filepath(value: Any) -> str:
|
||||||
if not value:
|
if not value:
|
||||||
return ""
|
return ""
|
||||||
@@ -154,7 +162,11 @@ DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([
|
|||||||
"type": positive_int}),
|
"type": positive_int}),
|
||||||
("max_content_length", {
|
("max_content_length", {
|
||||||
"value": "100000000",
|
"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}),
|
"type": positive_int}),
|
||||||
("timeout", {
|
("timeout", {
|
||||||
"value": "30",
|
"value": "30",
|
||||||
@@ -253,6 +265,11 @@ DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([
|
|||||||
"value": "12345",
|
"value": "12345",
|
||||||
"help": "dovecot auth port",
|
"help": "dovecot auth port",
|
||||||
"type": int}),
|
"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", {
|
("realm", {
|
||||||
"value": "Radicale - Password Required",
|
"value": "Radicale - Password Required",
|
||||||
"help": "message displayed when a password is needed",
|
"help": "message displayed when a password is needed",
|
||||||
@@ -261,58 +278,70 @@ DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([
|
|||||||
"value": "1",
|
"value": "1",
|
||||||
"help": "incorrect authentication delay",
|
"help": "incorrect authentication delay",
|
||||||
"type": positive_float}),
|
"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", {
|
("ldap_uri", {
|
||||||
"value": "ldap://localhost",
|
"value": "ldap://localhost",
|
||||||
"help": "URI to the ldap server",
|
"help": "URI to the LDAP server",
|
||||||
"type": str}),
|
"type": str}),
|
||||||
("ldap_base", {
|
("ldap_base", {
|
||||||
"value": "",
|
"value": "",
|
||||||
"help": "LDAP base DN of the ldap server",
|
"help": "Base DN of the LDAP server",
|
||||||
"type": str}),
|
"type": str}),
|
||||||
("ldap_reader_dn", {
|
("ldap_reader_dn", {
|
||||||
"value": "",
|
"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}),
|
"type": str}),
|
||||||
("ldap_secret", {
|
("ldap_secret", {
|
||||||
"value": "",
|
"value": "",
|
||||||
"help": "the password of the ldap_reader_dn",
|
"help": "Password of ldap_reader_dn (better: use ldap_secret_file)",
|
||||||
"type": str}),
|
"type": str}),
|
||||||
("ldap_secret_file", {
|
("ldap_secret_file", {
|
||||||
"value": "",
|
"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}),
|
"type": str}),
|
||||||
("ldap_filter", {
|
("ldap_filter", {
|
||||||
"value": "(cn={0})",
|
"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}),
|
"type": str}),
|
||||||
("ldap_user_attribute", {
|
("ldap_user_attribute", {
|
||||||
"value": "",
|
"value": "",
|
||||||
"help": "the attribute to be used as username after authentication",
|
"help": "Attribute to be used as username after authentication",
|
||||||
"type": str}),
|
|
||||||
("ldap_groups_attribute", {
|
|
||||||
"value": "",
|
|
||||||
"help": "attribute to read the group memberships from",
|
|
||||||
"type": str}),
|
"type": str}),
|
||||||
("ldap_use_ssl", {
|
("ldap_use_ssl", {
|
||||||
"value": "False",
|
"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}),
|
"type": bool}),
|
||||||
("ldap_security", {
|
("ldap_security", {
|
||||||
"value": "none",
|
"value": "none",
|
||||||
"help": "the encryption mode to be used: *none*|tls|starttls",
|
"help": "Encryption mode to be used: *none*|tls|starttls",
|
||||||
"type": str}),
|
"type": str}),
|
||||||
("ldap_ssl_verify_mode", {
|
("ldap_ssl_verify_mode", {
|
||||||
"value": "REQUIRED",
|
"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}),
|
"type": str}),
|
||||||
("ldap_ssl_ca_file", {
|
("ldap_ssl_ca_file", {
|
||||||
"value": "",
|
"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}),
|
"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", {
|
("imap_host", {
|
||||||
"value": "localhost",
|
"value": "localhost",
|
||||||
"help": "IMAP server hostname: address|address:port|[address]:port|*localhost*",
|
"help": "IMAP server hostname: address|address:port|[address]:port|*localhost*",
|
||||||
@@ -413,6 +442,10 @@ DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([
|
|||||||
"value": "",
|
"value": "",
|
||||||
"help": "command that is run after changes to storage",
|
"help": "command that is run after changes to storage",
|
||||||
"type": str}),
|
"type": str}),
|
||||||
|
("strict_preconditions", {
|
||||||
|
"value": "False",
|
||||||
|
"help": "strict preconditions check on PUT",
|
||||||
|
"type": bool}),
|
||||||
("_filesystem_fsync", {
|
("_filesystem_fsync", {
|
||||||
"value": "True",
|
"value": "True",
|
||||||
"help": "sync all changes to filesystem during requests",
|
"help": "sync all changes to filesystem during requests",
|
||||||
@@ -477,7 +510,7 @@ DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([
|
|||||||
"value": "False",
|
"value": "False",
|
||||||
"help": "Send one email to all attendees, versus one email per attendee",
|
"help": "Send one email to all attendees, versus one email per attendee",
|
||||||
"type": bool}),
|
"type": bool}),
|
||||||
("added_template", {
|
("new_or_added_to_event_template", {
|
||||||
"value": """Hello $attendee_name,
|
"value": """Hello $attendee_name,
|
||||||
|
|
||||||
You have been added as an attendee to the following calendar event.
|
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
|
$event_location
|
||||||
|
|
||||||
This is an automated message. Please do not reply.""",
|
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}),
|
"type": str}),
|
||||||
("removed_template", {
|
("deleted_or_removed_from_event_template", {
|
||||||
"value": """Hello $attendee_name,
|
"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_title
|
||||||
$event_start_time - $event_end_time
|
$event_start_time - $event_end_time
|
||||||
$event_location
|
$event_location
|
||||||
|
|
||||||
This is an automated message. Please do not reply.""",
|
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}),
|
"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([
|
("web", OrderedDict([
|
||||||
("type", {
|
("type", {
|
||||||
@@ -537,6 +581,10 @@ This is an automated message. Please do not reply.""",
|
|||||||
"value": "False",
|
"value": "False",
|
||||||
"help": "log request content on level=debug",
|
"help": "log request content on level=debug",
|
||||||
"type": bool}),
|
"type": bool}),
|
||||||
|
("response_header_on_debug", {
|
||||||
|
"value": "False",
|
||||||
|
"help": "log response header on level=debug",
|
||||||
|
"type": bool}),
|
||||||
("response_content_on_debug", {
|
("response_content_on_debug", {
|
||||||
"value": "False",
|
"value": "False",
|
||||||
"help": "log response content on level=debug",
|
"help": "log response content on level=debug",
|
||||||
@@ -549,6 +597,30 @@ This is an automated message. Please do not reply.""",
|
|||||||
"value": "False",
|
"value": "False",
|
||||||
"help": "log storage cache action on level=debug",
|
"help": "log storage cache action on level=debug",
|
||||||
"type": bool}),
|
"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", {
|
("mask_passwords", {
|
||||||
"value": "True",
|
"value": "True",
|
||||||
"help": "mask passwords in logs",
|
"help": "mask passwords in logs",
|
||||||
|
|||||||
@@ -55,21 +55,26 @@ def _cleanup(path):
|
|||||||
|
|
||||||
class HookNotificationItem:
|
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.type = notification_item_type.value
|
||||||
self.point = _cleanup(path)
|
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):
|
def to_json(self):
|
||||||
return json.dumps(
|
return json.dumps(
|
||||||
self,
|
{**self.__dict__, "content": self.content},
|
||||||
default=lambda o: o.__dict__,
|
|
||||||
sort_keys=True,
|
sort_keys=True,
|
||||||
indent=4
|
indent=4
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class DeleteHookNotificationItem(HookNotificationItem):
|
|
||||||
def __init__(self, path, uid, old_content=None):
|
|
||||||
super().__init__(notification_item_type=HookNotificationItemTypes.DELETE, path=path, content=uid)
|
|
||||||
self.old_content = old_content
|
|
||||||
|
|||||||
@@ -16,6 +16,8 @@
|
|||||||
# along with Radicale. If not, see <http://www.gnu.org/licenses/>.
|
# along with Radicale. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
import enum
|
import enum
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
import re
|
import re
|
||||||
import smtplib
|
import smtplib
|
||||||
import ssl
|
import ssl
|
||||||
@@ -29,8 +31,8 @@ from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|||||||
|
|
||||||
import vobject
|
import vobject
|
||||||
|
|
||||||
from radicale.hook import (BaseHook, DeleteHookNotificationItem,
|
from radicale.hook import (BaseHook, HookNotificationItem,
|
||||||
HookNotificationItem, HookNotificationItemTypes)
|
HookNotificationItemTypes)
|
||||||
from radicale.log import logger
|
from radicale.log import logger
|
||||||
|
|
||||||
PLUGIN_CONFIG_SCHEMA = {
|
PLUGIN_CONFIG_SCHEMA = {
|
||||||
@@ -63,7 +65,7 @@ PLUGIN_CONFIG_SCHEMA = {
|
|||||||
"value": "",
|
"value": "",
|
||||||
"type": str
|
"type": str
|
||||||
},
|
},
|
||||||
"added_template": {
|
"new_or_added_to_event_template": {
|
||||||
"value": """Hello $attendee_name,
|
"value": """Hello $attendee_name,
|
||||||
|
|
||||||
You have been added as an attendee to the following calendar event.
|
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.""",
|
This is an automated message. Please do not reply.""",
|
||||||
"type": str
|
"type": str
|
||||||
},
|
},
|
||||||
"removed_template": {
|
"deleted_or_removed_from_event_template": {
|
||||||
"value": """Hello $attendee_name,
|
"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_title
|
||||||
$event_start_time - $event_end_time
|
$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)
|
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.
|
:param contents: The contents of the ICS file.
|
||||||
:return: True if the ICS file contains an event, False otherwise.
|
:return: True if the ICS file contains an event, False otherwise.
|
||||||
"""
|
"""
|
||||||
cal = vobject.readOne(contents)
|
return read_ics_event(contents) is not None
|
||||||
return cal.vevent is not None
|
|
||||||
|
|
||||||
|
|
||||||
def extract_email(value: str) -> Optional[str]:
|
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
|
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:
|
class ContentLine:
|
||||||
_key: str
|
_key: str
|
||||||
value: Any
|
value: Any
|
||||||
@@ -415,6 +495,11 @@ class Event(VComponent):
|
|||||||
"""Return the summary of the event."""
|
"""Return the summary of the event."""
|
||||||
return self._get_content_lines("SUMMARY")[0].value
|
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
|
@property
|
||||||
def location(self) -> Optional[str]:
|
def location(self) -> Optional[str]:
|
||||||
"""Return the location of the event."""
|
"""Return the location of the event."""
|
||||||
@@ -611,8 +696,9 @@ class EmailConfig:
|
|||||||
from_email: str,
|
from_email: str,
|
||||||
send_mass_emails: bool,
|
send_mass_emails: bool,
|
||||||
dryrun: bool,
|
dryrun: bool,
|
||||||
added_template: MessageTemplate,
|
new_or_added_to_event_template: MessageTemplate,
|
||||||
removed_template: MessageTemplate):
|
deleted_or_removed_from_event_template: MessageTemplate,
|
||||||
|
updated_event_template: MessageTemplate):
|
||||||
self.host = host
|
self.host = host
|
||||||
self.port = port
|
self.port = port
|
||||||
self.security = SMTP_SECURITY_TYPE_ENUM.from_string(value=security)
|
self.security = SMTP_SECURITY_TYPE_ENUM.from_string(value=security)
|
||||||
@@ -622,10 +708,9 @@ class EmailConfig:
|
|||||||
self.from_email = from_email
|
self.from_email = from_email
|
||||||
self.send_mass_emails = send_mass_emails
|
self.send_mass_emails = send_mass_emails
|
||||||
self.dryrun = dryrun
|
self.dryrun = dryrun
|
||||||
self.added_template = added_template
|
self.new_or_added_to_event_template = new_or_added_to_event_template
|
||||||
self.removed_template = removed_template
|
self.deleted_or_removed_from_event_template = deleted_or_removed_from_event_template
|
||||||
self.updated_template = added_template # Reuse added template for updated events
|
self.updated_event_template = updated_event_template
|
||||||
self.deleted_template = removed_template # Reuse removed template for deleted events
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
"""
|
"""
|
||||||
@@ -639,26 +724,17 @@ class EmailConfig:
|
|||||||
|
|
||||||
def send_added_email(self, attendees: List[Attendee], event: EmailEvent) -> bool:
|
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 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.
|
:return: True if the email was sent successfully, False otherwise.
|
||||||
"""
|
"""
|
||||||
ics_attachment = ICSEmailAttachment(file_content=event.ics_content, file_name=f"{event.file_name}")
|
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)
|
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:
|
def send_updated_email(self, attendees: List[Attendee], event: EmailEvent) -> bool:
|
||||||
"""
|
"""
|
||||||
Send a notification for updated events.
|
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}")
|
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)
|
ics_attachment=ics_attachment)
|
||||||
|
|
||||||
def send_deleted_email(self, attendees: List[Attendee], event: EmailEvent) -> bool:
|
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 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: 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)
|
ics_attachment=None)
|
||||||
|
|
||||||
def _prepare_and_send_email(self, template: MessageTemplate, attendees: List[Attendee],
|
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):
|
class Hook(BaseHook):
|
||||||
def __init__(self, configuration):
|
def __init__(self, configuration):
|
||||||
super().__init__(configuration)
|
super().__init__(configuration)
|
||||||
self.dryrun = self.configuration.get("hook", "dryrun")
|
|
||||||
self.email_config = EmailConfig(
|
self.email_config = EmailConfig(
|
||||||
host=self.configuration.get("hook", "smtp_server"),
|
host=self.configuration.get("hook", "smtp_server"),
|
||||||
port=self.configuration.get("hook", "smtp_port"),
|
port=self.configuration.get("hook", "smtp_port"),
|
||||||
@@ -836,14 +912,18 @@ class Hook(BaseHook):
|
|||||||
from_email=self.configuration.get("hook", "from_email"),
|
from_email=self.configuration.get("hook", "from_email"),
|
||||||
send_mass_emails=self.configuration.get("hook", "mass_email"),
|
send_mass_emails=self.configuration.get("hook", "mass_email"),
|
||||||
dryrun=self.configuration.get("hook", "dryrun"),
|
dryrun=self.configuration.get("hook", "dryrun"),
|
||||||
added_template=MessageTemplate(
|
new_or_added_to_event_template=MessageTemplate(
|
||||||
subject="You have been added to an event",
|
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(
|
deleted_or_removed_from_event_template=MessageTemplate(
|
||||||
subject="You have been removed from an event",
|
subject="An event you were invited to has been deleted",
|
||||||
body=self.configuration.get("hook", "removed_template")
|
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(
|
logger.info(
|
||||||
"Email hook initialized with configuration: %s",
|
"Email hook initialized with configuration: %s",
|
||||||
@@ -866,7 +946,7 @@ class Hook(BaseHook):
|
|||||||
:type notification_item: HookNotificationItem
|
:type notification_item: HookNotificationItem
|
||||||
:return: None
|
:return: None
|
||||||
"""
|
"""
|
||||||
if self.dryrun:
|
if self.email_config.dryrun:
|
||||||
logger.warning("Hook 'email': DRY-RUN received notification_item: %r", vars(notification_item))
|
logger.warning("Hook 'email': DRY-RUN received notification_item: %r", vars(notification_item))
|
||||||
else:
|
else:
|
||||||
logger.debug("Received notification_item: %r", vars(notification_item))
|
logger.debug("Received notification_item: %r", vars(notification_item))
|
||||||
@@ -881,50 +961,122 @@ class Hook(BaseHook):
|
|||||||
return
|
return
|
||||||
|
|
||||||
elif notification_type == HookNotificationItemTypes.UPSERT:
|
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_event(contents=new_item_str):
|
||||||
|
# If ICS file does not contain an event, do not send any notifications (regardless of previous content).
|
||||||
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.
|
|
||||||
logger.debug("No event found in the ICS file, skipping notification.")
|
logger.debug("No event found in the ICS file, skipping notification.")
|
||||||
return
|
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
|
if not previous_item_str:
|
||||||
attendees=email_event.event.attendees,
|
# Dealing with a completely new event, no previous content to compare against.
|
||||||
event=email_event
|
# Email every attendee about the new event.
|
||||||
)
|
logger.debug("New event detected, sending notifications to all attendees.")
|
||||||
if not email_success:
|
email_success: bool = self.email_config.send_added_email( # type: ignore
|
||||||
logger.error("Failed to send some or all email notifications for event: %s", email_event.event.uid)
|
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
|
return
|
||||||
|
|
||||||
elif notification_type == HookNotificationItemTypes.DELETE:
|
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
|
deleted_item_str: str = notification_item.old_content # type: ignore # A serialized vobject.base.Component
|
||||||
if not isinstance(notification_item, DeleteHookNotificationItem):
|
|
||||||
return
|
|
||||||
|
|
||||||
item_str: str = notification_item.old_content # type: ignore # A serialized vobject.base.Component
|
if not ics_contents_contains_event(contents=deleted_item_str):
|
||||||
|
|
||||||
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 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.")
|
logger.debug("No event found in the ICS file, skipping notification.")
|
||||||
return
|
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
|
email_success: bool = self.email_config.send_deleted_email( # type: ignore
|
||||||
attendees=email_event.event.attendees,
|
attendees=email_event.event.attendees,
|
||||||
event=email_event
|
event=email_event
|
||||||
)
|
)
|
||||||
if not email_success:
|
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
|
return
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ Helper functions for HTTP.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import contextlib
|
import contextlib
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import pathlib
|
import pathlib
|
||||||
import sys
|
import sys
|
||||||
@@ -31,7 +32,7 @@ import time
|
|||||||
from http import client
|
from http import client
|
||||||
from typing import List, Mapping, Union, cast
|
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
|
from radicale.log import logger
|
||||||
|
|
||||||
if sys.version_info < (3, 9):
|
if sys.version_info < (3, 9):
|
||||||
@@ -49,42 +50,42 @@ else:
|
|||||||
|
|
||||||
NOT_ALLOWED: types.WSGIResponse = (
|
NOT_ALLOWED: types.WSGIResponse = (
|
||||||
client.FORBIDDEN, (("Content-Type", "text/plain"),),
|
client.FORBIDDEN, (("Content-Type", "text/plain"),),
|
||||||
"Access to the requested resource forbidden.")
|
"Access to the requested resource forbidden.", None)
|
||||||
FORBIDDEN: types.WSGIResponse = (
|
FORBIDDEN: types.WSGIResponse = (
|
||||||
client.FORBIDDEN, (("Content-Type", "text/plain"),),
|
client.FORBIDDEN, (("Content-Type", "text/plain"),),
|
||||||
"Action on the requested resource refused.")
|
"Action on the requested resource refused.", None)
|
||||||
BAD_REQUEST: types.WSGIResponse = (
|
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 = (
|
NOT_FOUND: types.WSGIResponse = (
|
||||||
client.NOT_FOUND, (("Content-Type", "text/plain"),),
|
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 = (
|
CONFLICT: types.WSGIResponse = (
|
||||||
client.CONFLICT, (("Content-Type", "text/plain"),),
|
client.CONFLICT, (("Content-Type", "text/plain"),),
|
||||||
"Conflict in the request.")
|
"Conflict in the request.", None)
|
||||||
METHOD_NOT_ALLOWED: types.WSGIResponse = (
|
METHOD_NOT_ALLOWED: types.WSGIResponse = (
|
||||||
client.METHOD_NOT_ALLOWED, (("Content-Type", "text/plain"),),
|
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 = (
|
PRECONDITION_FAILED: types.WSGIResponse = (
|
||||||
client.PRECONDITION_FAILED,
|
client.PRECONDITION_FAILED,
|
||||||
(("Content-Type", "text/plain"),), "Precondition failed.")
|
(("Content-Type", "text/plain"),), "Precondition failed.", None)
|
||||||
REQUEST_TIMEOUT: types.WSGIResponse = (
|
REQUEST_TIMEOUT: types.WSGIResponse = (
|
||||||
client.REQUEST_TIMEOUT, (("Content-Type", "text/plain"),),
|
client.REQUEST_TIMEOUT, (("Content-Type", "text/plain"),),
|
||||||
"Connection timed out.")
|
"Connection timed out.", None)
|
||||||
REQUEST_ENTITY_TOO_LARGE: types.WSGIResponse = (
|
REQUEST_ENTITY_TOO_LARGE: types.WSGIResponse = (
|
||||||
client.REQUEST_ENTITY_TOO_LARGE, (("Content-Type", "text/plain"),),
|
client.REQUEST_ENTITY_TOO_LARGE, (("Content-Type", "text/plain"),),
|
||||||
"Request body too large.")
|
"Request body too large.", None)
|
||||||
REMOTE_DESTINATION: types.WSGIResponse = (
|
REMOTE_DESTINATION: types.WSGIResponse = (
|
||||||
client.BAD_GATEWAY, (("Content-Type", "text/plain"),),
|
client.BAD_GATEWAY, (("Content-Type", "text/plain"),),
|
||||||
"Remote destination not supported.")
|
"Remote destination not supported.", None)
|
||||||
DIRECTORY_LISTING: types.WSGIResponse = (
|
DIRECTORY_LISTING: types.WSGIResponse = (
|
||||||
client.FORBIDDEN, (("Content-Type", "text/plain"),),
|
client.FORBIDDEN, (("Content-Type", "text/plain"),),
|
||||||
"Directory listings are not supported.")
|
"Directory listings are not supported.", None)
|
||||||
INSUFFICIENT_STORAGE: types.WSGIResponse = (
|
INSUFFICIENT_STORAGE: types.WSGIResponse = (
|
||||||
client.INSUFFICIENT_STORAGE, (("Content-Type", "text/plain"),),
|
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 = (
|
INTERNAL_SERVER_ERROR: types.WSGIResponse = (
|
||||||
client.INTERNAL_SERVER_ERROR, (("Content-Type", "text/plain"),),
|
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"
|
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,
|
content = decode_request(configuration, environ,
|
||||||
read_raw_request_body(configuration, environ))
|
read_raw_request_body(configuration, environ))
|
||||||
if configuration.get("logging", "request_content_on_debug"):
|
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:
|
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
|
return content
|
||||||
|
|
||||||
|
|
||||||
def redirect(location: str, status: int = client.FOUND) -> types.WSGIResponse:
|
def redirect(location: str, status: int = client.FOUND) -> types.WSGIResponse:
|
||||||
return (status,
|
return (status,
|
||||||
{"Location": location, "Content-Type": "text/plain"},
|
{"Location": location, "Content-Type": "text/plain"},
|
||||||
"Redirected to %s" % location)
|
"Redirected to %s" % location, None)
|
||||||
|
|
||||||
|
|
||||||
def _serve_traversable(
|
def _serve_traversable(
|
||||||
@@ -214,7 +218,7 @@ def _serve_traversable(
|
|||||||
# adjust on the fly default main.js of InfCloud installation
|
# 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)
|
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'")
|
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(
|
def serve_resource(
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
# Copyright © 2008 Pascal Halter
|
# Copyright © 2008 Pascal Halter
|
||||||
# Copyright © 2014 Jean-Marc Martins
|
# Copyright © 2014 Jean-Marc Martins
|
||||||
# Copyright © 2008-2017 Guillaume Ayoub
|
# 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
|
# 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
|
# 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
|
import vobject
|
||||||
|
|
||||||
from radicale import storage # noqa:F401
|
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.item import filter as radicale_filter
|
||||||
from radicale.log import logger
|
from radicale.log import logger
|
||||||
|
|
||||||
@@ -55,6 +56,8 @@ def read_components(s: str) -> List[vobject.base.Component]:
|
|||||||
# * 0x0A Line Feed
|
# * 0x0A Line Feed
|
||||||
# * 0x0D Carriage Return
|
# * 0x0D Carriage Return
|
||||||
s = re.sub(r'[\x00-\x08\x0B\x0C\x0E-\x1F]', '', s)
|
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))
|
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())
|
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 Item:
|
||||||
"""Class for address book and calendar entries."""
|
"""Class for address book and calendar entries."""
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ else:
|
|||||||
TRIGGER = datetime | None
|
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.
|
"""Transform any date to a UTC datetime.
|
||||||
|
|
||||||
If ``d`` is a datetime without timezone, return as UTC datetime. If ``d``
|
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())
|
d = datetime.combine(d, datetime.min.time())
|
||||||
if not d.tzinfo:
|
if not d.tzinfo:
|
||||||
# NOTE: using vobject's UTC as it wasn't playing well with datetime's.
|
# 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
|
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(
|
for child, is_recurrence, recurrences in get_children(
|
||||||
vobject_item.vevent_list):
|
vobject_item.vevent_list):
|
||||||
# TODO: check if there's a timezone
|
# 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:
|
if child.rruleset:
|
||||||
dtstarts, infinity = getrruleset(child, recurrences)
|
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)
|
dtend = getattr(child, "dtend", None)
|
||||||
if dtend is not None:
|
if dtend is not None:
|
||||||
dtend = dtend.value
|
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()
|
original_duration = (dtend - dtstart).total_seconds()
|
||||||
dtend = date_to_datetime(dtend)
|
dtend = date_to_datetime(dtend)
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ import threading
|
|||||||
from tempfile import TemporaryDirectory
|
from tempfile import TemporaryDirectory
|
||||||
from typing import Iterator, Type, Union
|
from typing import Iterator, Type, Union
|
||||||
|
|
||||||
from radicale import storage, types
|
from radicale import storage, types, utils
|
||||||
|
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
import ctypes
|
import ctypes
|
||||||
@@ -286,9 +286,10 @@ def path_to_filesystem(root: str, sane_path: str) -> str:
|
|||||||
safe_path = os.path.join(safe_path, part)
|
safe_path = os.path.join(safe_path, part)
|
||||||
# Check for conflicting files (e.g. case-insensitive file systems
|
# Check for conflicting files (e.g. case-insensitive file systems
|
||||||
# or short names on Windows file systems)
|
# or short names on Windows file systems)
|
||||||
if (os.path.lexists(safe_path) and
|
if os.path.lexists(safe_path):
|
||||||
part not in (e.name for e in os.scandir(safe_path_parent))):
|
with os.scandir(safe_path_parent) as entries:
|
||||||
raise CollidingPathError(part)
|
if part not in (e.name for e in entries):
|
||||||
|
raise CollidingPathError(part)
|
||||||
return safe_path
|
return safe_path
|
||||||
|
|
||||||
|
|
||||||
@@ -320,13 +321,36 @@ def name_from_path(path: str, collection: "storage.BaseCollection") -> str:
|
|||||||
|
|
||||||
def path_permissions(path):
|
def path_permissions(path):
|
||||||
path = pathlib.Path(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):
|
def path_permissions_as_string(path):
|
||||||
try:
|
pp = path_permissions(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])
|
||||||
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)
|
|
||||||
return s
|
return s
|
||||||
|
|||||||
@@ -339,6 +339,7 @@ def serve(configuration: config.Configuration,
|
|||||||
# Fallback to busy waiting. (select(...) blocks SIGINT on Windows.)
|
# Fallback to busy waiting. (select(...) blocks SIGINT on Windows.)
|
||||||
select_timeout = 1.0
|
select_timeout = 1.0
|
||||||
max_connections: int = configuration.get("server", "max_connections")
|
max_connections: int = configuration.get("server", "max_connections")
|
||||||
|
logger.info("Maximum parallel connections: %d", max_connections)
|
||||||
logger.info("Radicale server ready")
|
logger.info("Radicale server ready")
|
||||||
logger.debug("TRACE: Radicale server ready ('logging/trace_on_debug' is active)")
|
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)")
|
logger.debug("TRACE/SERVER: Radicale server ready ('logging/trace_on_debug' is active - either with 'SERVER' or empty filter)")
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ Take a look at the class ``BaseCollection`` if you want to implement your own.
|
|||||||
import json
|
import json
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
from hashlib import sha256
|
from hashlib import sha256
|
||||||
from typing import (Callable, ContextManager, Iterable, Iterator, Mapping,
|
from typing import (Callable, ContextManager, Dict, Iterable, Iterator, List,
|
||||||
Optional, Sequence, Set, Tuple, Union, overload)
|
Mapping, Optional, Sequence, Set, Tuple, Union, overload)
|
||||||
|
|
||||||
import vobject
|
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
|
# NOTE: change only if cache structure is modified to avoid cache invalidation on update
|
||||||
CACHE_VERSION_RADICALE = "3.3.1"
|
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":
|
def load(configuration: "config.Configuration") -> "BaseStorage":
|
||||||
@@ -112,17 +113,18 @@ class BaseCollection:
|
|||||||
invalid.
|
invalid.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def hrefs_iter() -> Iterator[str]:
|
def hrefs_iter() -> Iterator[str]:
|
||||||
for item in self.get_all():
|
for item in self.get_all():
|
||||||
assert item.href
|
assert item.href
|
||||||
yield item.href
|
yield item.href
|
||||||
|
|
||||||
token = "http://radicale.org/ns/sync/%s" % self.etag.strip("\"")
|
token = "http://radicale.org/ns/sync/%s" % self.etag.strip("\"")
|
||||||
if old_token:
|
if old_token:
|
||||||
raise ValueError("Sync token are not supported")
|
raise ValueError("Sync token are not supported")
|
||||||
return token, hrefs_iter()
|
return token, hrefs_iter()
|
||||||
|
|
||||||
def get_multi(self, hrefs: Iterable[str]
|
def get_multi(self, hrefs: Iterable[str]) -> Iterable[Tuple[str, Optional["radicale_item.Item"]]]:
|
||||||
) -> Iterable[Tuple[str, Optional["radicale_item.Item"]]]:
|
|
||||||
"""Fetch multiple items.
|
"""Fetch multiple items.
|
||||||
|
|
||||||
It's not required to return the requested items in the correct order.
|
It's not required to return the requested items in the correct order.
|
||||||
@@ -175,8 +177,11 @@ class BaseCollection:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def upload(self, href: str, item: "radicale_item.Item") -> (
|
def upload(self, href: str, item: "radicale_item.Item") -> (
|
||||||
"radicale_item.Item"):
|
Tuple)["radicale_item.Item", Optional["radicale_item.Item"]]:
|
||||||
"""Upload a new or replace an existing item."""
|
"""Upload a new or replace an existing item.
|
||||||
|
|
||||||
|
Return the uploaded item and the old item if it was replaced.
|
||||||
|
"""
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
def delete(self, href: Optional[str] = None) -> None:
|
def delete(self, href: Optional[str] = None) -> None:
|
||||||
@@ -188,10 +193,12 @@ class BaseCollection:
|
|||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
@overload
|
@overload
|
||||||
def get_meta(self, key: None = None) -> Mapping[str, str]: ...
|
def get_meta(self, key: None = None) -> Mapping[str, str]:
|
||||||
|
...
|
||||||
|
|
||||||
@overload
|
@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
|
def get_meta(self, key: Optional[str] = None
|
||||||
) -> Union[Mapping[str, str], Optional[str]]:
|
) -> Union[Mapping[str, str], Optional[str]]:
|
||||||
@@ -293,8 +300,7 @@ class BaseStorage:
|
|||||||
|
|
||||||
def discover(
|
def discover(
|
||||||
self, path: str, depth: str = "0",
|
self, path: str, depth: str = "0",
|
||||||
child_context_manager: Optional[
|
child_context_manager: Optional[Callable[[str, Optional[str]], ContextManager[None]]] = None,
|
||||||
Callable[[str, Optional[str]], ContextManager[None]]] = None,
|
|
||||||
user_groups: Set[str] = set([])) -> Iterable["types.CollectionOrItem"]:
|
user_groups: Set[str] = set([])) -> Iterable["types.CollectionOrItem"]:
|
||||||
"""Discover a list of collections under the given ``path``.
|
"""Discover a list of collections under the given ``path``.
|
||||||
|
|
||||||
@@ -328,7 +334,8 @@ class BaseStorage:
|
|||||||
def create_collection(
|
def create_collection(
|
||||||
self, href: str,
|
self, href: str,
|
||||||
items: Optional[Iterable["radicale_item.Item"]] = None,
|
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.
|
"""Create a collection.
|
||||||
|
|
||||||
``href`` is the sanitized path.
|
``href`` is the sanitized path.
|
||||||
@@ -348,7 +355,7 @@ class BaseStorage:
|
|||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
@types.contextmanager
|
@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.
|
"""Set a context manager to lock the whole storage.
|
||||||
|
|
||||||
``mode`` must either be "r" for shared access or "w" for exclusive
|
``mode`` must either be "r" for shared access or "w" for exclusive
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
from tempfile import TemporaryDirectory
|
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
|
import radicale.item as radicale_item
|
||||||
from radicale import pathutils
|
from radicale import pathutils
|
||||||
@@ -30,9 +30,37 @@ from radicale.storage.multifilesystem.base import StorageBase
|
|||||||
|
|
||||||
class StoragePartCreateCollection(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,
|
def create_collection(self, href: str,
|
||||||
items: Optional[Iterable[radicale_item.Item]] = None,
|
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()
|
folder = self._get_collection_root_folder()
|
||||||
|
|
||||||
# Path should already be sanitized
|
# Path should already be sanitized
|
||||||
@@ -44,11 +72,14 @@ class StoragePartCreateCollection(StorageBase):
|
|||||||
self._makedirs_synced(filesystem_path)
|
self._makedirs_synced(filesystem_path)
|
||||||
return self._collection_class(
|
return self._collection_class(
|
||||||
cast(multifilesystem.Storage, self),
|
cast(multifilesystem.Storage, self),
|
||||||
pathutils.unstrip_path(sane_path, True))
|
pathutils.unstrip_path(sane_path, True)), {}, []
|
||||||
|
|
||||||
parent_dir = os.path.dirname(filesystem_path)
|
parent_dir = os.path.dirname(filesystem_path)
|
||||||
self._makedirs_synced(parent_dir)
|
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
|
# Create a temporary directory with an unsafe name
|
||||||
try:
|
try:
|
||||||
with TemporaryDirectory(prefix=".Radicale.tmp-", dir=parent_dir
|
with TemporaryDirectory(prefix=".Radicale.tmp-", dir=parent_dir
|
||||||
@@ -68,14 +99,20 @@ class StoragePartCreateCollection(StorageBase):
|
|||||||
col._upload_all_nonatomic(items, suffix=".vcf")
|
col._upload_all_nonatomic(items, suffix=".vcf")
|
||||||
|
|
||||||
if os.path.lexists(filesystem_path):
|
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)
|
pathutils.rename_exchange(tmp_filesystem_path, filesystem_path)
|
||||||
else:
|
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)
|
os.rename(tmp_filesystem_path, filesystem_path)
|
||||||
self._sync_directory(parent_dir)
|
self._sync_directory(parent_dir)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise ValueError("Failed to create collection %r as %r %s" %
|
raise ValueError("Failed to create collection %r as %r %s" %
|
||||||
(href, filesystem_path, e)) from e
|
(href, filesystem_path, e)) from e
|
||||||
|
|
||||||
|
# TODO: Return new-old pairs and just-new items (new vs updated)
|
||||||
return self._collection_class(
|
return self._collection_class(
|
||||||
cast(multifilesystem.Storage, self),
|
cast(multifilesystem.Storage, self),
|
||||||
pathutils.unstrip_path(sane_path, True))
|
pathutils.unstrip_path(sane_path, True)), replaced_items, new_item_hrefs
|
||||||
|
|||||||
@@ -68,8 +68,21 @@ class CollectionPartGet(CollectionPartCache, CollectionPartLock,
|
|||||||
else:
|
else:
|
||||||
path = os.path.join(self._filesystem_path, href)
|
path = os.path.join(self._filesystem_path, href)
|
||||||
try:
|
try:
|
||||||
with open(path, "rb") as f:
|
if self._storage._use_mtime_and_size_for_item_cache is True:
|
||||||
raw_text = f.read()
|
# 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):
|
except (FileNotFoundError, IsADirectoryError):
|
||||||
return None
|
return None
|
||||||
except PermissionError:
|
except PermissionError:
|
||||||
@@ -100,6 +113,12 @@ class CollectionPartGet(CollectionPartCache, CollectionPartLock,
|
|||||||
# Check if another process created the file in the meantime
|
# Check if another process created the file in the meantime
|
||||||
cache_content = self._load_item_cache(href, cache_hash)
|
cache_content = self._load_item_cache(href, cache_hash)
|
||||||
if cache_content is None:
|
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:
|
try:
|
||||||
vobject_items = radicale_item.read_components(
|
vobject_items = radicale_item.read_components(
|
||||||
raw_text.decode(self._encoding))
|
raw_text.decode(self._encoding))
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import errno
|
|||||||
import os
|
import os
|
||||||
import pickle
|
import pickle
|
||||||
import sys
|
import sys
|
||||||
from typing import Iterable, Iterator, TextIO, cast
|
from typing import Iterable, Iterator, Optional, TextIO, Tuple, cast
|
||||||
|
|
||||||
import radicale.item as radicale_item
|
import radicale.item as radicale_item
|
||||||
from radicale import pathutils
|
from radicale import pathutils
|
||||||
@@ -36,10 +36,11 @@ class CollectionPartUpload(CollectionPartGet, CollectionPartCache,
|
|||||||
CollectionPartHistory, CollectionBase):
|
CollectionPartHistory, CollectionBase):
|
||||||
|
|
||||||
def upload(self, href: str, item: radicale_item.Item
|
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):
|
if not pathutils.is_safe_filesystem_path_component(href):
|
||||||
raise pathutils.UnsafePathError(href)
|
raise pathutils.UnsafePathError(href)
|
||||||
path = pathutils.path_to_filesystem(self._filesystem_path, href)
|
path = pathutils.path_to_filesystem(self._filesystem_path, href)
|
||||||
|
old_item = self._get(href, verify_href=False)
|
||||||
try:
|
try:
|
||||||
with self._atomic_write(path, newline="") as fo: # type: ignore
|
with self._atomic_write(path, newline="") as fo: # type: ignore
|
||||||
f = cast(TextIO, fo)
|
f = cast(TextIO, fo)
|
||||||
@@ -67,7 +68,7 @@ class CollectionPartUpload(CollectionPartGet, CollectionPartCache,
|
|||||||
uploaded_item = self._get(href, verify_href=False)
|
uploaded_item = self._get(href, verify_href=False)
|
||||||
if uploaded_item is None:
|
if uploaded_item is None:
|
||||||
raise RuntimeError("Storage modified externally")
|
raise RuntimeError("Storage modified externally")
|
||||||
return uploaded_item
|
return uploaded_item, old_item
|
||||||
|
|
||||||
def _upload_all_nonatomic(self, items: Iterable[radicale_item.Item],
|
def _upload_all_nonatomic(self, items: Iterable[radicale_item.Item],
|
||||||
suffix: str = "") -> None:
|
suffix: str = "") -> None:
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
# This file is part of Radicale - CalDAV and CardDAV server
|
# This file is part of Radicale - CalDAV and CardDAV server
|
||||||
# Copyright © 2012-2017 Guillaume Ayoub
|
# 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
|
# 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
|
# it under the terms of the GNU General Public License as published by
|
||||||
@@ -22,6 +23,8 @@ Tests for Radicale.
|
|||||||
|
|
||||||
import base64
|
import base64
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
import shutil
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -35,7 +38,7 @@ import defusedxml.ElementTree as DefusedET
|
|||||||
import vobject
|
import vobject
|
||||||
|
|
||||||
import radicale
|
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]]
|
RESPONSES = Dict[str, Union[int, Dict[str, Tuple[int, ET.Element]], vobject.base.Component]]
|
||||||
|
|
||||||
@@ -51,6 +54,11 @@ class BaseTest:
|
|||||||
application: app.Application
|
application: app.Application
|
||||||
|
|
||||||
def setup_method(self) -> None:
|
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.configuration = config.load()
|
||||||
self.colpath = tempfile.mkdtemp()
|
self.colpath = tempfile.mkdtemp()
|
||||||
self.configure({
|
self.configure({
|
||||||
@@ -75,6 +83,12 @@ class BaseTest:
|
|||||||
if login is not None and not isinstance(login, str):
|
if login is not None and not isinstance(login, str):
|
||||||
raise TypeError("login argument must be %r, not %r" %
|
raise TypeError("login argument must be %r, not %r" %
|
||||||
(str, type(login)))
|
(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()}
|
environ: Dict[str, Any] = {k.upper(): v for k, v in kwargs.items()}
|
||||||
for k, v in environ.items():
|
for k, v in environ.items():
|
||||||
if not isinstance(v, str):
|
if not isinstance(v, str):
|
||||||
@@ -84,6 +98,12 @@ class BaseTest:
|
|||||||
if login:
|
if login:
|
||||||
environ["HTTP_AUTHORIZATION"] = "Basic " + base64.b64encode(
|
environ["HTTP_AUTHORIZATION"] = "Basic " + base64.b64encode(
|
||||||
login.encode(encoding)).decode()
|
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["REQUEST_METHOD"] = method.upper()
|
||||||
environ["PATH_INFO"] = path
|
environ["PATH_INFO"] = path
|
||||||
if data is not None:
|
if data is not None:
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
# This file is part of Radicale - CalDAV and CardDAV server
|
# 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
|
# 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
|
# 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,
|
def get(self, environ: types.WSGIEnviron, base_prefix: str, path: str,
|
||||||
user: str) -> types.WSGIResponse:
|
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,
|
def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str,
|
||||||
user: str) -> types.WSGIResponse:
|
user: str) -> types.WSGIResponse:
|
||||||
content = httputils.read_request_body(self.configuration, environ)
|
content = httputils.read_request_body(self.configuration, environ)
|
||||||
return client.OK, {"Content-Type": "text/plain"}, "echo:" + content
|
return client.OK, {"Content-Type": "text/plain"}, "echo:" + content, None
|
||||||
|
|||||||
16
radicale/tests/static/broken-vcards.vcf
Normal file
16
radicale/tests/static/broken-vcards.vcf
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
BEGIN:VCARD
|
||||||
|
VERSION:3.0
|
||||||
|
PRODID:-//Inverse inc.//SOGo Connector 1.0//EN
|
||||||
|
UID:C68582D2-2E60-0001-C2C0-000000000000.vcf
|
||||||
|
X-MOZILLA-HTML:FALSE
|
||||||
|
EMAIL;TYPE=work:test-misses-N-or-FN@example.com
|
||||||
|
X-RADICALE-NAME:C68582D2-2E60-0001-C2C0-000000000000.vcf
|
||||||
|
END:VCARD
|
||||||
|
BEGIN:VCARD
|
||||||
|
VERSION:3.0
|
||||||
|
PRODID:-//Inverse inc.//SOGo Connector 1.0//EN
|
||||||
|
UID:C68582D2-2E60-0001-C2C0-000000000001.vcf
|
||||||
|
X-MOZILLA-HTML:FALSE
|
||||||
|
EMAIL;TYPE=work:test-misses-N-or-FN@example1.com
|
||||||
|
X-RADICALE-NAME:C68582D2-2E60-0001-C2C0-000000000001.vcf
|
||||||
|
END:VCARD
|
||||||
16
radicale/tests/static/broken-vcards2-no_uid.vcf
Normal file
16
radicale/tests/static/broken-vcards2-no_uid.vcf
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
BEGIN:VCARD
|
||||||
|
VERSION:3.0
|
||||||
|
PRODID:-//Inverse inc.//SOGo Connector 1.0//EN
|
||||||
|
UID:C68582D2-2E60-0001-C2C0-000000000000.vcf
|
||||||
|
X-MOZILLA-HTML:FALSE
|
||||||
|
EMAIL;TYPE=work:test-misses-N-or-FN@example.com
|
||||||
|
FN:Test Example
|
||||||
|
X-RADICALE-NAME:C68582D2-2E60-0001-C2C0-000000000000.vcf
|
||||||
|
END:VCARD
|
||||||
|
BEGIN:VCARD
|
||||||
|
VERSION:3.0
|
||||||
|
PRODID:-//Inverse inc.//SOGo Connector 1.0//EN
|
||||||
|
X-MOZILLA-HTML:FALSE
|
||||||
|
EMAIL;TYPE=work:test-misses-N-or-FN@example1.com
|
||||||
|
X-RADICALE-NAME:C68582D2-2E60-0001-C2C0-000000000001.vcf
|
||||||
|
END:VCARD
|
||||||
17
radicale/tests/static/broken-vcards2.vcf
Normal file
17
radicale/tests/static/broken-vcards2.vcf
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
BEGIN:VCARD
|
||||||
|
VERSION:3.0
|
||||||
|
PRODID:-//Inverse inc.//SOGo Connector 1.0//EN
|
||||||
|
UID:C68582D2-2E60-0001-C2C0-000000000000.vcf
|
||||||
|
X-MOZILLA-HTML:FALSE
|
||||||
|
EMAIL;TYPE=work:test-misses-N-or-FN@example.com
|
||||||
|
FN:Test Example
|
||||||
|
X-RADICALE-NAME:C68582D2-2E60-0001-C2C0-000000000000.vcf
|
||||||
|
END:VCARD
|
||||||
|
BEGIN:VCARD
|
||||||
|
VERSION:3.0
|
||||||
|
PRODID:-//Inverse inc.//SOGo Connector 1.0//EN
|
||||||
|
UID:C68582D2-2E60-0001-C2C0-000000000001.vcf
|
||||||
|
X-MOZILLA-HTML:FALSE
|
||||||
|
EMAIL;TYPE=work:test-misses-N-or-FN@example1.com
|
||||||
|
X-RADICALE-NAME:C68582D2-2E60-0001-C2C0-000000000001.vcf
|
||||||
|
END:VCARD
|
||||||
25
radicale/tests/static/broken-vevents.ics
Normal file
25
radicale/tests/static/broken-vevents.ics
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
BEGIN:VCALENDAR
|
||||||
|
PRODID:-//Radicale//NONSGML Radicale Server//EN
|
||||||
|
VERSION:2.0
|
||||||
|
BEGIN:VEVENT
|
||||||
|
CREATED:20160725T060147Z
|
||||||
|
LAST-MODIFIED:20160727T193435Z
|
||||||
|
DTSTAMP:20160727T193435Z
|
||||||
|
UID:040000008200E00074C5B7101A82E00800000000
|
||||||
|
SUMMARY:Good ICS
|
||||||
|
STATUS:CONFIRMED
|
||||||
|
X-MOZ-LASTACK:20160727T193435Z
|
||||||
|
DTSTART;TZID=Europe/Budapest:20160727T170000
|
||||||
|
DTEND;TZID=Europe/Budapest:20160727T223000
|
||||||
|
CLASS:PUBLIC
|
||||||
|
X-LIC-ERROR:No value for LOCATION property. Removing entire property:
|
||||||
|
END:VEVENT
|
||||||
|
BEGIN:VEVENT
|
||||||
|
CREATED:20160725T060147Z
|
||||||
|
LAST-MODIFIED:20160727T193435Z
|
||||||
|
DTSTAMP:20160727T193435Z
|
||||||
|
UID:040000008200E00074C5B7101A82E00800000001
|
||||||
|
CLASS:PUBLIC
|
||||||
|
X-LIC-ERROR:No value for LOCATION property. Removing entire property:
|
||||||
|
END:VEVENT
|
||||||
|
END:VCALENDAR
|
||||||
7
radicale/tests/static/contact1_v4.vcf
Normal file
7
radicale/tests/static/contact1_v4.vcf
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
BEGIN:VCARD
|
||||||
|
VERSION:4.0
|
||||||
|
UID:contact1
|
||||||
|
N:Contact;;;;
|
||||||
|
FN:Contact
|
||||||
|
NICKNAME:test
|
||||||
|
END:VCARD
|
||||||
12
radicale/tests/static/contact_multiple_v4.vcf
Normal file
12
radicale/tests/static/contact_multiple_v4.vcf
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
BEGIN:VCARD
|
||||||
|
VERSION:4.0
|
||||||
|
UID:contact1
|
||||||
|
N:Contact1;;;;
|
||||||
|
FN:Contact1
|
||||||
|
END:VCARD
|
||||||
|
BEGIN:VCARD
|
||||||
|
VERSION:4.0
|
||||||
|
UID:contact2
|
||||||
|
N:Contact2;;;;
|
||||||
|
FN:Contact2
|
||||||
|
END:VCARD
|
||||||
8
radicale/tests/static/contact_photo_with_data_uri_v4.vcf
Normal file
8
radicale/tests/static/contact_photo_with_data_uri_v4.vcf
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
BEGIN:VCARD
|
||||||
|
VERSION:4.0
|
||||||
|
UID:contact
|
||||||
|
N:Contact;;;;
|
||||||
|
FN:Contact
|
||||||
|
NICKNAME:test
|
||||||
|
PHOTO:data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAAD0lEQVQIHQEEAPv/AP///wX+Av4DfRnGAAAAAElFTkSuQmCC
|
||||||
|
END:VCARD
|
||||||
30
radicale/tests/static/event_issue1812_getetag.ics
Normal file
30
radicale/tests/static/event_issue1812_getetag.ics
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
BEGIN:VCALENDAR
|
||||||
|
VERSION:2.0
|
||||||
|
PRODID:-//algoo.fr//NONSGML Open Calendar v0.9//EN
|
||||||
|
BEGIN:VTIMEZONE
|
||||||
|
TZID:Europe/Paris
|
||||||
|
LAST-MODIFIED:20250523T094234Z
|
||||||
|
BEGIN:STANDARD
|
||||||
|
DTSTART:19701025T030000Z
|
||||||
|
RRULE:BYDAY=-1SU;BYMONTH=10;FREQ=YEARLY
|
||||||
|
TZNAME:CET
|
||||||
|
TZOFFSETFROM:+0200
|
||||||
|
TZOFFSETTO:+0100
|
||||||
|
END:STANDARD
|
||||||
|
BEGIN:DAYLIGHT
|
||||||
|
DTSTART:19700329T020000Z
|
||||||
|
RRULE:BYDAY=-1SU;BYMONTH=3;FREQ=YEARLY
|
||||||
|
TZNAME:CEST
|
||||||
|
TZOFFSETFROM:+0100
|
||||||
|
TZOFFSETTO:+0200
|
||||||
|
END:DAYLIGHT
|
||||||
|
END:VTIMEZONE
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:070a3478-4411-4364-844d-26f3542fc364
|
||||||
|
DTSTART;TZID=Europe/Paris;VALUE=DATE:20250716
|
||||||
|
DTEND;TZID=Europe/Paris;VALUE=DATE:20250717
|
||||||
|
DTSTAMP;VALUE=DATE-TIME:20250723T080354Z
|
||||||
|
SEQUENCE:1
|
||||||
|
SUMMARY:Filtered event
|
||||||
|
END:VEVENT
|
||||||
|
END:VCALENDAR
|
||||||
14
radicale/tests/static/event_issue1847_1.ics
Normal file
14
radicale/tests/static/event_issue1847_1.ics
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
BEGIN:VCALENDAR
|
||||||
|
VERSION:2.0
|
||||||
|
PRODID:-//algoo.fr//NONSGML Open Calendar v0.9//EN
|
||||||
|
BEGIN:VEVENT
|
||||||
|
CREATED:20250814T153429Z
|
||||||
|
LAST-MODIFIED:20250814T153503Z
|
||||||
|
DTSTAMP:20250814T153503Z
|
||||||
|
UID:f91964cb-53ca-4942-8811-c38f076f4328
|
||||||
|
SUMMARY:error
|
||||||
|
DTSTART:20250814T180000
|
||||||
|
DTEND;TZID=Europe/Brussels:20250814T190000
|
||||||
|
TRANSP:OPAQUE
|
||||||
|
END:VEVENT
|
||||||
|
END:VCALENDAR
|
||||||
14
radicale/tests/static/event_issue1847_2.ics
Normal file
14
radicale/tests/static/event_issue1847_2.ics
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
BEGIN:VCALENDAR
|
||||||
|
VERSION:2.0
|
||||||
|
PRODID:-//algoo.fr//NONSGML Open Calendar v0.9//EN
|
||||||
|
BEGIN:VEVENT
|
||||||
|
CREATED:20250814T153429Z
|
||||||
|
LAST-MODIFIED:20250814T153503Z
|
||||||
|
DTSTAMP:20250814T153503Z
|
||||||
|
UID:f91964cb-53ca-4942-8811-c38f076f4328
|
||||||
|
SUMMARY:error
|
||||||
|
DTSTART;TZID=Europe/Brussels:20250814T180000
|
||||||
|
DTEND:20250814T190000
|
||||||
|
TRANSP:OPAQUE
|
||||||
|
END:VEVENT
|
||||||
|
END:VCALENDAR
|
||||||
29
radicale/tests/static/event_issue1880_1.ics
Normal file
29
radicale/tests/static/event_issue1880_1.ics
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
BEGIN:VCALENDAR
|
||||||
|
VERSION:2.0
|
||||||
|
PRODID:-//algoo.fr//NONSGML Open Calendar v0.9//EN
|
||||||
|
BEGIN:VTIMEZONE
|
||||||
|
TZID:Europe/Paris
|
||||||
|
LAST-MODIFIED:20250523T094234Z
|
||||||
|
BEGIN:STANDARD
|
||||||
|
DTSTART:19701025T030000Z
|
||||||
|
RRULE:BYDAY=-1SU;BYMONTH=10;FREQ=YEARLY
|
||||||
|
TZNAME:CET
|
||||||
|
TZOFFSETFROM:+0200
|
||||||
|
TZOFFSETTO:+0100
|
||||||
|
END:STANDARD
|
||||||
|
BEGIN:DAYLIGHT
|
||||||
|
DTSTART:19700329T020000Z
|
||||||
|
RRULE:BYDAY=-1SU;BYMONTH=3;FREQ=YEARLY
|
||||||
|
TZNAME:CEST
|
||||||
|
TZOFFSETFROM:+0100
|
||||||
|
TZOFFSETTO:+0200
|
||||||
|
END:DAYLIGHT
|
||||||
|
END:VTIMEZONE
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:f5b69821-addc-4010-9ab8-891df1c33c01
|
||||||
|
DTSTART;TZID=Europe/Paris;VALUE=DATE-TIME:20250925T093000
|
||||||
|
DTEND;TZID=Europe/Paris;VALUE=DATE-TIME:20250925T140000
|
||||||
|
DTSTAMP:20250923T114003Z
|
||||||
|
SUMMARY:event from opencalendar
|
||||||
|
END:VEVENT
|
||||||
|
END:VCALENDAR
|
||||||
396
radicale/tests/static/event_issue1880_2.ics
Normal file
396
radicale/tests/static/event_issue1880_2.ics
Normal file
@@ -0,0 +1,396 @@
|
|||||||
|
BEGIN:VCALENDAR
|
||||||
|
VERSION:2.0
|
||||||
|
PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
|
||||||
|
BEGIN:VTIMEZONE
|
||||||
|
TZID:Europe/Paris
|
||||||
|
BEGIN:STANDARD
|
||||||
|
DTSTART:19110311T000000
|
||||||
|
RDATE:19110311T000000
|
||||||
|
TZNAME:Europe/Paris(STD)
|
||||||
|
TZOFFSETFROM:+000921
|
||||||
|
TZOFFSETTO:+000000
|
||||||
|
END:STANDARD
|
||||||
|
BEGIN:STANDARD
|
||||||
|
DTSTART:19161002T000000
|
||||||
|
RDATE:19161002T000000
|
||||||
|
TZNAME:Europe/Paris(STD)
|
||||||
|
TZOFFSETFROM:+010000
|
||||||
|
TZOFFSETTO:+000000
|
||||||
|
END:STANDARD
|
||||||
|
BEGIN:STANDARD
|
||||||
|
DTSTART:19171008T000000
|
||||||
|
RDATE:19171008T000000
|
||||||
|
TZNAME:Europe/Paris(STD)
|
||||||
|
TZOFFSETFROM:+010000
|
||||||
|
TZOFFSETTO:+000000
|
||||||
|
END:STANDARD
|
||||||
|
BEGIN:STANDARD
|
||||||
|
DTSTART:19181007T000000
|
||||||
|
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=1MO;UNTIL=19191006T000000
|
||||||
|
TZNAME:Europe/Paris(STD)
|
||||||
|
TZOFFSETFROM:+010000
|
||||||
|
TZOFFSETTO:+000000
|
||||||
|
END:STANDARD
|
||||||
|
BEGIN:STANDARD
|
||||||
|
DTSTART:19201024T000000
|
||||||
|
RDATE:19201024T000000
|
||||||
|
TZNAME:Europe/Paris(STD)
|
||||||
|
TZOFFSETFROM:+010000
|
||||||
|
TZOFFSETTO:+000000
|
||||||
|
END:STANDARD
|
||||||
|
BEGIN:STANDARD
|
||||||
|
DTSTART:19211026T000000
|
||||||
|
RDATE:19211026T000000
|
||||||
|
TZNAME:Europe/Paris(STD)
|
||||||
|
TZOFFSETFROM:+010000
|
||||||
|
TZOFFSETTO:+000000
|
||||||
|
END:STANDARD
|
||||||
|
BEGIN:STANDARD
|
||||||
|
DTSTART:19221008T000000
|
||||||
|
RDATE:19221008T000000
|
||||||
|
TZNAME:Europe/Paris(STD)
|
||||||
|
TZOFFSETFROM:+010000
|
||||||
|
TZOFFSETTO:+000000
|
||||||
|
END:STANDARD
|
||||||
|
BEGIN:STANDARD
|
||||||
|
DTSTART:19231007T000000
|
||||||
|
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=1SU;UNTIL=19321002T000000
|
||||||
|
TZNAME:Europe/Paris(STD)
|
||||||
|
TZOFFSETFROM:+010000
|
||||||
|
TZOFFSETTO:+000000
|
||||||
|
END:STANDARD
|
||||||
|
BEGIN:STANDARD
|
||||||
|
DTSTART:19331008T000000
|
||||||
|
RDATE:19331008T000000
|
||||||
|
TZNAME:Europe/Paris(STD)
|
||||||
|
TZOFFSETFROM:+010000
|
||||||
|
TZOFFSETTO:+000000
|
||||||
|
END:STANDARD
|
||||||
|
BEGIN:STANDARD
|
||||||
|
DTSTART:19341007T000000
|
||||||
|
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=1SU;UNTIL=19381002T000000
|
||||||
|
TZNAME:Europe/Paris(STD)
|
||||||
|
TZOFFSETFROM:+010000
|
||||||
|
TZOFFSETTO:+000000
|
||||||
|
END:STANDARD
|
||||||
|
BEGIN:STANDARD
|
||||||
|
DTSTART:19391119T000000
|
||||||
|
RDATE:19391119T000000
|
||||||
|
TZNAME:Europe/Paris(STD)
|
||||||
|
TZOFFSETFROM:+010000
|
||||||
|
TZOFFSETTO:+000000
|
||||||
|
END:STANDARD
|
||||||
|
BEGIN:STANDARD
|
||||||
|
DTSTART:19421102T030000
|
||||||
|
RDATE:19421102T030000
|
||||||
|
TZNAME:Europe/Paris(STD)
|
||||||
|
TZOFFSETFROM:+020000
|
||||||
|
TZOFFSETTO:+010000
|
||||||
|
END:STANDARD
|
||||||
|
BEGIN:STANDARD
|
||||||
|
DTSTART:19431004T030000
|
||||||
|
RDATE:19431004T030000
|
||||||
|
TZNAME:Europe/Paris(STD)
|
||||||
|
TZOFFSETFROM:+020000
|
||||||
|
TZOFFSETTO:+010000
|
||||||
|
END:STANDARD
|
||||||
|
BEGIN:STANDARD
|
||||||
|
DTSTART:19450916T030000
|
||||||
|
RDATE:19450916T030000
|
||||||
|
TZNAME:Europe/Paris(STD)
|
||||||
|
TZOFFSETFROM:+020000
|
||||||
|
TZOFFSETTO:+010000
|
||||||
|
END:STANDARD
|
||||||
|
BEGIN:STANDARD
|
||||||
|
DTSTART:19760926T010000
|
||||||
|
RDATE:19760926T010000
|
||||||
|
TZNAME:Europe/Paris(STD)
|
||||||
|
TZOFFSETFROM:+020000
|
||||||
|
TZOFFSETTO:+010000
|
||||||
|
END:STANDARD
|
||||||
|
BEGIN:STANDARD
|
||||||
|
DTSTART:19770925T030000
|
||||||
|
RDATE:19770925T030000
|
||||||
|
TZNAME:Europe/Paris(STD)
|
||||||
|
TZOFFSETFROM:+020000
|
||||||
|
TZOFFSETTO:+010000
|
||||||
|
END:STANDARD
|
||||||
|
BEGIN:STANDARD
|
||||||
|
DTSTART:19781001T030000
|
||||||
|
RDATE:19781001T030000
|
||||||
|
TZNAME:Europe/Paris(STD)
|
||||||
|
TZOFFSETFROM:+020000
|
||||||
|
TZOFFSETTO:+010000
|
||||||
|
END:STANDARD
|
||||||
|
BEGIN:STANDARD
|
||||||
|
DTSTART:19790930T030000
|
||||||
|
RRULE:FREQ=YEARLY;BYMONTH=9;BYDAY=-1SU;UNTIL=19950924T030000
|
||||||
|
TZNAME:Europe/Paris(STD)
|
||||||
|
TZOFFSETFROM:+020000
|
||||||
|
TZOFFSETTO:+010000
|
||||||
|
END:STANDARD
|
||||||
|
BEGIN:STANDARD
|
||||||
|
DTSTART:19961027T030000
|
||||||
|
RDATE:19961027T030000
|
||||||
|
TZNAME:Europe/Paris(STD)
|
||||||
|
TZOFFSETFROM:+020000
|
||||||
|
TZOFFSETTO:+010000
|
||||||
|
END:STANDARD
|
||||||
|
BEGIN:STANDARD
|
||||||
|
DTSTART:19971026T030000
|
||||||
|
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
|
||||||
|
TZNAME:(STD)
|
||||||
|
TZOFFSETFROM:+020000
|
||||||
|
TZOFFSETTO:+010000
|
||||||
|
END:STANDARD
|
||||||
|
BEGIN:DAYLIGHT
|
||||||
|
DTSTART:19160614T230000
|
||||||
|
RDATE:19160614T230000
|
||||||
|
TZNAME:Europe/Paris(DST)
|
||||||
|
TZOFFSETFROM:+000000
|
||||||
|
TZOFFSETTO:+010000
|
||||||
|
END:DAYLIGHT
|
||||||
|
BEGIN:DAYLIGHT
|
||||||
|
DTSTART:19170324T230000
|
||||||
|
RDATE:19170324T230000
|
||||||
|
TZNAME:Europe/Paris(DST)
|
||||||
|
TZOFFSETFROM:+000000
|
||||||
|
TZOFFSETTO:+010000
|
||||||
|
END:DAYLIGHT
|
||||||
|
BEGIN:DAYLIGHT
|
||||||
|
DTSTART:19180309T230000
|
||||||
|
RDATE:19180309T230000
|
||||||
|
TZNAME:Europe/Paris(DST)
|
||||||
|
TZOFFSETFROM:+000000
|
||||||
|
TZOFFSETTO:+010000
|
||||||
|
END:DAYLIGHT
|
||||||
|
BEGIN:DAYLIGHT
|
||||||
|
DTSTART:19190301T230000
|
||||||
|
RDATE:19190301T230000
|
||||||
|
TZNAME:Europe/Paris(DST)
|
||||||
|
TZOFFSETFROM:+000000
|
||||||
|
TZOFFSETTO:+010000
|
||||||
|
END:DAYLIGHT
|
||||||
|
BEGIN:DAYLIGHT
|
||||||
|
DTSTART:19200214T230000
|
||||||
|
RDATE:19200214T230000
|
||||||
|
TZNAME:Europe/Paris(DST)
|
||||||
|
TZOFFSETFROM:+000000
|
||||||
|
TZOFFSETTO:+010000
|
||||||
|
END:DAYLIGHT
|
||||||
|
BEGIN:DAYLIGHT
|
||||||
|
DTSTART:19210314T230000
|
||||||
|
RDATE:19210314T230000
|
||||||
|
TZNAME:Europe/Paris(DST)
|
||||||
|
TZOFFSETFROM:+000000
|
||||||
|
TZOFFSETTO:+010000
|
||||||
|
END:DAYLIGHT
|
||||||
|
BEGIN:DAYLIGHT
|
||||||
|
DTSTART:19220325T230000
|
||||||
|
RDATE:19220325T230000
|
||||||
|
TZNAME:Europe/Paris(DST)
|
||||||
|
TZOFFSETFROM:+000000
|
||||||
|
TZOFFSETTO:+010000
|
||||||
|
END:DAYLIGHT
|
||||||
|
BEGIN:DAYLIGHT
|
||||||
|
DTSTART:19230526T230000
|
||||||
|
RDATE:19230526T230000
|
||||||
|
TZNAME:Europe/Paris(DST)
|
||||||
|
TZOFFSETFROM:+000000
|
||||||
|
TZOFFSETTO:+010000
|
||||||
|
END:DAYLIGHT
|
||||||
|
BEGIN:DAYLIGHT
|
||||||
|
DTSTART:19240329T230000
|
||||||
|
RDATE:19240329T230000
|
||||||
|
TZNAME:Europe/Paris(DST)
|
||||||
|
TZOFFSETFROM:+000000
|
||||||
|
TZOFFSETTO:+010000
|
||||||
|
END:DAYLIGHT
|
||||||
|
BEGIN:DAYLIGHT
|
||||||
|
DTSTART:19250404T230000
|
||||||
|
RDATE:19250404T230000
|
||||||
|
TZNAME:Europe/Paris(DST)
|
||||||
|
TZOFFSETFROM:+000000
|
||||||
|
TZOFFSETTO:+010000
|
||||||
|
END:DAYLIGHT
|
||||||
|
BEGIN:DAYLIGHT
|
||||||
|
DTSTART:19260417T230000
|
||||||
|
RDATE:19260417T230000
|
||||||
|
TZNAME:Europe/Paris(DST)
|
||||||
|
TZOFFSETFROM:+000000
|
||||||
|
TZOFFSETTO:+010000
|
||||||
|
END:DAYLIGHT
|
||||||
|
BEGIN:DAYLIGHT
|
||||||
|
DTSTART:19270409T230000
|
||||||
|
RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=2SA;UNTIL=19280414T230000
|
||||||
|
TZNAME:Europe/Paris(DST)
|
||||||
|
TZOFFSETFROM:+000000
|
||||||
|
TZOFFSETTO:+010000
|
||||||
|
END:DAYLIGHT
|
||||||
|
BEGIN:DAYLIGHT
|
||||||
|
DTSTART:19290420T230000
|
||||||
|
RDATE:19290420T230000
|
||||||
|
TZNAME:Europe/Paris(DST)
|
||||||
|
TZOFFSETFROM:+000000
|
||||||
|
TZOFFSETTO:+010000
|
||||||
|
END:DAYLIGHT
|
||||||
|
BEGIN:DAYLIGHT
|
||||||
|
DTSTART:19300412T230000
|
||||||
|
RDATE:19300412T230000
|
||||||
|
TZNAME:Europe/Paris(DST)
|
||||||
|
TZOFFSETFROM:+000000
|
||||||
|
TZOFFSETTO:+010000
|
||||||
|
END:DAYLIGHT
|
||||||
|
BEGIN:DAYLIGHT
|
||||||
|
DTSTART:19310418T230000
|
||||||
|
RDATE:19310418T230000
|
||||||
|
TZNAME:Europe/Paris(DST)
|
||||||
|
TZOFFSETFROM:+000000
|
||||||
|
TZOFFSETTO:+010000
|
||||||
|
END:DAYLIGHT
|
||||||
|
BEGIN:DAYLIGHT
|
||||||
|
DTSTART:19320402T230000
|
||||||
|
RDATE:19320402T230000
|
||||||
|
TZNAME:Europe/Paris(DST)
|
||||||
|
TZOFFSETFROM:+000000
|
||||||
|
TZOFFSETTO:+010000
|
||||||
|
END:DAYLIGHT
|
||||||
|
BEGIN:DAYLIGHT
|
||||||
|
DTSTART:19330325T230000
|
||||||
|
RDATE:19330325T230000
|
||||||
|
TZNAME:Europe/Paris(DST)
|
||||||
|
TZOFFSETFROM:+000000
|
||||||
|
TZOFFSETTO:+010000
|
||||||
|
END:DAYLIGHT
|
||||||
|
BEGIN:DAYLIGHT
|
||||||
|
DTSTART:19340407T230000
|
||||||
|
RDATE:19340407T230000
|
||||||
|
TZNAME:Europe/Paris(DST)
|
||||||
|
TZOFFSETFROM:+000000
|
||||||
|
TZOFFSETTO:+010000
|
||||||
|
END:DAYLIGHT
|
||||||
|
BEGIN:DAYLIGHT
|
||||||
|
DTSTART:19350330T230000
|
||||||
|
RDATE:19350330T230000
|
||||||
|
TZNAME:Europe/Paris(DST)
|
||||||
|
TZOFFSETFROM:+000000
|
||||||
|
TZOFFSETTO:+010000
|
||||||
|
END:DAYLIGHT
|
||||||
|
BEGIN:DAYLIGHT
|
||||||
|
DTSTART:19360418T230000
|
||||||
|
RDATE:19360418T230000
|
||||||
|
TZNAME:Europe/Paris(DST)
|
||||||
|
TZOFFSETFROM:+000000
|
||||||
|
TZOFFSETTO:+010000
|
||||||
|
END:DAYLIGHT
|
||||||
|
BEGIN:DAYLIGHT
|
||||||
|
DTSTART:19370403T230000
|
||||||
|
RDATE:19370403T230000
|
||||||
|
TZNAME:Europe/Paris(DST)
|
||||||
|
TZOFFSETFROM:+000000
|
||||||
|
TZOFFSETTO:+010000
|
||||||
|
END:DAYLIGHT
|
||||||
|
BEGIN:DAYLIGHT
|
||||||
|
DTSTART:19380326T230000
|
||||||
|
RDATE:19380326T230000
|
||||||
|
TZNAME:Europe/Paris(DST)
|
||||||
|
TZOFFSETFROM:+000000
|
||||||
|
TZOFFSETTO:+010000
|
||||||
|
END:DAYLIGHT
|
||||||
|
BEGIN:DAYLIGHT
|
||||||
|
DTSTART:19390415T230000
|
||||||
|
RDATE:19390415T230000
|
||||||
|
TZNAME:Europe/Paris(DST)
|
||||||
|
TZOFFSETFROM:+000000
|
||||||
|
TZOFFSETTO:+010000
|
||||||
|
END:DAYLIGHT
|
||||||
|
BEGIN:DAYLIGHT
|
||||||
|
DTSTART:19400225T020000
|
||||||
|
RDATE:19400225T020000
|
||||||
|
TZNAME:Europe/Paris(DST)
|
||||||
|
TZOFFSETFROM:+000000
|
||||||
|
TZOFFSETTO:+010000
|
||||||
|
END:DAYLIGHT
|
||||||
|
BEGIN:DAYLIGHT
|
||||||
|
DTSTART:19400614T230000
|
||||||
|
RDATE:19400614T230000
|
||||||
|
TZNAME:Europe/Paris(DST)
|
||||||
|
TZOFFSETFROM:+010000
|
||||||
|
TZOFFSETTO:+020000
|
||||||
|
END:DAYLIGHT
|
||||||
|
BEGIN:DAYLIGHT
|
||||||
|
DTSTART:19430329T020000
|
||||||
|
RDATE:19430329T020000
|
||||||
|
TZNAME:Europe/Paris(DST)
|
||||||
|
TZOFFSETFROM:+010000
|
||||||
|
TZOFFSETTO:+020000
|
||||||
|
END:DAYLIGHT
|
||||||
|
BEGIN:DAYLIGHT
|
||||||
|
DTSTART:19440403T020000
|
||||||
|
RDATE:19440403T020000
|
||||||
|
TZNAME:Europe/Paris(DST)
|
||||||
|
TZOFFSETFROM:+010000
|
||||||
|
TZOFFSETTO:+020000
|
||||||
|
END:DAYLIGHT
|
||||||
|
BEGIN:DAYLIGHT
|
||||||
|
DTSTART:19440825T000000
|
||||||
|
RDATE:19440825T000000
|
||||||
|
TZNAME:Europe/Paris(DST)
|
||||||
|
TZOFFSETFROM:+020000
|
||||||
|
TZOFFSETTO:+020000
|
||||||
|
END:DAYLIGHT
|
||||||
|
BEGIN:DAYLIGHT
|
||||||
|
DTSTART:19441008T010000
|
||||||
|
RDATE:19441008T010000
|
||||||
|
TZNAME:Europe/Paris(DST)
|
||||||
|
TZOFFSETFROM:+020000
|
||||||
|
TZOFFSETTO:+010000
|
||||||
|
END:DAYLIGHT
|
||||||
|
BEGIN:DAYLIGHT
|
||||||
|
DTSTART:19450402T020000
|
||||||
|
RDATE:19450402T020000
|
||||||
|
TZNAME:Europe/Paris(DST)
|
||||||
|
TZOFFSETFROM:+010000
|
||||||
|
TZOFFSETTO:+020000
|
||||||
|
END:DAYLIGHT
|
||||||
|
BEGIN:DAYLIGHT
|
||||||
|
DTSTART:19760328T010000
|
||||||
|
RDATE:19760328T010000
|
||||||
|
TZNAME:Europe/Paris(DST)
|
||||||
|
TZOFFSETFROM:+010000
|
||||||
|
TZOFFSETTO:+020000
|
||||||
|
END:DAYLIGHT
|
||||||
|
BEGIN:DAYLIGHT
|
||||||
|
DTSTART:19770403T020000
|
||||||
|
RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=1SU;UNTIL=19800406T020000
|
||||||
|
TZNAME:Europe/Paris(DST)
|
||||||
|
TZOFFSETFROM:+010000
|
||||||
|
TZOFFSETTO:+020000
|
||||||
|
END:DAYLIGHT
|
||||||
|
BEGIN:DAYLIGHT
|
||||||
|
DTSTART:19810329T020000
|
||||||
|
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU;UNTIL=19960331T020000
|
||||||
|
TZNAME:Europe/Paris(DST)
|
||||||
|
TZOFFSETFROM:+010000
|
||||||
|
TZOFFSETTO:+020000
|
||||||
|
END:DAYLIGHT
|
||||||
|
BEGIN:DAYLIGHT
|
||||||
|
DTSTART:19970330T020000
|
||||||
|
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
|
||||||
|
TZNAME:(DST)
|
||||||
|
TZOFFSETFROM:+010000
|
||||||
|
TZOFFSETTO:+020000
|
||||||
|
END:DAYLIGHT
|
||||||
|
X-TZINFO:Europe/Paris[2024a]
|
||||||
|
END:VTIMEZONE
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:50c08af4-295c-4bea-9ea4-7402b8e82143
|
||||||
|
DTSTART;TZID=Europe/Paris:20250924T133000
|
||||||
|
DTEND;TZID=Europe/Paris:20250924T143000
|
||||||
|
CREATED:20250923T113902Z
|
||||||
|
DTSTAMP:20250923T113912Z
|
||||||
|
LAST-MODIFIED:20250923T113912Z
|
||||||
|
SUMMARY:event from thunderbird
|
||||||
|
TRANSP:OPAQUE
|
||||||
|
END:VEVENT
|
||||||
|
END:VCALENDAR
|
||||||
38
radicale/tests/static/event_issue1970_ok.ics
Normal file
38
radicale/tests/static/event_issue1970_ok.ics
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
BEGIN:VCALENDAR
|
||||||
|
PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
|
||||||
|
VERSION:2.0
|
||||||
|
BEGIN:VTIMEZONE
|
||||||
|
TZID:Europe/Paris
|
||||||
|
X-LIC-LOCATION:Europe/Paris
|
||||||
|
BEGIN:DAYLIGHT
|
||||||
|
TZOFFSETFROM:+0100
|
||||||
|
TZOFFSETTO:+0200
|
||||||
|
TZNAME:CEST
|
||||||
|
DTSTART:19700329T020000
|
||||||
|
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3
|
||||||
|
END:DAYLIGHT
|
||||||
|
BEGIN:STANDARD
|
||||||
|
TZOFFSETFROM:+0200
|
||||||
|
TZOFFSETTO:+0100
|
||||||
|
TZNAME:CET
|
||||||
|
DTSTART:19701025T030000
|
||||||
|
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10
|
||||||
|
END:STANDARD
|
||||||
|
END:VTIMEZONE
|
||||||
|
BEGIN:VEVENT
|
||||||
|
CREATED:20130902T150157Z
|
||||||
|
LAST-MODIFIED:20130902T150158Z
|
||||||
|
DTSTAMP:20130902T150158Z
|
||||||
|
UID:event1
|
||||||
|
SUMMARY:Event
|
||||||
|
CATEGORIES:some_category1,another_category2
|
||||||
|
DESCRIPTION:Line1
|
||||||
|
Line2
|
||||||
|
Line3
|
||||||
|
ORGANIZER:mailto:unclesam@example.com
|
||||||
|
ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=TENTATIVE;CN=Jane Doe:MAILTO:janedoe@example.com
|
||||||
|
ATTENDEE;ROLE=REQ-PARTICIPANT;DELEGATED-FROM="MAILTO:bob@host.com";PARTSTAT=ACCEPTED;CN=John Doe:MAILTO:johndoe@example.com
|
||||||
|
DTSTART;TZID=Europe/Paris:20130901T180000
|
||||||
|
DTEND;TZID=Europe/Paris:20130901T190000
|
||||||
|
END:VEVENT
|
||||||
|
END:VCALENDAR
|
||||||
39
radicale/tests/static/event_issue1970_problem.ics
Normal file
39
radicale/tests/static/event_issue1970_problem.ics
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
BEGIN:VCALENDAR
|
||||||
|
PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
|
||||||
|
VERSION:2.0
|
||||||
|
BEGIN:VTIMEZONE
|
||||||
|
TZID:Europe/Paris
|
||||||
|
X-LIC-LOCATION:Europe/Paris
|
||||||
|
BEGIN:DAYLIGHT
|
||||||
|
TZOFFSETFROM:+0100
|
||||||
|
TZOFFSETTO:+0200
|
||||||
|
TZNAME:CEST
|
||||||
|
DTSTART:19700329T020000
|
||||||
|
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3
|
||||||
|
END:DAYLIGHT
|
||||||
|
BEGIN:STANDARD
|
||||||
|
TZOFFSETFROM:+0200
|
||||||
|
TZOFFSETTO:+0100
|
||||||
|
TZNAME:CET
|
||||||
|
DTSTART:19701025T030000
|
||||||
|
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10
|
||||||
|
END:STANDARD
|
||||||
|
END:VTIMEZONE
|
||||||
|
BEGIN:VEVENT
|
||||||
|
CREATED:20130902T150157Z
|
||||||
|
LAST-MODIFIED:20130902T150158Z
|
||||||
|
DTSTAMP:20130902T150158Z
|
||||||
|
UID:event1
|
||||||
|
SUMMARY:Event having description with empty line
|
||||||
|
CATEGORIES:some_category1,another_category2
|
||||||
|
DESCRIPTION:Line1
|
||||||
|
Line2
|
||||||
|
|
||||||
|
Line4
|
||||||
|
ORGANIZER:mailto:unclesam@example.com
|
||||||
|
ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=TENTATIVE;CN=Jane Doe:MAILTO:janedoe@example.com
|
||||||
|
ATTENDEE;ROLE=REQ-PARTICIPANT;DELEGATED-FROM="MAILTO:bob@host.com";PARTSTAT=ACCEPTED;CN=John Doe:MAILTO:johndoe@example.com
|
||||||
|
DTSTART;TZID=Europe/Paris:20130901T180000
|
||||||
|
DTEND;TZID=Europe/Paris:20130901T190000
|
||||||
|
END:VEVENT
|
||||||
|
END:VCALENDAR
|
||||||
40
radicale/tests/static/event_multiple3.ics
Normal file
40
radicale/tests/static/event_multiple3.ics
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
BEGIN:VCALENDAR
|
||||||
|
PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
|
||||||
|
VERSION:2.0
|
||||||
|
BEGIN:VTIMEZONE
|
||||||
|
TZID:Europe/Paris
|
||||||
|
X-LIC-LOCATION:Europe/Paris
|
||||||
|
BEGIN:DAYLIGHT
|
||||||
|
TZOFFSETFROM:+0100
|
||||||
|
TZOFFSETTO:+0200
|
||||||
|
TZNAME:CEST
|
||||||
|
DTSTART:19700329T020000
|
||||||
|
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3
|
||||||
|
END:DAYLIGHT
|
||||||
|
BEGIN:STANDARD
|
||||||
|
TZOFFSETFROM:+0200
|
||||||
|
TZOFFSETTO:+0100
|
||||||
|
TZNAME:CET
|
||||||
|
DTSTART:19701025T030000
|
||||||
|
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10
|
||||||
|
END:STANDARD
|
||||||
|
END:VTIMEZONE
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:event
|
||||||
|
SUMMARY:Event
|
||||||
|
DTSTART;TZID=Europe/Paris:20130901T190000
|
||||||
|
DTEND;TZID=Europe/Paris:20130901T200000
|
||||||
|
END:VEVENT
|
||||||
|
BEGIN:VTODO
|
||||||
|
UID:todo
|
||||||
|
DTSTART;TZID=Europe/Paris:20130901T220000
|
||||||
|
DURATION:PT1H
|
||||||
|
SUMMARY:Todo
|
||||||
|
END:VTODO
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:event2
|
||||||
|
SUMMARY:Event-with-longer-description
|
||||||
|
DTSTART;TZID=Europe/Paris:20130901T190000
|
||||||
|
DTEND;TZID=Europe/Paris:20130901T200000
|
||||||
|
END:VEVENT
|
||||||
|
END:VCALENDAR
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
# Copyright © 2012-2016 Jean-Marc Martins
|
# Copyright © 2012-2016 Jean-Marc Martins
|
||||||
# Copyright © 2012-2017 Guillaume Ayoub
|
# Copyright © 2012-2017 Guillaume Ayoub
|
||||||
# Copyright © 2017-2022 Unrud <unrud@outlook.com>
|
# 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
|
# 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
|
# 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
|
import pytest
|
||||||
|
|
||||||
from radicale import xmlutils
|
from radicale import utils, xmlutils
|
||||||
from radicale.tests import BaseTest
|
from radicale.tests import BaseTest
|
||||||
|
|
||||||
|
|
||||||
@@ -114,45 +114,60 @@ class TestBaseAuthRequests(BaseTest):
|
|||||||
def test_htpasswd_sha256_autodetect(self) -> None:
|
def test_htpasswd_sha256_autodetect(self) -> None:
|
||||||
self._test_htpasswd("autodetect", "tmp:$5$i4Ni4TQq6L5FKss5$ilpTjkmnxkwZeV35GB9cYSsDXTALBn6KtWRJAzNlCL/")
|
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:
|
def test_htpasswd_sha512(self) -> None:
|
||||||
self._test_htpasswd("sha512", "tmp:$6$3Qhl8r6FLagYdHYa$UCH9yXCed4A.J9FQsFPYAOXImzZUMfvLa0lwcWOxWYLOF5sE/lF99auQ4jKvHY2vijxmefl7G6kMqZ8JPdhIJ/")
|
self._test_htpasswd("sha512", "tmp:$6$3Qhl8r6FLagYdHYa$UCH9yXCed4A.J9FQsFPYAOXImzZUMfvLa0lwcWOxWYLOF5sE/lF99auQ4jKvHY2vijxmefl7G6kMqZ8JPdhIJ/")
|
||||||
|
|
||||||
def test_htpasswd_sha512_autodetect(self) -> None:
|
def test_htpasswd_sha512_autodetect(self) -> None:
|
||||||
self._test_htpasswd("autodetect", "tmp:$6$3Qhl8r6FLagYdHYa$UCH9yXCed4A.J9FQsFPYAOXImzZUMfvLa0lwcWOxWYLOF5sE/lF99auQ4jKvHY2vijxmefl7G6kMqZ8JPdhIJ/")
|
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(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:
|
def test_htpasswd_bcrypt_2a(self) -> None:
|
||||||
self._test_htpasswd("bcrypt", "tmp:$2a$10$Mj4A9vMecAp/K7.0fMKoVOk1SjgR.RBhl06a52nvzXhxlT3HB7Reu")
|
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:
|
def test_htpasswd_bcrypt_2a_autodetect(self) -> None:
|
||||||
self._test_htpasswd("autodetect", "tmp:$2a$10$Mj4A9vMecAp/K7.0fMKoVOk1SjgR.RBhl06a52nvzXhxlT3HB7Reu")
|
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(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:
|
def test_htpasswd_bcrypt_2b(self) -> None:
|
||||||
self._test_htpasswd("bcrypt", "tmp:$2b$12$7a4z/fdmXlBIfkz0smvzW.1Nds8wpgC/bo2DVOb4OSQKWCDL1A1wu")
|
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(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:
|
def test_htpasswd_bcrypt_2b_autodetect(self) -> None:
|
||||||
self._test_htpasswd("autodetect", "tmp:$2b$12$7a4z/fdmXlBIfkz0smvzW.1Nds8wpgC/bo2DVOb4OSQKWCDL1A1wu")
|
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(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:
|
def test_htpasswd_bcrypt_2y(self) -> None:
|
||||||
self._test_htpasswd("bcrypt", "tmp:$2y$05$oD7hbiQFQlvCM7zoalo/T.MssV3VNTRI3w5KDnj8NTUKJNWfVpvRq")
|
self._test_htpasswd("bcrypt", "tmp:$2y$05$oD7hbiQFQlvCM7zoalo/T.MssV3VNTRI3w5KDnj8NTUKJNWfVpvRq")
|
||||||
|
|
||||||
@pytest.mark.skipif(has_bcrypt == 0, reason="No bcrypt module installed")
|
@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:
|
def test_htpasswd_bcrypt_2y_autodetect(self) -> None:
|
||||||
self._test_htpasswd("autodetect", "tmp:$2y$05$oD7hbiQFQlvCM7zoalo/T.MssV3VNTRI3w5KDnj8NTUKJNWfVpvRq")
|
self._test_htpasswd("autodetect", "tmp:$2y$05$oD7hbiQFQlvCM7zoalo/T.MssV3VNTRI3w5KDnj8NTUKJNWfVpvRq")
|
||||||
|
|
||||||
@pytest.mark.skipif(has_bcrypt == 0, reason="No bcrypt module installed")
|
@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:
|
def test_htpasswd_bcrypt_C10(self) -> None:
|
||||||
self._test_htpasswd("bcrypt", "tmp:$2y$10$bZsWq06ECzxqi7RmulQvC.T1YHUnLW2E3jn.MU2pvVTGn1dfORt2a")
|
self._test_htpasswd("bcrypt", "tmp:$2y$10$bZsWq06ECzxqi7RmulQvC.T1YHUnLW2E3jn.MU2pvVTGn1dfORt2a")
|
||||||
|
|
||||||
@pytest.mark.skipif(has_bcrypt == 0, reason="No bcrypt module installed")
|
@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:
|
def test_htpasswd_bcrypt_C10_autodetect(self) -> None:
|
||||||
self._test_htpasswd("bcrypt", "tmp:$2y$10$bZsWq06ECzxqi7RmulQvC.T1YHUnLW2E3jn.MU2pvVTGn1dfORt2a")
|
self._test_htpasswd("bcrypt", "tmp:$2y$10$bZsWq06ECzxqi7RmulQvC.T1YHUnLW2E3jn.MU2pvVTGn1dfORt2a")
|
||||||
|
|
||||||
@pytest.mark.skipif(has_bcrypt == 0, reason="No bcrypt module installed")
|
@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:
|
def test_htpasswd_bcrypt_unicode(self) -> None:
|
||||||
self._test_htpasswd("bcrypt", "😀:$2y$10$Oyz5aHV4MD9eQJbk6GPemOs4T6edK6U9Sqlzr.W1mMVCS8wJUftnW", "unicode")
|
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"))
|
href_element = prop.find(xmlutils.make_clark("D:href"))
|
||||||
assert href_element is not None and href_element.text == "/test/"
|
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:
|
def test_http_x_remote_user(self) -> None:
|
||||||
self.configure({"auth": {"type": "http_x_remote_user"}})
|
self.configure({"auth": {"type": "http_x_remote_user"}})
|
||||||
_, responses = self.propfind("/", """\
|
_, responses = self.propfind("/", """\
|
||||||
@@ -282,13 +314,23 @@ class TestBaseAuthRequests(BaseTest):
|
|||||||
|
|
||||||
@pytest.mark.skipif(sys.platform == 'win32', reason="Not supported on Windows")
|
@pytest.mark.skipif(sys.platform == 'win32', reason="Not supported on Windows")
|
||||||
def _test_dovecot(
|
def _test_dovecot(
|
||||||
self, user, password, expected_status,
|
self, user, password, expected_status, expected_rip=None,
|
||||||
response=b'FAIL\n1\n', mech=[b'PLAIN'], broken=None):
|
response=b'FAIL\t1', mech=[b'PLAIN'], broken=None,
|
||||||
|
extra_config=None, extra_env=None):
|
||||||
import socket
|
import socket
|
||||||
from unittest.mock import DEFAULT, patch
|
from unittest.mock import DEFAULT, patch
|
||||||
|
|
||||||
self.configure({"auth": {"type": "dovecot",
|
if extra_env is None:
|
||||||
"dovecot_socket": "./dovecot.sock"}})
|
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:
|
if broken is None:
|
||||||
broken = []
|
broken = []
|
||||||
@@ -311,10 +353,18 @@ class TestBaseAuthRequests(BaseTest):
|
|||||||
if "done" not in broken:
|
if "done" not in broken:
|
||||||
handshake += b'DONE\n'
|
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(
|
with patch.multiple(
|
||||||
'socket.socket',
|
'socket.socket',
|
||||||
connect=DEFAULT,
|
connect=DEFAULT,
|
||||||
send=DEFAULT,
|
send=record_sent_data,
|
||||||
recv=DEFAULT
|
recv=DEFAULT
|
||||||
) as mock_socket:
|
) as mock_socket:
|
||||||
if "socket" in broken:
|
if "socket" in broken:
|
||||||
@@ -325,7 +375,9 @@ class TestBaseAuthRequests(BaseTest):
|
|||||||
status, _, answer = self.request(
|
status, _, answer = self.request(
|
||||||
"PROPFIND", "/",
|
"PROPFIND", "/",
|
||||||
HTTP_AUTHORIZATION="Basic %s" % base64.b64encode(
|
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
|
assert status == expected_status
|
||||||
|
|
||||||
@pytest.mark.skipif(sys.platform == 'win32', reason="Not supported on Windows")
|
@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):
|
def test_dovecot_auth_id_mismatch(self):
|
||||||
self._test_dovecot("user", "password", 401, response=b'OK\t2')
|
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:
|
def test_custom(self) -> None:
|
||||||
"""Custom authentication."""
|
"""Custom authentication."""
|
||||||
self.configure({"auth": {"type": "radicale.tests.custom.auth"}})
|
self.configure({"auth": {"type": "radicale.tests.custom.auth"}})
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# This file is part of Radicale - CalDAV and CardDAV server
|
# This file is part of Radicale - CalDAV and CardDAV server
|
||||||
# Copyright © 2012-2017 Guillaume Ayoub
|
# Copyright © 2012-2017 Guillaume Ayoub
|
||||||
# Copyright © 2017-2022 Unrud <unrud@outlook.com>
|
# 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
|
# 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
|
# 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 logging
|
||||||
import os
|
import os
|
||||||
import posixpath
|
import posixpath
|
||||||
|
import urllib
|
||||||
from typing import Any, Callable, ClassVar, Iterable, List, Optional, Tuple
|
from typing import Any, Callable, ClassVar, Iterable, List, Optional, Tuple
|
||||||
|
|
||||||
import defusedxml.ElementTree as DefusedET
|
import defusedxml.ElementTree as DefusedET
|
||||||
|
import pytest
|
||||||
import vobject
|
import vobject
|
||||||
|
|
||||||
from radicale import storage, xmlutils
|
from radicale import storage, utils, xmlutils
|
||||||
from radicale.tests import RESPONSES, BaseTest
|
from radicale.tests import RESPONSES, BaseTest
|
||||||
from radicale.tests.helpers import get_file_content
|
from radicale.tests.helpers import get_file_content
|
||||||
|
|
||||||
@@ -142,6 +144,64 @@ permissions: RrWw""")
|
|||||||
assert "Event" in answer
|
assert "Event" in answer
|
||||||
assert "UID: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:
|
def test_add_event_without_uid(self) -> None:
|
||||||
"""Add an event without UID."""
|
"""Add an event without UID."""
|
||||||
self.mkcalendar("/calendar.ics/")
|
self.mkcalendar("/calendar.ics/")
|
||||||
@@ -201,6 +261,34 @@ permissions: RrWw""")
|
|||||||
_, answer = self.get(path)
|
_, answer = self.get(path)
|
||||||
assert "UID:contact1" in answer
|
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:
|
def test_add_contact_photo_with_data_uri(self) -> None:
|
||||||
"""Test workaround for broken PHOTO data from InfCloud"""
|
"""Test workaround for broken PHOTO data from InfCloud"""
|
||||||
self.create_addressbook("/contacts.vcf/")
|
self.create_addressbook("/contacts.vcf/")
|
||||||
@@ -216,6 +304,48 @@ permissions: RrWw""")
|
|||||||
path = "/contacts.vcf/contact.vcf"
|
path = "/contacts.vcf/contact.vcf"
|
||||||
self.put(path, contact, check=400)
|
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:
|
def test_update_event(self) -> None:
|
||||||
"""Update an event."""
|
"""Update an event."""
|
||||||
self.mkcalendar("/calendar.ics/")
|
self.mkcalendar("/calendar.ics/")
|
||||||
@@ -229,6 +359,56 @@ permissions: RrWw""")
|
|||||||
_, answer = self.get(path)
|
_, answer = self.get(path)
|
||||||
assert "DTSTAMP:20130902T150159Z" in answer
|
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:
|
def test_update_event_uid_event(self) -> None:
|
||||||
"""Update an event with a different UID."""
|
"""Update an event with a different UID."""
|
||||||
self.mkcalendar("/calendar.ics/")
|
self.mkcalendar("/calendar.ics/")
|
||||||
@@ -306,6 +486,22 @@ permissions: RrWw""")
|
|||||||
for uid2 in uids[i + 1:]:
|
for uid2 in uids[i + 1:]:
|
||||||
assert uid1 != uid2
|
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:
|
def test_verify(self) -> None:
|
||||||
"""Verify the storage."""
|
"""Verify the storage."""
|
||||||
contacts = get_file_content("contact_multiple.vcf")
|
contacts = get_file_content("contact_multiple.vcf")
|
||||||
@@ -401,6 +597,33 @@ permissions: RrWw""")
|
|||||||
self.get(path1, check=404)
|
self.get(path1, check=404)
|
||||||
self.get(path2)
|
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:
|
def test_move_between_collections_duplicate_uid(self) -> None:
|
||||||
"""Move a item to a collection which already contains the UID."""
|
"""Move a item to a collection which already contains the UID."""
|
||||||
self.mkcalendar("/calendar1.ics/")
|
self.mkcalendar("/calendar1.ics/")
|
||||||
@@ -568,11 +791,13 @@ permissions: RrWw""")
|
|||||||
assert not isinstance(response, int)
|
assert not isinstance(response, int)
|
||||||
status, prop = response["D:sync-token"]
|
status, prop = response["D:sync-token"]
|
||||||
assert status == 200 and prop.text
|
assert status == 200 and prop.text
|
||||||
|
assert "C:max-resource-size" not in response
|
||||||
_, responses = self.propfind("/calendar.ics/event.ics", propfind)
|
_, responses = self.propfind("/calendar.ics/event.ics", propfind)
|
||||||
response = responses["/calendar.ics/event.ics"]
|
response = responses["/calendar.ics/event.ics"]
|
||||||
assert not isinstance(response, int)
|
assert not isinstance(response, int)
|
||||||
status, prop = response["D:getetag"]
|
status, prop = response["D:getetag"]
|
||||||
assert status == 200 and prop.text
|
assert status == 200 and prop.text
|
||||||
|
assert "C:max-resource-size" not in response
|
||||||
|
|
||||||
def test_propfind_nonexistent(self) -> None:
|
def test_propfind_nonexistent(self) -> None:
|
||||||
"""Read a property that does not exist."""
|
"""Read a property that does not exist."""
|
||||||
@@ -584,6 +809,87 @@ permissions: RrWw""")
|
|||||||
status, prop = response["ICAL:calendar-color"]
|
status, prop = response["ICAL:calendar-color"]
|
||||||
assert status == 404 and not prop.text
|
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:
|
def test_proppatch(self) -> None:
|
||||||
"""Set/Remove a property and read it back."""
|
"""Set/Remove a property and read it back."""
|
||||||
self.mkcalendar("/calendar.ics/")
|
self.mkcalendar("/calendar.ics/")
|
||||||
@@ -1621,7 +1927,7 @@ permissions: RrWw""")
|
|||||||
</C:free-busy-query>""", 400, is_xml=False)
|
</C:free-busy-query>""", 400, is_xml=False)
|
||||||
|
|
||||||
def _report_sync_token(
|
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]:
|
) -> Tuple[str, RESPONSES]:
|
||||||
sync_token_xml = (
|
sync_token_xml = (
|
||||||
"<sync-token><![CDATA[%s]]></sync-token>" % sync_token
|
"<sync-token><![CDATA[%s]]></sync-token>" % sync_token
|
||||||
@@ -1633,7 +1939,7 @@ permissions: RrWw""")
|
|||||||
<getetag />
|
<getetag />
|
||||||
</prop>
|
</prop>
|
||||||
%s
|
%s
|
||||||
</sync-collection>""" % sync_token_xml)
|
</sync-collection>""" % sync_token_xml, **kwargs)
|
||||||
xml = DefusedET.fromstring(answer)
|
xml = DefusedET.fromstring(answer)
|
||||||
if status in (403, 409):
|
if status in (403, 409):
|
||||||
assert xml.tag == xmlutils.make_clark("D:error")
|
assert xml.tag == xmlutils.make_clark("D:error")
|
||||||
@@ -1781,6 +2087,15 @@ permissions: RrWw""")
|
|||||||
calendar_path, "http://radicale.org/ns/sync/INVALID")
|
calendar_path, "http://radicale.org/ns/sync/INVALID")
|
||||||
assert not sync_token
|
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:
|
def test_propfind_sync_token(self) -> None:
|
||||||
"""Retrieve the sync-token with a propfind request"""
|
"""Retrieve the sync-token with a propfind request"""
|
||||||
calendar_path = "/calendar.ics/"
|
calendar_path = "/calendar.ics/"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
# Copyright © 2017-2019 Unrud <unrud@outlook.com>
|
# Copyright © 2017-2019 Unrud <unrud@outlook.com>
|
||||||
# Copyright © 2024 Pieter Hijma <pieterhijma@users.noreply.github.com>
|
# Copyright © 2024 Pieter Hijma <pieterhijma@users.noreply.github.com>
|
||||||
# Copyright © 2025 David Greaves <david@dgreaves.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
|
# 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
|
# it under the terms of the GNU General Public License as published by
|
||||||
@@ -513,6 +514,191 @@ permissions: RrWw""")
|
|||||||
assert event2_calendar_data.text
|
assert event2_calendar_data.text
|
||||||
assert "UID:c6be8b2c-3d72-453c-b698-4f25cdf1569e" in 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
|
||||||
|
|
||||||
def test_report_with_expand_property_all_day_event_overridden(self) -> None:
|
def test_report_with_expand_property_all_day_event_overridden(self) -> None:
|
||||||
self._test_expand(
|
self._test_expand(
|
||||||
"event_full_day_rrule_overridden",
|
"event_full_day_rrule_overridden",
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ Radicale tests related to hook 'email'
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
from radicale.tests import BaseTest
|
from radicale.tests import BaseTest
|
||||||
from radicale.tests.helpers import get_file_content
|
from radicale.tests.helpers import get_file_content
|
||||||
@@ -63,11 +65,26 @@ permissions: RrWw""")
|
|||||||
self.configure({"hook": {"type": "email",
|
self.configure({"hook": {"type": "email",
|
||||||
"dryrun": "True"}})
|
"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)
|
caplog.set_level(logging.WARNING)
|
||||||
"""Add an event."""
|
"""Add an event."""
|
||||||
self.mkcalendar("/calendar.ics/")
|
self.mkcalendar("/calendar.ics/")
|
||||||
event = get_file_content("event1.ics")
|
event = get_file_content("event1.ics")
|
||||||
|
event = self._replace_end_date_in_event(event, self._future_date_timestamp())
|
||||||
path = "/calendar.ics/event1.ics"
|
path = "/calendar.ics/event1.ics"
|
||||||
self.put(path, event)
|
self.put(path, event)
|
||||||
_, headers, answer = self.request("GET", path, check=200)
|
_, headers, answer = self.request("GET", path, check=200)
|
||||||
@@ -76,35 +93,68 @@ permissions: RrWw""")
|
|||||||
assert "VEVENT" in answer
|
assert "VEVENT" in answer
|
||||||
assert "Event" in answer
|
assert "Event" in answer
|
||||||
assert "UID: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)
|
caplog.set_level(logging.WARNING)
|
||||||
"""Delete an event."""
|
"""Delete an event."""
|
||||||
self.mkcalendar("/calendar.ics/")
|
self.mkcalendar("/calendar.ics/")
|
||||||
event = get_file_content("event1.ics")
|
event = get_file_content("event1.ics")
|
||||||
|
event = self._replace_end_date_in_event(event, self._future_date_timestamp())
|
||||||
path = "/calendar.ics/event1.ics"
|
path = "/calendar.ics/event1.ics"
|
||||||
self.put(path, event)
|
self.put(path, event)
|
||||||
_, responses = self.delete(path)
|
_, responses = self.delete(path)
|
||||||
assert responses[path] == 200
|
assert responses[path] == 200
|
||||||
_, answer = self.get("/calendar.ics/")
|
_, answer = self.get("/calendar.ics/")
|
||||||
assert "VEVENT" not in answer
|
assert "VEVENT" not in answer
|
||||||
found = 0
|
|
||||||
for line in caplog.messages:
|
logs = caplog.messages
|
||||||
if line.find("notification_item: {'type': 'delete'") != -1:
|
# Should have a log saying the notification item was received
|
||||||
found = found | 1
|
assert len([log for log in logs if "received notification_item: {'type': 'delete'," in log]) == 1
|
||||||
if line.find("to_addresses=['janedoe@example.com']") != -1:
|
# Should NOT have a log saying that no email is sent (email won't actually be sent due to dryrun)
|
||||||
found = found | 2
|
assert len([log for log in logs if "skipping notification for event: event1" in log]) == 0
|
||||||
if line.find("to_addresses=['johndoe@example.com']") != -1:
|
|
||||||
found = found | 4
|
def test_delete_event_with_past_end_date(self, caplog) -> None:
|
||||||
if (found != 7):
|
caplog.set_level(logging.WARNING)
|
||||||
raise ValueError("Logging misses expected log lines, found=%d", found)
|
"""Delete an event."""
|
||||||
|
self.mkcalendar("/calendar.ics/")
|
||||||
|
event = get_file_content("event1.ics")
|
||||||
|
event = self._replace_end_date_in_event(event, self._past_date_timestamp())
|
||||||
|
path = "/calendar.ics/event1.ics"
|
||||||
|
self.put(path, event)
|
||||||
|
_, responses = self.delete(path)
|
||||||
|
assert responses[path] == 200
|
||||||
|
_, answer = self.get("/calendar.ics/")
|
||||||
|
assert "VEVENT" not in answer
|
||||||
|
|
||||||
|
logs = caplog.messages
|
||||||
|
# Should have a log saying the notification item was received
|
||||||
|
assert len([log for log in logs if "received notification_item: {'type': 'delete'," in log]) == 1
|
||||||
|
# Should have a log saying that no email is sent due to past end date
|
||||||
|
assert len([log for log in logs if "Event end time is in the past, skipping notification for event: event1" in log]) == 1
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ Radicale tests related to hook 'rabbitmq'
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from radicale.tests import BaseTest
|
from radicale.tests import BaseTest
|
||||||
from radicale.tests.helpers import get_file_content
|
from radicale.tests.helpers import get_file_content
|
||||||
|
|
||||||
@@ -29,6 +31,14 @@ from radicale.tests.helpers import get_file_content
|
|||||||
class TestHooks(BaseTest):
|
class TestHooks(BaseTest):
|
||||||
"""Tests with hooks."""
|
"""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:
|
def setup_method(self) -> None:
|
||||||
BaseTest.setup_method(self)
|
BaseTest.setup_method(self)
|
||||||
rights_file_path = os.path.join(self.colpath, "rights")
|
rights_file_path = os.path.join(self.colpath, "rights")
|
||||||
@@ -63,6 +73,7 @@ permissions: RrWw""")
|
|||||||
self.configure({"hook": {"type": "rabbitmq",
|
self.configure({"hook": {"type": "rabbitmq",
|
||||||
"dryrun": "True"}})
|
"dryrun": "True"}})
|
||||||
|
|
||||||
|
@pytest.mark.skipif(has_pika == 0, reason="No pika module installed")
|
||||||
def test_add_event(self, caplog) -> None:
|
def test_add_event(self, caplog) -> None:
|
||||||
caplog.set_level(logging.WARNING)
|
caplog.set_level(logging.WARNING)
|
||||||
"""Add an event."""
|
"""Add an event."""
|
||||||
@@ -83,6 +94,7 @@ permissions: RrWw""")
|
|||||||
if (found is False):
|
if (found is False):
|
||||||
raise ValueError("Logging misses expected log line")
|
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:
|
def test_delete_event(self, caplog) -> None:
|
||||||
caplog.set_level(logging.WARNING)
|
caplog.set_level(logging.WARNING)
|
||||||
"""Delete an event."""
|
"""Delete an event."""
|
||||||
|
|||||||
91
radicale/tests/test_pathutils.py
Normal file
91
radicale/tests/test_pathutils.py
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
# This file is part of Radicale - CalDAV and CardDAV server
|
||||||
|
# Copyright © 2025 Tobias Brox <tobias@tobix.eu>
|
||||||
|
#
|
||||||
|
# This library is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This library is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with Radicale. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
"""
|
||||||
|
Tests for pathutils module.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
import gc
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from radicale import pathutils
|
||||||
|
|
||||||
|
|
||||||
|
class TestPathToFilesystem:
|
||||||
|
"""Tests for path_to_filesystem function."""
|
||||||
|
|
||||||
|
@pytest.mark.filterwarnings("error::ResourceWarning")
|
||||||
|
@pytest.mark.filterwarnings("error::pytest.PytestUnraisableExceptionWarning")
|
||||||
|
def test_scandir_iterator_closed(self) -> None:
|
||||||
|
"""Verify that os.scandir iterator is properly closed.
|
||||||
|
|
||||||
|
This test catches ResourceWarning: unclosed scandir iterator
|
||||||
|
which occurs when os.scandir() is used without a context manager.
|
||||||
|
See: https://github.com/Kozea/Radicale/issues/1972
|
||||||
|
|
||||||
|
The ResourceWarning is emitted during garbage collection when an
|
||||||
|
unclosed scandir iterator is finalized. We use pytest.mark.filterwarnings
|
||||||
|
to convert both ResourceWarning and PytestUnraisableExceptionWarning
|
||||||
|
to errors.
|
||||||
|
"""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
# Create a subdirectory so path_to_filesystem has something
|
||||||
|
# to scan (the scandir check is for case-insensitive filesystems)
|
||||||
|
subdir = os.path.join(tmpdir, "testdir")
|
||||||
|
os.makedirs(subdir)
|
||||||
|
|
||||||
|
# Call path_to_filesystem - if scandir iterator is not closed,
|
||||||
|
# a ResourceWarning will be emitted during garbage collection
|
||||||
|
result = pathutils.path_to_filesystem(tmpdir, "testdir")
|
||||||
|
assert result == subdir
|
||||||
|
|
||||||
|
# Force garbage collection to trigger any ResourceWarning
|
||||||
|
# from unclosed iterators
|
||||||
|
gc.collect()
|
||||||
|
|
||||||
|
def test_path_to_filesystem_basic(self) -> None:
|
||||||
|
"""Test basic path_to_filesystem functionality."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
# Test empty path
|
||||||
|
result = pathutils.path_to_filesystem(tmpdir, "")
|
||||||
|
assert result == tmpdir
|
||||||
|
|
||||||
|
# Test single component
|
||||||
|
subdir = os.path.join(tmpdir, "test")
|
||||||
|
os.makedirs(subdir)
|
||||||
|
result = pathutils.path_to_filesystem(tmpdir, "test")
|
||||||
|
assert result == subdir
|
||||||
|
|
||||||
|
# Test nested path
|
||||||
|
nested = os.path.join(subdir, "nested")
|
||||||
|
os.makedirs(nested)
|
||||||
|
result = pathutils.path_to_filesystem(tmpdir, "test/nested")
|
||||||
|
assert result == nested
|
||||||
|
|
||||||
|
def test_unsafe_path_raises(self) -> None:
|
||||||
|
"""Test that unsafe path components raise UnsafePathError."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
# Hidden files (starting with .) are not safe
|
||||||
|
with pytest.raises(pathutils.UnsafePathError):
|
||||||
|
pathutils.path_to_filesystem(tmpdir, ".hidden")
|
||||||
|
|
||||||
|
# Backup files (ending with ~) are not safe
|
||||||
|
with pytest.raises(pathutils.UnsafePathError):
|
||||||
|
pathutils.path_to_filesystem(tmpdir, "backup~")
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
# This file is part of Radicale - CalDAV and CardDAV server
|
# 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
|
# 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
|
# 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)
|
runtime_checkable)
|
||||||
|
|
||||||
WSGIResponseHeaders = Union[Mapping[str, str], Sequence[Tuple[str, str]]]
|
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]
|
WSGIEnviron = Mapping[str, Any]
|
||||||
WSGIStartResponse = Callable[[str, List[Tuple[str, str]]], Any]
|
WSGIStartResponse = Callable[[str, List[Tuple[str, str]]], Any]
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
# Copyright © 2014 Jean-Marc Martins
|
# Copyright © 2014 Jean-Marc Martins
|
||||||
# Copyright © 2012-2017 Guillaume Ayoub
|
# Copyright © 2012-2017 Guillaume Ayoub
|
||||||
# Copyright © 2017-2018 Unrud <unrud@outlook.com>
|
# 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
|
# 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
|
# it under the terms of the GNU General Public License as published by
|
||||||
@@ -21,9 +21,14 @@ import datetime
|
|||||||
import os
|
import os
|
||||||
import ssl
|
import ssl
|
||||||
import sys
|
import sys
|
||||||
|
import textwrap
|
||||||
|
from hashlib import sha256
|
||||||
from importlib import import_module, metadata
|
from importlib import import_module, metadata
|
||||||
|
from string import ascii_letters, digits, punctuation
|
||||||
from typing import Callable, Sequence, Tuple, Type, TypeVar, Union
|
from typing import Callable, Sequence, Tuple, Type, TypeVar, Union
|
||||||
|
|
||||||
|
from packaging.version import Version
|
||||||
|
|
||||||
from radicale import config
|
from radicale import config
|
||||||
from radicale.log import logger
|
from radicale.log import logger
|
||||||
|
|
||||||
@@ -47,8 +52,18 @@ ADDRESS_TYPE = Union[Tuple[Union[str, bytes, bytearray], int],
|
|||||||
Tuple[str, int, int, 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_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,
|
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):
|
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)
|
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():
|
def packages_version():
|
||||||
versions = []
|
versions = []
|
||||||
versions.append("python=%s.%s.%s" % (sys.version_info[0], sys.version_info[1], sys.version_info[2]))
|
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
|
return protocols
|
||||||
|
|
||||||
|
|
||||||
|
def unknown_if_empty(value):
|
||||||
|
if value == "":
|
||||||
|
return "UNKNOWN"
|
||||||
|
else:
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
def user_groups_as_string():
|
def user_groups_as_string():
|
||||||
if sys.platform != "win32":
|
if sys.platform != "win32":
|
||||||
euid = os.geteuid()
|
euid = os.geteuid()
|
||||||
egid = os.getegid()
|
|
||||||
try:
|
try:
|
||||||
username = pwd.getpwuid(euid)[0]
|
username = pwd.getpwuid(euid)[0]
|
||||||
|
user = "%s(%d)" % (unknown_if_empty(username), euid)
|
||||||
except Exception:
|
except Exception:
|
||||||
# name of user not found
|
# name of user not found
|
||||||
s = "user=(%d) group=(%d)" % (euid, egid)
|
user = "UNKNOWN(%d)" % euid
|
||||||
return s
|
|
||||||
gids = os.getgrouplist(username, egid)
|
egid = os.getegid()
|
||||||
groups = []
|
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:
|
try:
|
||||||
gi = grp.getgrgid(gid)
|
groups.append("%s(%d)" % (grp.getgrnam(egid)[0], egid))
|
||||||
groups.append("%s(%d)" % (gi.gr_name, gid))
|
|
||||||
except Exception:
|
except Exception:
|
||||||
groups.append("%s(%d)" % (gid, gid))
|
# workaround to get groupid by name
|
||||||
s = "user=%s(%d) groups=%s" % (username, euid, ','.join(groups))
|
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:
|
else:
|
||||||
username = os.getlogin()
|
username = os.getlogin()
|
||||||
s = "user=%s" % (username)
|
s = "user=%s" % (username)
|
||||||
@@ -255,12 +333,175 @@ def format_ut(unixtime: int) -> str:
|
|||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
# TODO check how to support this better
|
# TODO check how to support this better
|
||||||
return str(unixtime)
|
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):
|
if sys.version_info < (3, 11):
|
||||||
dt = datetime.datetime.utcfromtimestamp(unixtime)
|
dt = datetime.datetime.utcfromtimestamp(unixtime)
|
||||||
else:
|
else:
|
||||||
dt = datetime.datetime.fromtimestamp(unixtime, datetime.UTC)
|
dt = datetime.datetime.fromtimestamp(unixtime, datetime.UTC)
|
||||||
r = str(unixtime) + "(" + dt.strftime('%Y-%m-%dT%H:%M:%SZ') + ")"
|
r = str(unixtime) + "(" + dt.strftime('%Y-%m-%dT%H:%M:%SZ') + ")"
|
||||||
else:
|
|
||||||
r = str(unixtime) + "(>MAX:" + str(DATETIME_MAX_UNIXTIME) + ")"
|
|
||||||
return r
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
def format_unit(value: float, binary: bool = False) -> str:
|
||||||
|
if binary:
|
||||||
|
if value > UNIT_G:
|
||||||
|
value = value / UNIT_G
|
||||||
|
unit = "G"
|
||||||
|
elif value > UNIT_M:
|
||||||
|
value = value / UNIT_M
|
||||||
|
unit = "M"
|
||||||
|
elif value > UNIT_K:
|
||||||
|
value = value / UNIT_K
|
||||||
|
unit = "K"
|
||||||
|
else:
|
||||||
|
unit = ""
|
||||||
|
else:
|
||||||
|
if value > UNIT_g:
|
||||||
|
value = value / UNIT_g
|
||||||
|
unit = "g"
|
||||||
|
elif value > UNIT_m:
|
||||||
|
value = value / UNIT_m
|
||||||
|
unit = "m"
|
||||||
|
elif value > UNIT_k:
|
||||||
|
value = value / UNIT_k
|
||||||
|
unit = "k"
|
||||||
|
else:
|
||||||
|
unit = ""
|
||||||
|
return ("%.1f %s" % (value, unit))
|
||||||
|
|
||||||
|
|
||||||
|
def limit_str(content: str, limit: int) -> str:
|
||||||
|
length = len(content)
|
||||||
|
if limit > 0 and length >= limit:
|
||||||
|
return content[:limit] + ("...(shortened because original length %d > limit %d)" % (length, limit))
|
||||||
|
else:
|
||||||
|
return content
|
||||||
|
|
||||||
|
|
||||||
|
def textwrap_str(content: str, limit: int = 2000) -> str:
|
||||||
|
# TODO: add support for config option and prefix
|
||||||
|
return textwrap.indent(limit_str(content, limit), " ", lambda line: True)
|
||||||
|
|
||||||
|
|
||||||
|
def dataToHex(data, count):
|
||||||
|
result = ''
|
||||||
|
for item in range(count):
|
||||||
|
if ((item > 0) and ((item % 8) == 0)):
|
||||||
|
result += ' '
|
||||||
|
if (item < len(data)):
|
||||||
|
result += '%02x' % data[item] + ' '
|
||||||
|
else:
|
||||||
|
result += ' '
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def dataToAscii(data, count):
|
||||||
|
result = ''
|
||||||
|
for item in range(count):
|
||||||
|
if (item < len(data)):
|
||||||
|
char = chr(data[item])
|
||||||
|
if char in ascii_letters or \
|
||||||
|
char in digits or \
|
||||||
|
char in punctuation or \
|
||||||
|
char == ' ':
|
||||||
|
result += char
|
||||||
|
else:
|
||||||
|
result += '.'
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def dataToSpecial(data, count):
|
||||||
|
result = ''
|
||||||
|
for item in range(count):
|
||||||
|
if (item < len(data)):
|
||||||
|
char = chr(data[item])
|
||||||
|
if char == '\r':
|
||||||
|
result += 'C'
|
||||||
|
elif char == '\n':
|
||||||
|
result += 'L'
|
||||||
|
elif (ord(char) & 0xf8) == 0xf0: # assuming UTF-8
|
||||||
|
result += '4'
|
||||||
|
elif (ord(char) & 0xf0) == 0xf0: # assuming UTF-8
|
||||||
|
result += '3'
|
||||||
|
elif (ord(char) & 0xe0) == 0xe0: # assuming UTF-8
|
||||||
|
result += '2'
|
||||||
|
else:
|
||||||
|
result += '.'
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def hexdump_str(content: str, limit: int = 2000) -> str:
|
||||||
|
result = "Hexdump of string: index <bytes> | <ASCII> | <CTRL: C=CR L=LF 2/3/4=UTF-8-length> |\n"
|
||||||
|
index = 0
|
||||||
|
size = 16
|
||||||
|
bytestring = content.encode("utf-8") # assuming UTF-8
|
||||||
|
length = len(bytestring)
|
||||||
|
|
||||||
|
while (index < length) and (index < limit):
|
||||||
|
data = bytestring[index:index+size]
|
||||||
|
hex = dataToHex(data, size)
|
||||||
|
ascii = dataToAscii(data, size)
|
||||||
|
special = dataToSpecial(data, size)
|
||||||
|
result += '%08x ' % index
|
||||||
|
result += hex
|
||||||
|
result += '|'
|
||||||
|
result += '%-16s' % ascii
|
||||||
|
result += '|'
|
||||||
|
result += '%-16s' % special
|
||||||
|
result += '|'
|
||||||
|
result += '\n'
|
||||||
|
index += size
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def hexdump_line(line: str, limit: int = 200) -> str:
|
||||||
|
result = ""
|
||||||
|
length_str = len(line)
|
||||||
|
bytestring = line.encode("utf-8") # assuming UTF-8
|
||||||
|
length = len(bytestring)
|
||||||
|
size = length
|
||||||
|
if (size > limit):
|
||||||
|
size = limit
|
||||||
|
|
||||||
|
hex = dataToHex(bytestring, size)
|
||||||
|
ascii = dataToAscii(bytestring, size)
|
||||||
|
special = dataToSpecial(bytestring, size)
|
||||||
|
result += '%3d/%3d' % (length_str, length)
|
||||||
|
result += ': '
|
||||||
|
result += hex
|
||||||
|
result += '|'
|
||||||
|
result += ascii
|
||||||
|
result += '|'
|
||||||
|
result += special
|
||||||
|
result += '|'
|
||||||
|
result += '\n'
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def hexdump_lines(lines: str, limit: int = 200) -> str:
|
||||||
|
result = "Hexdump of lines: nr chars/bytes: <bytes> | <ASCII> | <CTRL: C=CR L=LF 2/3/4=UTF-8-length> |\n"
|
||||||
|
counter = 0
|
||||||
|
for line in lines.splitlines(True):
|
||||||
|
result += '% 4d ' % counter
|
||||||
|
result += hexdump_line(line)
|
||||||
|
counter += 1
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def sha256_str(content: str) -> str:
|
||||||
|
_hash = sha256()
|
||||||
|
_hash.update(content.encode("utf-8")) # assuming UTF-8
|
||||||
|
return _hash.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def sha256_bytes(content: bytes) -> str:
|
||||||
|
_hash = sha256()
|
||||||
|
_hash.update(content)
|
||||||
|
return _hash.hexdigest()
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
# This file is part of Radicale - CalDAV and CardDAV server
|
# 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
|
# 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
|
# 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
|
assert pathutils.sanitize_path(path) == path
|
||||||
if path != "/.web":
|
if path != "/.web":
|
||||||
return httputils.redirect(base_prefix + "/.web")
|
return httputils.redirect(base_prefix + "/.web")
|
||||||
return client.OK, {"Content-Type": "text/plain"}, "Radicale works!"
|
return client.OK, {"Content-Type": "text/plain"}, "Radicale works!", None
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
# Copyright © 2008 Nicolas Kandel
|
# Copyright © 2008 Nicolas Kandel
|
||||||
# Copyright © 2008 Pascal Halter
|
# Copyright © 2008 Pascal Halter
|
||||||
# Copyright © 2008-2015 Guillaume Ayoub
|
# 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
|
# 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
|
# 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
|
import xml.etree.ElementTree as ET
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from http import client
|
from http import client
|
||||||
from typing import Dict, Mapping, Optional
|
from typing import Dict, Mapping, Optional, Union
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
from radicale import item, pathutils
|
from radicale import item, pathutils
|
||||||
@@ -56,7 +57,7 @@ for short, url in NAMESPACES.items():
|
|||||||
ET.register_namespace("" if short == "D" else short, url)
|
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."""
|
"""Indent an ElementTree ``element`` and its children."""
|
||||||
def pretty_xml_recursive(element: ET.Element, level: int) -> None:
|
def pretty_xml_recursive(element: ET.Element, level: int) -> None:
|
||||||
indent = "\n" + level * " "
|
indent = "\n" + level * " "
|
||||||
@@ -71,6 +72,9 @@ def pretty_xml(element: ET.Element) -> str:
|
|||||||
sub_element.tail = indent
|
sub_element.tail = indent
|
||||||
elif level > 0 and not (element.tail or "").strip():
|
elif level > 0 and not (element.tail or "").strip():
|
||||||
element.tail = indent
|
element.tail = indent
|
||||||
|
|
||||||
|
if element is None:
|
||||||
|
return ""
|
||||||
element = copy.deepcopy(element)
|
element = copy.deepcopy(element)
|
||||||
pretty_xml_recursive(element, 0)
|
pretty_xml_recursive(element, 0)
|
||||||
return '<?xml version="1.0"?>\n%s' % ET.tostring(element, "unicode")
|
return '<?xml version="1.0"?>\n%s' % ET.tostring(element, "unicode")
|
||||||
|
|||||||
@@ -29,13 +29,31 @@ skip_install = True
|
|||||||
|
|
||||||
[tool:isort]
|
[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_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]
|
[flake8]
|
||||||
# Only enable default tests (https://github.com/PyCQA/flake8/issues/790#issuecomment-812823398)
|
# Only enable default tests (https://github.com/PyCQA/flake8/issues/790#issuecomment-812823398)
|
||||||
# DNE: DOES-NOT-EXIST
|
# DNE: DOES-NOT-EXIST
|
||||||
select = E,F,W,C90,DNE000
|
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
|
extend-exclude = build
|
||||||
|
|
||||||
[mypy]
|
[mypy]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# This file is part of Radicale - CalDAV and CardDAV server
|
# This file is part of Radicale - CalDAV and CardDAV server
|
||||||
# Copyright © 2009-2017 Guillaume Ayoub
|
# Copyright © 2009-2017 Guillaume Ayoub
|
||||||
# Copyright © 2017-2018 Unrud <unrud@outlook.com>
|
# 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
|
# 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
|
# 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
|
# When the version is updated, a new section in the CHANGELOG.md file must be
|
||||||
# added too.
|
# added too.
|
||||||
VERSION = "3.5.5.dev"
|
VERSION = "3.6.1.dev"
|
||||||
|
|
||||||
with open("README.md", encoding="utf-8") as f:
|
with open("README.md", encoding="utf-8") as f:
|
||||||
long_description = f.read()
|
long_description = f.read()
|
||||||
@@ -36,9 +36,11 @@ web_files = ["web/internal_data/css/icon.png",
|
|||||||
"web/internal_data/fn.js",
|
"web/internal_data/fn.js",
|
||||||
"web/internal_data/index.html"]
|
"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",
|
"pika>=1.1.0",
|
||||||
"requests",
|
"requests",
|
||||||
|
"packaging",
|
||||||
]
|
]
|
||||||
bcrypt_requires = ["bcrypt"]
|
bcrypt_requires = ["bcrypt"]
|
||||||
argon2_requires = ["argon2-cffi"]
|
argon2_requires = ["argon2-cffi"]
|
||||||
|
|||||||
Reference in New Issue
Block a user